Claude Codeのmodでui.viewを使い、サブエージェントの会話を表示する
Claude Code v2.1.300のmod API「$.ui.view」で、指定したエージェントのトランスクリプトをメイン画面に出す方法です。agentIdの取り方と、戻し方を書きます。
$.ui.view({ agentId })は、指定したエージェントのトランスクリプトをメインの表示に出すmod APIです。$.ui.view({})と空のオブジェクトで呼ぶと、元の表示に戻ります。Claude Code v2.1.300で加わりました。
/watchのような自作コマンド1つで、サブエージェントの会話へ飛んで戻ってこられます。この記事では、agentIdの入手元、コマンドへの組み込み方、公式ドキュメントに載っていない部分の扱いを順に書きます。
$.ui.viewは何を切り替えるのか
v2.1.300の更新履歴には、次の1行があります。
Added
$.ui.view({ agentId })for mods: shows an agent's transcript in the main view when you ask, and$.ui.view({})goes back
読み取れることは3つです。
| 呼び方 | 起きること |
|---|---|
$.ui.view({ agentId }) | 起きることそのエージェントのトランスクリプトがメインの表示に出る |
$.ui.view({}) | 起きること元の表示に戻る |
| 呼ぶタイミング | 起きることユーザーが頼んだとき("when you ask") |
「when you ask」とあるので、ユーザーが頼んだときに呼ぶ使い方が基本のようです。呼ぶ起点は、コマンドやキー操作などユーザーの操作にするのが無難です。
同じ画面系のAPIでも、$.ui.openとは役割が違います。$.ui.openは、トランスクリプトの横やプロンプトの上に「ペイン」と呼ぶ領域を足します。$.ui.viewは、足すのではなくメインの表示そのものを別のエージェントの会話に切り替えます。
| API | 画面で起きること | 元に戻す方法 |
|---|---|---|
$.ui.view({ agentId }) | 画面で起きることメインの表示が、そのエージェントの会話に替わる | 元に戻す方法$.ui.view({}) |
$.ui.open({ id }) | 画面で起きることペインが1つ増える。幅が足りないと置かれず、isPlacedがfalseで返る | 元に戻す方法$.ui.close({ id })、またはcloseOnEscapeでEsc |
$.ui.toast(text) | 画面で起きること通知が出る。何も指定しなければ4秒で消える | 元に戻す方法不要 |
$.ui.status(text) | 画面で起きることプロンプトの下に1行出て、書き換えるまで残る | 元に戻す方法別の文字列で上書き |
会話を読み込むならview、会話の脇に集計や操作ボタンを置くならopenです。両者は排他ではなく、viewで切り替えた先にペインを開いた場合の描画は、ドキュメントに記述がありません。
agentIdはどこで手に入るか
$.ui.viewに渡すagentIdは、modが自分で拾います。リファレンスと更新履歴から、入手元は3系統あります。
- イベントの
e.agentId:tool.checkでは、サブエージェントかインプロセスのチームメイトが呼んだときに入り、メインの会話の呼び出しには入りません(v2.1.290以降)。turn.stepとturn.completeも、サブエージェントの分ではe.agentIdが付きます $.agent.list(): このセッションのサブエージェントとチームメイトを返します。各要素にはstatusがあり、pending・running・waiting・idle・completed・failed・killedのいずれかですagent.spawnフック: サブエージェントやチームメイトの開始直前に届きます
イベントのe.agentIdをそのまま$.ui.viewに渡す形が自然です。ただ、リファレンスの記述は「サブエージェントのIDを示す」までで、$.ui.viewが受け取るagentIdと同じ値かどうかは明記されていません。最初の1回は、実際に呼んで画面が切り替わるかで確かめます。
手がかりは1つあります。$.session.send({ to: { agentId }, text })は、agentIdに「$.agent.list()から取ったID」を渡す仕様です。つまりlist()の要素から取れるIDは、agentIdという名前で別のAPIに渡せます。viewも同じ名前の引数なので同系統の値と考えられます。ただしapi.mdの記述はsendの引数までで、viewの引数には触れていません。
$.agent.list()の要素にagentIdというフィールドがあるかも、リファレンスの表には書かれていません。要素の形を知るには、実際に返った中身を見るのが早い方法です。次のコマンドは、list()の結果をそのままトランスクリプトに出します。
on('command.run', { command: 'agents' }, async ($, e) => {
const agents = await $.agent.list()
// 要素の形が分からないうちは、そのまま文字列にして見る
return { text: JSON.stringify(agents, null, 2) }
})statusがcompletedやkilledの要素にviewが使えるかも、記述がありません。終わったサブエージェントの会話が読めるかは、実際に試して確かめる部分です。型定義でも確認できます。
コマンドにして切り替える
Claude Codeのmodは、$.command.registerでスラッシュコマンドを足せます。$.ui.viewを呼ぶコマンドは、次の形で書けます。リファレンスの仕様に沿った例で、agentIdを引数で渡す前提です。
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'watch',
description: 'Show a subagent transcript in the main view',
argumentHint: '[agentId]',
immediate: true,
})
return next(e)
})
on('command.run', { command: 'watch' }, async ($, e) => {
const agentId = e.args.trim()
if (agentId === '') {
// 引数なしなら元の表示に戻る
await $.ui.view({})
} else {
await $.ui.view({ agentId })
}
// トランスクリプトに何も書かない
return {}
})e.argsはコマンド名のあとに打った文字列で、何も打たなければ空文字列です。コマンドが{}を返すと、トランスクリプトには何も印字されません。画面を切り替えるだけのコマンドでは、この返し方が合います。
サブエージェントが走っている最中に切り替えたいなら、登録にimmediate: trueを足します。何も指定しないと、Claudeが作業している間はコマンドを走らせられません。サブエージェントを覗きたい場面の多くは、まさにClaudeが作業中のときです。また、コマンドが返すtextはトランスクリプトに出るだけでなく、Claudeも読みます。「No subagent has run yet.」のようなエラー文も、Claudeに伝わる点に注意してください。
組み込みコマンドと同じ名前は登録できません。watchが使えない環境なら、別の名前に変えます。名前が取られていると$.command.registerが例外を投げ、そのフックの後続処理も走らなくなります。
直近のサブエージェントを覚えておく
IDを手で打つのは面倒です。サブエージェントのturn.stepでagentIdを覚えておけば、引数なしの/watchで直近の1つへ飛べます。
// 直近に動いたサブエージェントのID(modのモジュール内の変数)
let lastAgentId = null
on('turn.step', async ($, e, next) => {
// e.agentId があるのはサブエージェントのリクエスト
if (e.agentId) lastAgentId = e.agentId
return next(e)
})
on('command.run', { command: 'watch' }, async ($, e) => {
const typed = e.args.trim()
const target = typed || lastAgentId
if (!target) {
return { text: 'No subagent has run yet.' }
}
await $.ui.view({ agentId: target })
return {}
})戻すコマンドは別に用意します。/watch-backのような名前で$.ui.view({})だけを呼ぶ形です。/watchの引数なしを「戻る」に使うと、上の例のように直近のIDへの切り替えと衝突します。
この例の変数は、モジュールを読み込み直すと空に戻ります。セッションをまたいで残したいなら、全セッションで共有されるキーバリューストアの$.storeを使います。
$.storeには落とし穴があります。同じマシンの全セッションが1つのストアを共有するため、lastAgentIdのような値を固定のキーで保存すると、別のセッションのサブエージェントIDを拾って切り替えようとします。キーにセッションのID($.sessionのid)を含めて分けます。
もう1つ、agent.spawnフックでIDを控える設計には限界があります。ClaudeがSendMessageツールで既存のサブエージェントを再開したとき、agent.spawnフックは再び走りません。再開された側のIDは、turn.stepのe.agentIdか$.agent.list()で拾い直す必要があります。チームメイトはagent.spawnのe.isTeammateがtrueになるので、サブエージェントと分けて扱いたい場合の目印になります。
サブエージェントの完了を通知して、そのまま飛ぶ
切り替えを自分で打つより、サブエージェントが終わった時点で通知を出し、IDを添えるほうが使いやすい場面もあります。turn.completeは、サブエージェントのターンでもe.agentId付きで発火します。e.durationMsはそのターンにかかった時間です。
on('turn.complete', async ($, e, next) => {
if (e.agentId) {
const sec = Math.round(e.durationMs / 1000)
// 通知にIDを載せる。続けて /watch <ID> と打てば会話を読める
await $.ui.toast('subagent ' + e.agentId + ' done in ' + sec + 's')
}
return next(e)
})turn.completeは観測用のイベントなので、next(e)で素通しすれば本体の動きは変わりません。トーストは何も指定しなければ4秒で消えるため、長いIDを手で写すには短すぎます。IDを読むのが目的なら、$.ui.statusに1行残す形にして、次の通知で上書きします。
画面が出る場所と、書かれていないこと
modのフックは、どのプロバイダーのセッションでも動きます。一方、modが画面に描いたものが出るのは、ターミナル、デスクトップアプリ、VS Code拡張(バージョン2.1.299以降)です。claude -pやクラウドセッションのように、描く面がない場所もあります。面ごとの対応はClaude Code modのUIが出る面と出ない面の対応表に表があるので、ここでは$.ui.viewに絞ります。
$.ui.viewがそうした面でどう振る舞うかは、リファレンスに記述がありません。近い例として、v2.1.299の更新履歴は、VS Code拡張やclaude -pのように描く面がないセッションで、$.ui.openがペインを「置いた」と報告してしまう不具合の修正を挙げています。$.ui.viewも、描く面のないセッションでは何も起きないかもしれません。そうした環境でも動かすmodなら、claude -pでコマンドを呼んで試しておきます。
リファレンスの$.uiの行にも注意点があります。メソッド一覧はresolveからblitまでの15個で、viewは載っていません。更新履歴に先に書かれ、ドキュメントの表が追いついていない状態です。各メソッドの引数と戻り値は、型定義が持っています。対話セッションで--plugin-dirから読み込んだmodには、.claude-plugin/types/に型定義が書き出されます。中心はclaude-code/index.d.tsで、全メソッドにコメントと例が付きます。公式ドキュメントの注記も、ページと食い違うときは書き出されたファイルを信頼するよう求めています。
型定義の中で'ui.view'を検索すれば、引数と戻り値が分かります。
| 知りたいこと | 見る場所 |
|---|---|
$.ui.viewが加わったバージョン | 見る場所更新履歴のv2.1.300 |
$.ui.viewの引数と戻り値 | 見る場所.claude-plugin/types/の型定義 |
agentIdが入るイベント | 見る場所イベントの一覧(tool.check・turn.step・turn.complete) |
| サブエージェントの状態 | 見る場所$.agent.list()のstatus |
切り替わらないときの切り分け
/watchを打っても画面が変わらないときは、次の順で見ます。
- コマンド自体が走っているか:
$.ui.toast('watch called')を1行足して通知が出るか見ます。出なければ登録に失敗しています。組み込みコマンドとの名前衝突が典型です - Claudeの作業中に打っていないか: 登録に
immediate: trueが無いと、作業中はコマンドが走りません。サブエージェントの最中に打つ/watchでは、真っ先に疑う点です - 描く面があるか:
claude -pやクラウドセッションでは画面がありません - IDが空でないか: 引数なしで
lastAgentIdがnullのままだと、上の例では切り替えず「No subagent has run yet.」を返します。サブエージェントが1度も動いていない状態です - IDの出どころ:
e.agentIdは、tool.checkならメインの会話の呼び出しでは付きません。メイン側のイベントから拾った値はundefinedです
1から3が先に当たる原因です。4と5は、turn.stepでIDを覚える例ならif (e.agentId)の判定で除かれ、サブエージェントを1つ走らせてから打てば解消します。トーストで通知が出て切り替えだけが起きないなら、残る疑いはagentIdの値そのものです。
組み合わせると何ができるか
$.ui.viewは単独でも使えますが、ほかのmodと組むと効きます。サブエージェントの権限判定を見分けるtool.checkでサブエージェントと組織承認を見分けるフックと同じagentIdを共有すれば、「このサブエージェントのBash呼び出しが止まった」という通知から、その会話へそのまま飛べます。
もう1つは、サブエージェントの最終報告を審査する仕組みとの併用です。SubagentHandbackでauto modeが報告を止めたとき、止まった理由を会話のトランスクリプトで読みたい場面があります。modがIDを控えておけば、/watchで該当の会話に移れます。
v2.1.300のほかのmod関連の変更はClaude Code v2.1.300にまとめています。agent.spawnでワークフローのエージェントのpromptとmodelを書き換えられるようになった点も、そこにあります。