From 24ec69b060915eebd5b3fbb8c48b2d8a0538318c Mon Sep 17 00:00:00 2001 From: takecchi Date: Thu, 20 Aug 2026 19:40:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Claude=20/=20Codex=20/=20Grok=20?= =?UTF-8?q?=E3=81=AE=203=20=E3=82=A8=E3=83=BC=E3=82=B8=E3=82=A7=E3=83=B3?= =?UTF-8?q?=E3=83=88=E5=AF=BE=E5=BF=9C=E3=82=92=E4=BB=95=E4=B8=8A=E3=81=92?= =?UTF-8?q?=E3=82=8B=EF=BC=88Phase=20D=20=E3=81=AE=E6=AE=8B=E3=82=8A?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase D の未着手 3 項目(docs/TASKS.md)を入れて、エージェント抽象の 「混在させても嘘をつかない」ところまで通す。 ## capability による UI 縮退 判定を純粋な `core/agent-capabilities.ts` に集約(`supportsCapability` = **不明なら縮退しない** / `capabilityLookup` / `agentSupports` / `showsAccountUsage`)。「値が 0 だから自然に消える」偶然に頼るのをやめた: - `cost`: `totalCostUsd(states, reportsCost)` が報告しない provider を合計から外す (混在時に「Claude ぶんの合計」を全体として出さない) - `usage`: 一覧のゲージを `showsAccountUsage` で出し分け、**同じ純関数**を `usage-poller` の `enabled` にも渡す = 出さないゲージのために 5 分ごとの `claude` probe を立てない - `permissions`: フッタの確認モード表示を `確認モード (非対応)` に差し替える (Codex では原理的に聞かれないのに「確認モード」と言い切っていた) - `transcript`: 持たない provider のセッションでは復元時に読みにも行かない ## どのセッションが何で走っているかの表示 - ヘッダに新規セッションの既定エージェント(プラン・モデルと同じ行) - 一覧の行は**混在時だけ**エージェント列を出す(`usesMultipleAgents` + `showsAgentColumn`。列が奪う幅はブランチ列の判定から差し引く) - 詳細は追加指示のプレースホルダに相手の名前(1 行も増やさない) - ログは `logLines(…, dividerFor)` が `LogEntry.agent` の境界に区切りを 1 本挿む ## 引き継ぎプロンプト `core/agent-handoff.ts`(英語固定 = AI 向け文字列)。ブランチ・最初の指示・ 直前の指示を並べ、続ける前に自分で `git status` / `git diff` を読むよう促す。 `composeSystemPrompt` の最後の節として **切替直後の 1 回だけ**渡す(キューへ 指示として積まないので、切替直後に余分なターンは走らない)。 refs #107 --- .claude/rules/sdk-integration.md | 13 +++- CLAUDE.md | 3 + README.md | 14 ++++- docs/ARCHITECTURE.md | 52 +++++++++++++-- docs/TASKS.md | 52 +++++++++++---- src/bootstrap/restore-sessions.ts | 17 ++++- src/bootstrap/usage-poller.ts | 14 +++++ src/core/agent-capabilities.spec.ts | 80 +++++++++++++++++++++++ src/core/agent-capabilities.ts | 80 +++++++++++++++++++++++ src/core/agent-display.spec.ts | 26 ++++++++ src/core/agent-display.ts | 38 +++++++++++ src/core/agent-handoff.spec.ts | 68 ++++++++++++++++++++ src/core/agent-handoff.ts | 98 +++++++++++++++++++++++++++++ src/core/banner-lines.spec.ts | 14 +++++ src/core/banner-lines.ts | 11 ++++ src/core/cost.spec.ts | 10 +++ src/core/cost.ts | 21 +++++-- src/core/i18n.ts | 33 +++++++++- src/core/index.ts | 3 + src/core/layout.spec.ts | 24 +++++++ src/core/layout.ts | 26 ++++++++ src/core/scroll.spec.ts | 40 ++++++++++++ src/core/scroll.ts | 19 +++++- src/core/session.spec.ts | 30 ++++++++- src/core/session.ts | 21 +++++++ src/core/system-prompt.ts | 14 ++++- src/main.tsx | 10 +++ src/ui/session-detail.tsx | 31 +++++++-- src/ui/session-list.tsx | 46 ++++++++++++-- src/ui/status-footer.spec.tsx | 10 +++ src/ui/status-footer.tsx | 16 ++++- 31 files changed, 889 insertions(+), 45 deletions(-) create mode 100644 src/core/agent-capabilities.spec.ts create mode 100644 src/core/agent-capabilities.ts create mode 100644 src/core/agent-display.spec.ts create mode 100644 src/core/agent-display.ts create mode 100644 src/core/agent-handoff.spec.ts create mode 100644 src/core/agent-handoff.ts diff --git a/.claude/rules/sdk-integration.md b/.claude/rules/sdk-integration.md index 902e82b..1e8ddbb 100644 --- a/.claude/rules/sdk-integration.md +++ b/.claude/rules/sdk-integration.md @@ -1,7 +1,8 @@ # エージェント連携規約(Claude Agent SDK) コーディングエージェントとの境界と、`@anthropic-ai/claude-agent-sdk` を触るときの不変条件。 -**`core/agent-ports.ts` / `core/agent-events.ts` / `core/claude-adapter.ts` / `core/claude-parse.ts` / +**`core/agent-ports.ts` / `core/agent-events.ts` / `core/agent-capabilities.ts` / +`core/agent-handoff.ts` / `core/claude-adapter.ts` / `core/claude-parse.ts` / `core/claude-errors.ts` / `core/codex-adapter.ts` / `core/codex-parse.ts` / `core/codex-errors.ts` / `core/grok-adapter.ts` / `core/grok-parse.ts` / `core/grok-errors.ts` / `core/jsonl.ts` / `core/session.ts` / `utils/model-catalog.ts` / `utils/codex.ts` / `utils/grok.ts` / @@ -30,7 +31,15 @@ provider 形への写像はアダプタが行う(Claude は `claude-adapter.ts` の `canUseTool`)。 - その provider に無い機能は `AgentCapabilities` で表明する(`permissions` / `interrupt` / `setModel` / `resume` / `modelCatalog` / `usage` / `cost` / `transcript`)。UI は capability を - 見て縮退する(今つながっているのは `/model` と `Ctrl+C`。残りは Phase D)。 + 見て縮退する。**判定は純粋な `core/agent-capabilities.ts` を通す**(`supportsCapability` / + `capabilityLookup` / `agentSupports` / `showsAccountUsage`)。守ること 2 つ: + - **capability が分からないときは縮退しない**(未登録の provider・`agent` を持たない古い + セッションで機能を隠すと、動くはずの操作が黙って消える)。 + - **「値が 0 だから自然に消える」に頼らない**。コスト・使用状況・トランスクリプト復元は + Claude 由来の仕組みで、他 provider は何も供給しないので今は勝手に消えるが、それは偶然。 + 混在時に「Claude ぶんの合計」を全体として出す余地が残るので明示的な分岐にする + (どこで何を縮退させているかの表は docs/ARCHITECTURE.md)。表示を縮退させたら + **取得も止める**(出さないゲージのために `claude` の probe を立てない)。 `AgentRun.interrupt` / `setModel` は optional。新しいアダプタは `NO_CAPABILITIES` から始めて、実装できたものだけ true にする。 diff --git a/CLAUDE.md b/CLAUDE.md index 15484a6..dff7bb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,9 @@ CI(`.github/workflows/ci.yml`)は `lint → typecheck → test → build`。 | セッションの状態・遷移 | `core/types.ts`(union)/ `core/status-meta.ts`(性質の表)/ `core/status-reducer.ts`(純粋 reducer) | | 別のエージェントに対応させる | `core/agent-ports.ts`(`AgentAdapter` / `AgentCapabilities` / `PermissionDecision` = DI 境界)/ `core/agent-events.ts`(`AgentEvent` の語彙 + 全 provider 共通の畳み込み `applyAgentEvent`)/ `core/claude-adapter.ts`・`core/claude-parse.ts`・`core/claude-errors.ts`(Claude 実装の 3 点セット)/ `core/codex-adapter.ts`・`core/codex-parse.ts`・`core/codex-errors.ts` + `core/codex-events.ts`(JSONL の型)・`core/codex-models.ts`・`core/codex-rollout.ts`(rollout から解決済みモデル)・`utils/codex.ts`(`codex exec` の起動 = 唯一の I/O)/ `core/grok-adapter.ts`・`core/grok-parse.ts`・`core/grok-errors.ts` + `core/grok-events.ts`(ACP メッセージの型)・`core/grok-models.ts`・`utils/grok.ts`(`grok agent stdio` の起動 = 唯一の I/O)/ 行区切り JSON の枠切りは provider 非依存の `core/jsonl.ts`(Codex / Grok 共用)/ アダプタの登録は `bootstrap/build-manager.ts` の `buildAgents` | | SDK メッセージの解釈 | `core/claude-parse.ts` **のみ**(`parseClaudeMessage`: SDKMessage → `AgentEvent[]`)+ `core/__fixtures__/*.jsonl`。Codex は `core/codex-parse.ts`(`parseCodexEvent`: `codex exec --json` の JSONL → `AgentEvent[]`)+ `core/__fixtures__/codex-*.jsonl`。Grok は `core/grok-parse.ts`(`createGrokParser`: ACP = JSON-RPC over stdio の通知 → `AgentEvent[]`)+ `core/__fixtures__/grok-*.jsonl` | +| capability による UI 縮退(コスト・使用状況・確認モード・ログ復元) | `core/agent-capabilities.ts`(`supportsCapability` = **不明なら縮退しない** / `capabilityLookup` / `agentSupports` / `showsAccountUsage`・純粋)/ `core/cost.ts` の `totalCostUsd(states, reportsCost)` / `bootstrap/usage-poller.ts` の `enabled` / `bootstrap/restore-sessions.ts` / `ui/status-footer.tsx` の `confirmSupported` | +| どのセッションが何で走っているかの表示 | `core/agent-display.ts`(`sessionAgentId` / `usesMultipleAgents`)/ `core/layout.ts` の `showsAgentColumn`(混在時だけ列を出す)/ `core/banner-lines.ts` の `agent`(ヘッダ = 既定)/ `core/scroll.ts` の `logLines(…, dividerFor)`(ログの切替区切り)/ `m.detail.followupPlaceholder(agent)`(詳細の入力欄) | +| エージェント切替時の引き継ぎ | `core/agent-handoff.ts`(`handoffInstruction` / `lastUserInstruction`・英語固定 = AI 向け文字列)/ `core/system-prompt.ts` の `handoff` 節 / `core/session.ts` の `setAgent`(**使い捨て**で次の `open()` が消費) | | エージェントの切替(`/agent`)| `core/session-manager.ts`(一覧=既定: `getDefaultAgentId` / `setDefaultAgent`・詳細=切替: `listAgents` / `getSessionAgent` / `setSessionAgent`)/ `ui/agent-select.tsx`(`mode:'default'`=一覧 / `'session'`=詳細)/ `core/status-reducer.ts` の `agent_switched` | | エージェントの導入・ログイン検出 | `core/agent-ports.ts` の `AgentAdapter.checkAvailability` / `AgentAvailability` / `core/agent-availability.ts`(`resolveDefaultAgentId` / `noAgentInstalled`・純粋)/ `utils/claude.ts` の `detectClaudeAvailability`・`utils/codex.ts` の `detectCodexAvailability`・`utils/grok.ts` の `detectGrokAvailability`(実 I/O)/ `SessionManager.checkAgents`(集約・キャッシュ)/ `ui/hooks.ts` の `useAgentAvailability` | | エージェントに codiva 内でサインイン(`/login` / `/agent` の `l`)| `core/agent-login.ts`(URL/コード抽出・ANSI 除去・純粋)/ `utils/agent-login.ts`(`spawnLogin` = プロセス起動)/ `ui/login-dialog.tsx` / `core/agent-ports.ts` の `AgentAdapter.login` + `AgentLoginProcess` / `SessionManager.startLogin` / `canLogin` / `refreshAgents` | diff --git a/README.md b/README.md index f57577d..9ef4887 100644 --- a/README.md +++ b/README.md @@ -303,17 +303,27 @@ Codiva v0.3.1 3 セッション 一度使ったエージェントの会話 id はセッションごとに保存されるので、Claude → Codex → Claude と戻したときは**元の会話の続き**から再開します(codiva を再起動しても同じです)。 +会話の文脈は渡せませんが、**切替先には「引き継ぎの覚書」を 1 回だけ渡します** — ブランチ名・そのセッションの最初の指示・直前の指示と、「続ける前に `git status` / `git diff` で作業ツリーの状態を自分で確かめること」を伝えるので、済んだ作業をやり直したり直前の指示を無視したりしにくくなります(切替直後に余分なターンは走りません。次にあなたが指示を送ったときに一緒に渡ります)。 + +**どのセッションが何で走っているかは画面で分かります。** + +- ヘッダに `エージェント: Claude` として**新規セッションの既定**が出ます。 +- 一覧の行には、**複数のエージェントが混ざっているときだけ**エージェント名の列が出ます(全部同じならヘッダと重複するだけなので、その幅はタイトルやブランチ名に回します)。 +- 詳細ビューの入力欄には `Claude に追加の指示を入力…` のように相手の名前が出ます。 +- 途中で切り替えたセッションの会話ログには `── ここから Codex ──` の区切りが入り、どこからが別のエージェントの発言か分かります。 + **Codex セッションの制約**(Claude セッションとの違い): - **ツール使用の許可を尋ねません。** `codex exec` の JSON 出力モードは承認要求を CLI 内部で自動的に拒否してしまい、codiva 側へ上げる手段がありません。そこで codiva は「それらしい許可ダイアログ」を出さず、**サンドボックスを唯一の安全弁**にしています(設定 `codexSandbox`。既定の `workspace-write` では書き込みがセッションの worktree 内に限定されます)。`質問あり` の状態にもなりません。 -- **コストを表示しません。** Codex はターン終了時にトークン数しか返さず、金額もアカウント全体の使用状況も運びません。ヘッダの合計コストと使用状況ゲージには Codex ぶんが含まれません。 +- **コストを表示しません。** Codex はターン終了時にトークン数しか返さず、金額もアカウント全体の使用状況も運びません。ヘッダの合計コストには Codex のセッションを数えません(Claude ぶんだけの金額を「全体」として出さないため)。使用状況ゲージは Claude のアカウントの枠なので、**Codex / Grok だけで作業している間はヘッダに出ません**(取得もしません)。 +- **フッタのモード表示が `確認モード (非対応)` になります。** 許可を尋ねられないので、`確認モード` のままだと「待っていれば聞かれる」と読めてしまうためです(`shift+tab` の切替そのものは効きます)。 - **再起動後にログが復元されません**(セッションの続きを再開すること自体はできます)。ログの再構築は Claude CLI の記録ファイルを読む仕組みで、Codex の記録は形式が異なるためです。 - `/model` の選択肢は Codex 側のモデル一覧(`codex debug models`)になります。一覧を取得できない環境では「デフォルト」だけになります(推測でモデル名を並べません)。`/agent` で provider を切り替えると、互換性のない切替前のモデル指定は CLI 既定へ戻ります。Codex は実行イベントにモデル名を含めないため、`/model` で明示したモデル名をセッション一覧に表示します。 **Grok セッションの制約**(Claude セッションとの違い): - **ツール使用の許可と質問はそのまま届きます。** Codex と違い、Grok は許可要求(`許可待ち`)と質問(`質問あり`)を codiva の双方向のやり取りで上げてくるので、いつもどおりダイアログで応答できます。 -- **コストを表示しません。** Grok はターンの終わりにトークン数しか返さず、金額もアカウント全体の使用状況も運びません。ヘッダの合計コストと使用状況ゲージには Grok ぶんが含まれません。 +- **コストを表示しません。** Grok はターンの終わりにトークン数しか返さず、金額もアカウント全体の使用状況も運びません。ヘッダの合計コストには Grok のセッションを数えず、使用状況ゲージ(Claude のアカウントの枠)も Grok だけで作業している間は出ません。 - **再起動後にログが復元されません**(セッションの続きを再開すること自体はできます)。ログの再構築は Claude CLI の記録ファイルを読む仕組みで、Grok の記録は形式が異なるためです。 - `/model` の選択肢は Grok 側のモデル一覧になります。一覧を取得できない環境では「デフォルト」だけになります(推測でモデル名を並べません)。`/agent` で provider を切り替えると、互換性のない切替前のモデル指定は CLI 既定へ戻ります。Codex と違い Grok は**実際に動いているモデル名を自分で教えてくれる**ので、`/model` で明示していなくてもセッション一覧にモデル名が出ます。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 44371d9..406fcb8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,6 +63,9 @@ codiva/ │ │ ├── status-reducer.ts # reduce(state, CodivaEvent): SessionState(codiva 起点のイベント・純関数) │ │ ├── agent-ports.ts # エージェントの DI 境界(AgentAdapter/AgentRun/AgentCapabilities/PermissionDecision・leaf) │ │ ├── agent-events.ts # AgentEvent の語彙 + applyAgentEvent()(全 provider 共通の畳み込み・純粋) +│ │ ├── agent-capabilities.ts # capability による UI 縮退の判定(不明なら縮退しない・showsAccountUsage) +│ │ ├── agent-display.ts # 「どのセッションが何で走っているか」の判定(sessionAgentId / usesMultipleAgents) +│ │ ├── agent-handoff.ts # 切替先へ渡す状況説明(英語固定・systemPrompt に 1 回だけ載る) │ │ ├── claude-adapter.ts # Claude 用 AgentAdapter(query() の組み立て・canUseTool の写像) │ │ ├── claude-parse.ts # parseClaudeMessage()(SDK メッセージ形状の解釈を集約・純粋) │ │ ├── claude-errors.ts # Claude CLI の失敗分類(文言/typed kind/HTTP status → AgentStopCause) @@ -262,6 +265,20 @@ provider ごとの resume id を控え、**これは永続化する**(`state.j 会話を resume しようとして壊れる)。 - **ログ行の帰属**(`LogEntry.agent`)は**切替が起きたあとだけ**刻む。単一エージェントで完結する セッションのログ行の形を変えないため(切替を使っていないユーザーには何も増えない)。 + 詳細ビューはこの帰属が変わる境界に区切り行(`── ここから Codex ──`)を 1 本挿む + (行の挿入は `core/scroll.ts` の `logLines(…, dividerFor)`、文言はカタログ + アダプタの表示名)。 +- **引き継ぎの状況説明を 1 回だけ渡す**(`core/agent-handoff.ts` の `handoffInstruction`)。 + 切替先は前の会話を持たないので、何も渡さないと「途中まで作業された作業ツリー」を白紙から + 見ることになり、済んだ作業をやり直したり直前の指示を無視したりする。ブランチ・最初の指示・ + 直前の指示を並べ、**続ける前に自分で `git status` / `git diff` を読む**よう促す文を + `AgentRunOptions.systemPrompt`(`composeSystemPrompt` の最後の節)に載せる。 + - **使い捨て**にする(`Session` が次の `open()` で消費する)。常設にすると、引き継ぎが済んだ + あとのターンや通信断からの再起動でも「前任者から引き継いだ」と言い続けることになる。 + - **キューへ指示として積まない**。積むと切替直後に「状況を読むだけのターン」が 1 本走り、 + provider のプロセスを無駄に立てる(ユーザーが次の指示を出すまで何も起こらないのが正しい)。 + - 各項目は 1 行に畳んで `MAX_HANDOFF_FIELD_CHARS` で切る(指示文はファイルを丸ごと貼った + ものになりうるので、systemPrompt が本文より大きくなるのを防ぐ)。AI 向けの文字列なので + i18n カタログには置かない(英語固定。`SHARED_IGNORED_FILES_NOTICE` と同じ扱い)。 ### 5. Claude 専用機能は capability で optional 化する @@ -301,10 +318,37 @@ provider が増えてもビュー側の分岐は増えない(未登録のエ 「`claude` でログインし直して」と言わないための配線で、一覧・詳細・デスクトップ通知の 3 経路で効く。 エージェント名は固有名詞なので翻訳しない(モデル名と同じ i18n の例外)。 -> 縮退の配線は Phase D で段階的に入れている。現状効いているのは `/model`(`setModel` / -> `modelCatalog`)・`Ctrl+C`(`interrupt`)・認証文言(`AgentLabel`)で、使用状況ゲージ・ -> コスト・許可ダイアログ・トランスクリプト復元はまだ capability を見ていない(現状は -> 実害が出ていないだけ。[TASKS.md](./TASKS.md) の Phase D)。 +縮退の判定は**純粋な `core/agent-capabilities.ts`** に寄せてある(`supportsCapability` / +`capabilityLookup` / `agentSupports` / `showsAccountUsage`)。要点は 2 つ: + +- **capability が分からないときは縮退しない**(`supportsCapability(undefined, …) === true`)。 + 未登録の provider・`agent` を持たない古いセッションで機能を隠すと、動くはずの操作が黙って + 消える。既存の `caps && !caps.setModel` と同じ規約。 +- **「数字が 0 だから自然に消える」に頼らない**。Codex / Grok は USD を運ばないのでヘッダの + 合計コストは今のところ勝手に消えるが、それは偶然であって、混在時に「Claude ぶんの合計」を + 全体のコストとして出す余地が残る。`AgentCapabilities` を見た明示的な分岐に置き換える。 + +| 縮退する対象 | capability | 見る場所 | 縮退の形 | +|---|---|---|---| +| `/model` のダイアログ | `setModel` / `modelCatalog` | `ui/session-detail.tsx` | 開かずに理由を出す・選択肢を provider 別に出し分け | +| `Ctrl+C` のヒント | `interrupt` | `ui/session-detail.tsx` | ヒント行を出さない | +| 合計コスト(ヘッダ) | `cost` | `core/cost.ts` の `totalCostUsd(states, reportsCost)` | 報告しない provider のセッションを合計に数えない | +| 使用状況ゲージ(ヘッダ) | `usage` | `showsAccountUsage`(一覧の表示 + `bootstrap/usage-poller.ts` の `enabled`) | 使っていなければ**出さないし取りにも行かない**(5 分ごとの probe を立てない) | +| 確認モードのフッタ表示 | `permissions` | `ui/status-footer.tsx` の `confirmSupported` | `確認モード (非対応)` に差し替える(下記) | +| トランスクリプト復元 | `transcript` | `bootstrap/restore-sessions.ts` | その provider のセッションでは読みにも行かない | +| 認証切れの文言 | —(`AgentLabel`) | 一覧・詳細・通知 | 駆動中の provider のコマンド名を出す | + +**確認モードの表示を capability で変える理由**: `permissions: false` の provider(Codex)では +許可ダイアログが原理的に出ない。それでもフッタが `確認モード` と言い切っていたので、 +「待っていれば聞かれる」と読めてしまっていた(ツールは確認なしに実行される)。ダイアログを +偽装しないのと同じ理由で、**モード表示の側を正直にする**。 + +**使用状況ゲージを消す判定**(`showsAccountUsage`)は「新規セッションの既定エージェント、または +`archived` でないセッションのどれかが `usage` を報告する」。ゲージが表しているのは +その provider のアカウントの消費で、Codex / Grok だけで作業している人には読みようがない +(`archived` を数えないのは、乗り換えた人のヘッダにマージ済みのセッション 1 件で残り続けるのを +避けるため)。**表示と取得は同じ純関数を通す**ので、出していないゲージのために +`claude` のサブプロセスが立つことはない。 ### 6. Codex アダプタ: 1 ターン = 1 プロセス diff --git a/docs/TASKS.md b/docs/TASKS.md index 4860b41..cf7929c 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -1284,10 +1284,10 @@ zsh: abort codiva > - `usage` / `cost` は false(ターンはトークン数だけで USD もアカウント全体の使用状況も無い)。 > `transcript` も false。タイトル生成は Codex と同じく Claude の haiku を使い回す。 -## Phase D: capability による UI 縮退 / `/agent` / 引き継ぎ(一部完了) +## Phase D: capability による UI 縮退 / `/agent` / 引き継ぎ ✅ -> Phase B(Codex 対応)に必要なぶんだけ先に入れた。**残りは未着手**なので下の未チェック項目を -> そのまま次の作業単位にする。 +> Phase B(Codex 対応)に必要なぶんだけ先に入れ、残り(capability 縮退の仕上げ・エージェントの +> 表示・引き継ぎプロンプト)を後から入れて完了。 - [x] `/model` の縮退: `SessionManager.getSessionAgent(id).capabilities` を詳細ビューが見て、 `setModel` を持たない provider では**ダイアログを開かず理由を出す**(黙って無反応にしない)。 @@ -1311,12 +1311,20 @@ zsh: abort codiva - [x] **未導入でも起動でき、案内を出す**: 設定 `agent` が無ければ起動時検出で**導入済みのものへ 自動で寄せる**(`resolveDefaultAgentId`、永続はしない)。どれも未導入なら一覧に `agent.noneInstalled` の 1 行を出す(`noAgentInstalled` が全件未導入で確定したときだけ) -- [ ] **残りの capability 縮退**: 使用状況ゲージ(`usage`)・コスト表示(`cost`)・許可ダイアログ - (`permissions`)・トランスクリプト復元(`transcript`)は**まだ capability を見ていない**。 - 現状は実害が出ていないだけ(Codex は USD を運ばないのでヘッダの合計コスト行は - `cost > 0` の条件で自然に出ない / 許可要求がそもそも届かないのでダイアログも出ない / - Claude のトランスクリプトパスに Codex の thread id のファイルは無いので復元が空になるだけ)。 - **混在時に嘘をつく余地が残っている**ので、明示的な分岐に置き換える +- [x] **残りの capability 縮退**: 判定を純粋な `core/agent-capabilities.ts` に集約 + (`supportsCapability` = **不明なら縮退しない** / `capabilityLookup` / `agentSupports` / + `showsAccountUsage`)。「数字が 0 だから自然に消える」偶然に頼るのをやめ、明示的な分岐にした: + - `cost`: `totalCostUsd(states, reportsCost)` が報告しない provider を合計から外す + (混在時に「Claude ぶんの合計」を全体として出さない) + - `usage`: 一覧が `showsAccountUsage`(既定エージェント or `archived` でないセッションの + どれかが `usage` を報告するか)でゲージを出し分け、**同じ純関数**を + `bootstrap/usage-poller.ts` の `enabled` にも渡す = 出さないゲージのために 5 分ごとの + `claude` probe を立てない + - `permissions`: フッタの確認モード表示を `confirmSupported` で `確認モード (非対応)` に + 差し替える(Codex では原理的に聞かれないのに「確認モード」と言い切っていた = + 「待っていれば聞かれる」と読める嘘)。ダイアログ自体は偽装も抑止もしない + - `transcript`: `bootstrap/restore-sessions.ts` が持たない provider のセッションでは + 読みにも行かない(worktree が同じで id が偶然衝突すれば別 provider のログを混ぜる余地) - [x] **TUI 内ログイン**(`/login` コマンド + `/agent` ダイアログの `l`)。端末を明け渡さず ` login` を裏で起動し、出力の認証 URL / デバイスコードをダイアログに出して自動で ブラウザを開く(`core/agent-login.ts` = 進行の畳み込み・純粋 / `utils/agent-login.ts` = @@ -1324,8 +1332,15 @@ zsh: abort codiva / `canLogin` / `refreshAgents`)。Codex は `login --device-auth`、Claude は `auth login`。 **色付き出力の ANSI を剥がしてから URL/コードを拾う**(実測: 拾えず → 修正)。 完了後 `refreshAgents` で状態を再判定 -- [ ] **引き継ぎプロンプトの生成**: 切替先は前の会話を持たないので、worktree の状況(ブランチ・ - 差分・直前の指示)を要約した最初の指示文を組み立てる純関数を core に置く +- [x] **引き継ぎプロンプトの生成**: `core/agent-handoff.ts`(`handoffInstruction` / + `lastUserInstruction`・純粋・英語固定 = AI 向け文字列なので i18n 対象外)。ブランチ・ + 最初の指示・直前の指示を並べ、**続ける前に自分で `git status` / `git diff` を読む**よう + 促す。渡し方は `AgentRunOptions.systemPrompt`(`composeSystemPrompt` の最後の節)で、 + `Session` が**使い捨て**で持ち次の `open()` で消費する。 + - キューへ指示として積まない(積むと切替直後に「状況を読むだけのターン」が 1 本走り、 + provider のプロセスを無駄に立てる) + - 常設にしない(引き継ぎ後のターンや通信断からの再起動でも「前任者から引き継いだ」と + 言い続けてしまう)。各項目は 1 行に畳んで `MAX_HANDOFF_FIELD_CHARS` で切る - [x] i18n: `AgentLabel` を `DEFAULT_AGENT_LABEL` 固定ではなく**セッションのエージェント**から 引くよう配線(`agentLabelOf()` + `SessionManager.getSessionAgentLabel()`)。認証切れの案内は 一覧・詳細・デスクトップ通知の 3 経路すべてで駆動中の provider を出す — Codex のセッションに @@ -1335,8 +1350,19 @@ zsh: abort codiva 指示を**古いエージェントが受け取っていた**。入力キューを閉じて新しいキューに差し替え、 進行中のターンは `run.interrupt()` で畳み、積み残しの指示は `drain()` で新しいキューへ 移す(詳細は `.claude/rules/session-domain.md`)。`/agent` を入れて初めて踏める経路だった -- [ ] 一覧・詳細にエージェントの表示(どのセッションが何で走っているか)と、`LogEntry.agent` を - 使ったログ上の区切り表示(状態には載っているが**どこにも描いていない**) +- [x] 一覧・詳細にエージェントの表示(どのセッションが何で走っているか)と、`LogEntry.agent` を + 使ったログ上の区切り表示: + - ヘッダ(`bannerLines` の `agent`)に**新規セッションの既定**を出す(プラン・モデル・ + ブランチと同じ行 = 行を増やさない) + - 一覧の行に**混在しているときだけ**エージェント列を出す(`core/agent-display.ts` の + `usesMultipleAgents` + `core/layout.ts` の `showsAgentColumn`)。全部同じ provider なら + ヘッダと重複するだけで title / branch から幅を奪うため。列が奪う幅 + (`AGENT_COLUMN_CELLS`)はブランチ列の判定から差し引く(行が折り返すと + `rowLineAtPoint` の「1 セッション = 1 行」が崩れてクリック位置が全部ズレる) + - 詳細は追加指示のプレースホルダに出す(`m.detail.followupPlaceholder(agent)`。 + ステータスヘッダを持たないビューなので**1 行も増やさずに**出せる場所がここ) + - ログは `logLines(…, dividerFor)` が `LogEntry.agent` の境界に区切り行を 1 本挿む + (`── ここから Codex ──`。切替を使っていないセッションには 1 本も出ない) --- diff --git a/src/bootstrap/restore-sessions.ts b/src/bootstrap/restore-sessions.ts index 6f8a5d3..5357afe 100644 --- a/src/bootstrap/restore-sessions.ts +++ b/src/bootstrap/restore-sessions.ts @@ -1,4 +1,10 @@ -import { type LogEntry, type SessionManager, transcriptLogEntries } from '@/core'; +import { + agentSupports, + capabilityLookup, + type LogEntry, + type SessionManager, + transcriptLogEntries, +} from '@/core'; import { loadState, loadTranscriptText, pruneMissingWorktrees } from '@/utils'; /** @@ -6,9 +12,15 @@ import { loadState, loadTranscriptText, pruneMissingWorktrees } from '@/utils'; * conversation log is rebuilt from its SDK transcript (~/.claude/projects/…): * `resume` restores only the model-side context and never re-emits past messages, * so without this the detail view would start empty. + * + * トランスクリプトの読み出しは **`transcript` capability を持つ provider のセッション + * だけ**に投げる。パスの組み立てと JSONL の解釈は Claude CLI の記録に固有なので、 + * Codex / Grok のセッション id で問い合わせても当たらない(今は空振りするだけだが、 + * worktree が同じで id が偶然衝突すれば**別 provider のログを混ぜて**復元してしまう)。 */ export async function restoreSessions(manager: SessionManager, statePath: string): Promise { const persisted = pruneMissingWorktrees(await loadState(statePath)); + const capabilities = capabilityLookup(manager.listAgents()); const histories = new Map(); // One transcript at a time on purpose. Reading them in parallel held every raw // JSONL (a busy session's is several MB) in the heap simultaneously, on top of @@ -16,6 +28,9 @@ export async function restoreSessions(manager: SessionManager, statePath: string // restored sessions. Sequentially, each text is collectable as soon as it has // been converted, and the (bounded) entries are all that stay. for (const p of persisted.sessions) { + if (!agentSupports(capabilities, p.agent, 'transcript')) { + continue; + } const text = await loadTranscriptText(p.worktreePath, p.sdkSessionId); if (text !== undefined) { histories.set(p.id, transcriptLogEntries(text)); diff --git a/src/bootstrap/usage-poller.ts b/src/bootstrap/usage-poller.ts index 8d1f6af..e957a98 100644 --- a/src/bootstrap/usage-poller.ts +++ b/src/bootstrap/usage-poller.ts @@ -37,6 +37,15 @@ export interface UsagePollingDeps { * Rejection is ignored; the poll runs either way. */ after?: Promise; + /** + * 問い合わせてよいか(毎回の poll の直前に聞く)。`false` の回は probe を立てず、 + * 「空振り」としても数えない(あとで対象が現れたら再開する)。 + * + * 使用状況ゲージは `usage` を報告する provider のアカウントの話なので、Codex / + * Grok だけで作業している間は表示もしないし取りにも行かない(合成レイヤが + * `showsAccountUsage` で判定する)。省略時は常に問い合わせる。 + */ + enabled?: () => boolean; /** Override for tests. Defaults to {@link USAGE_POLL_INTERVAL_MS}. */ intervalMs?: number; } @@ -68,6 +77,11 @@ export function startUsagePolling(deps: UsagePollingDeps): () => void { if (inFlight || stopped) { return; } + // 出さない画面のために `claude` のサブプロセスを立てない。stop() はしない — + // あとで Claude のセッションを作ったら(既定を戻したら)そこから再開する。 + if (deps.enabled?.() === false) { + return; + } inFlight = true; try { // fetchUsageSnapshot は投げない契約だが、ここで潰しておけば注入側の実装が diff --git a/src/core/agent-capabilities.spec.ts b/src/core/agent-capabilities.spec.ts new file mode 100644 index 0000000..b32db23 --- /dev/null +++ b/src/core/agent-capabilities.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { + type AgentCapabilitySource, + agentSupports, + capabilityLookup, + showsAccountUsage, + supportsCapability, +} from './agent-capabilities'; +import { NO_CAPABILITIES } from './agent-ports'; +import type { AgentId, SessionStatus } from './types'; + +const FULL = { + permissions: true, + interrupt: true, + setModel: true, + resume: true, + modelCatalog: true, + usage: true, + cost: true, + transcript: true, +}; + +const AGENTS: AgentCapabilitySource[] = [ + { id: 'claude', capabilities: FULL }, + { id: 'codex', capabilities: NO_CAPABILITIES }, + { id: 'grok', capabilities: { ...NO_CAPABILITIES, permissions: true, interrupt: true } }, +]; + +describe('supportsCapability', () => { + it.each([ + ['持っている', FULL, 'usage' as const, true], + ['持っていない', NO_CAPABILITIES, 'usage' as const, false], + ])('%s', (_label, caps, key, expected) => { + expect(supportsCapability(caps, key)).toBe(expected); + }); + + it('不明(undefined)なら縮退しない', () => { + // 未登録の provider・`agent` を持たない古いセッションで機能を隠すと、 + // 動くはずの操作が黙って消える。 + expect(supportsCapability(undefined, 'usage')).toBe(true); + expect(supportsCapability(undefined, 'permissions')).toBe(true); + }); +}); + +describe('capabilityLookup / agentSupports', () => { + const lookup = capabilityLookup(AGENTS); + + it.each<[AgentId | undefined, keyof typeof FULL, boolean]>([ + ['claude', 'usage', true], + ['claude', 'cost', true], + ['codex', 'usage', false], + ['codex', 'permissions', false], + ['grok', 'permissions', true], + ['grok', 'cost', false], + [undefined, 'usage', true], + ])('%s の %s → %s', (agent, key, expected) => { + expect(agentSupports(lookup, agent, key)).toBe(expected); + }); + + it('未登録の provider は不明として扱う', () => { + const only = capabilityLookup([{ id: 'codex', capabilities: NO_CAPABILITIES }]); + expect(only('claude')).toBeUndefined(); + expect(agentSupports(only, 'claude', 'usage')).toBe(true); + }); +}); + +describe('showsAccountUsage', () => { + const capabilities = capabilityLookup(AGENTS); + const session = (agent: AgentId, status: SessionStatus = 'completed') => ({ agent, status }); + + it.each<[string, AgentId | undefined, { agent?: AgentId; status: SessionStatus }[], boolean]>([ + ['既定が usage を報告する', 'claude', [], true], + ['既定は報告しないがセッションが報告する', 'codex', [session('claude')], true], + ['既定もセッションも報告しない', 'codex', [session('codex'), session('grok')], false], + ['archived だけの claude は数えない', 'codex', [session('claude', 'archived')], false], + ['既定が不明なら出す', undefined, [session('codex')], true], + ])('%s', (_label, defaultAgent, sessions, expected) => { + expect(showsAccountUsage({ sessions, defaultAgent, capabilities })).toBe(expected); + }); +}); diff --git a/src/core/agent-capabilities.ts b/src/core/agent-capabilities.ts new file mode 100644 index 0000000..ae8fc7e --- /dev/null +++ b/src/core/agent-capabilities.ts @@ -0,0 +1,80 @@ +import type { AgentCapabilities } from './agent-ports'; +import type { AgentId, SessionStatus } from './types'; + +/** + * capability による UI 縮退の判定(純粋)。 + * + * 「持たない機能を出さない」だけでなく、**混在時に嘘をつかない**ことがここの目的。 + * ヘッダの使用状況ゲージ・合計コスト・トランスクリプト復元は Claude 由来の仕組みで、 + * Codex / Grok のセッションはそこへ何も供給しない。数字が 0 だから自然に消える、という + * 偶然に頼っていると「Claude ぶんの合計」を「全体」として出してしまう余地が残る + * (`AgentCapabilities` を見た明示的な分岐に置き換える。docs/TASKS.md Phase D)。 + */ + +/** capability を引ける最小の形(`AgentAdapter` の構造部分だけ受ける)。 */ +export interface AgentCapabilitySource { + readonly id: AgentId; + readonly capabilities: AgentCapabilities; +} + +/** `SessionState` のうちここが見る部分だけ(テストから素の値で駆動できるように)。 */ +export interface AgentUsageSession { + readonly agent?: AgentId; + readonly status: SessionStatus; +} + +/** id → capabilities の引き当て(未登録の provider は undefined = 不明)。 */ +export type CapabilityLookup = (agent: AgentId | undefined) => AgentCapabilities | undefined; + +/** + * capability の 1 項目。**分からないときは縮退しない**(`true` を返す)。 + * + * 未登録の provider や `agent` を持たない古いセッションで機能を隠すと、動くはずの + * 操作が黙って消える(既存の `caps && !caps.setModel` と同じ規約に合わせてある)。 + */ +export function supportsCapability( + caps: AgentCapabilities | undefined, + key: keyof AgentCapabilities, +): boolean { + return caps ? caps[key] : true; +} + +/** 登録アダプタの一覧から capability の引き当てを作る。 */ +export function capabilityLookup(agents: readonly AgentCapabilitySource[]): CapabilityLookup { + const table = new Map(agents.map((a) => [a.id, a.capabilities])); + return (agent) => (agent === undefined ? undefined : table.get(agent)); +} + +/** そのエージェントがその機能を持つか(引き当て + 不明は縮退しない)。 */ +export function agentSupports( + lookup: CapabilityLookup, + agent: AgentId | undefined, + key: keyof AgentCapabilities, +): boolean { + return supportsCapability(lookup(agent), key); +} + +/** + * ヘッダの使用状況ゲージ(アカウント全体の枠)を出すか。 + * + * ゲージが表しているのは **`usage` を報告する provider のアカウント**の消費であって、 + * codiva 全体の消費ではない。Codex / Grok だけで作業している人に Claude の枠を + * 出しても読みようがなく(それを埋めているのは別のツール)、5 分ごとの probe + * サブプロセスも無駄になる。だから「新規セッションの既定」か「まだ生きている + * セッションのどれか」が `usage` を報告するときだけ出す。 + * + * `archived` を数えないのは、マージ済みの過去のセッション 1 件で永久に出続けるのを + * 避けるため(provider を乗り換えた人のヘッダに残る)。 + */ +export function showsAccountUsage(input: { + sessions: readonly AgentUsageSession[]; + defaultAgent?: AgentId; + capabilities: CapabilityLookup; +}): boolean { + if (agentSupports(input.capabilities, input.defaultAgent, 'usage')) { + return true; + } + return input.sessions.some( + (s) => s.status !== 'archived' && agentSupports(input.capabilities, s.agent, 'usage'), + ); +} diff --git a/src/core/agent-display.spec.ts b/src/core/agent-display.spec.ts new file mode 100644 index 0000000..e964c7f --- /dev/null +++ b/src/core/agent-display.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { sessionAgentId, usesMultipleAgents } from './agent-display'; +import type { AgentId } from './types'; + +describe('sessionAgentId', () => { + it.each<[{ agent?: AgentId }, AgentId]>([ + [{ agent: 'codex' }, 'codex'], + [{ agent: 'grok' }, 'grok'], + // 切替対応より前に保存された状態(`agent` 無し)は既定(claude)扱い。 + [{}, 'claude'], + ])('%o → %s', (session, expected) => { + expect(sessionAgentId(session)).toBe(expected); + }); +}); + +describe('usesMultipleAgents', () => { + it.each<[string, { agent?: AgentId }[], boolean]>([ + ['0 件', [], false], + ['全部同じ', [{ agent: 'codex' }, { agent: 'codex' }], false], + ['未設定は claude と同じ', [{}, { agent: 'claude' }], false], + ['混在', [{ agent: 'claude' }, { agent: 'codex' }], true], + ['未設定と別 provider の混在', [{}, { agent: 'grok' }], true], + ])('%s', (_label, sessions, expected) => { + expect(usesMultipleAgents(sessions)).toBe(expected); + }); +}); diff --git a/src/core/agent-display.ts b/src/core/agent-display.ts new file mode 100644 index 0000000..9a734e6 --- /dev/null +++ b/src/core/agent-display.ts @@ -0,0 +1,38 @@ +import type { AgentId } from './types'; + +/** + * 「どのセッションが何で走っているか」を出すための純粋なヘルパ。 + * + * 状態には `SessionState.agent` / `LogEntry.agent` が載っているが、長らく**どこにも + * 描いていなかった**(docs/TASKS.md Phase D)。表示名そのものはアダプタが持つ固有名詞 + * (`AgentAdapter.displayName`)なので、ここが持つのは「誰の行か」と「列を出すか」の判定だけ。 + */ + +/** + * `agent` を持たない状態を読むときの既定。エージェント切替に対応する前に保存された + * スナップショットの復元(`core/persistence.ts`)と同じ扱いに揃えてある。 + */ +const FALLBACK_AGENT: AgentId = 'claude'; + +/** そのセッションを駆動している provider(未設定 = 切替対応より前の状態は既定扱い)。 */ +export function sessionAgentId(session: { agent?: AgentId }): AgentId { + return session.agent ?? FALLBACK_AGENT; +} + +/** + * 一覧に並んでいるセッションが 2 種類以上の provider で走っているか。 + * + * 列を**常に**出さないのはヘッダに既定エージェントが出ているから: 全部同じ provider + * なら 1 行ぶんの情報が全行で重複するだけで、狭い端末では title / branch から幅を + * 奪う。混ざった瞬間だけ「どれが何か」が読めれば足りる。 + */ +export function usesMultipleAgents(sessions: readonly { agent?: AgentId }[]): boolean { + const seen = new Set(); + for (const session of sessions) { + seen.add(sessionAgentId(session)); + if (seen.size > 1) { + return true; + } + } + return false; +} diff --git a/src/core/agent-handoff.spec.ts b/src/core/agent-handoff.spec.ts new file mode 100644 index 0000000..a25a10e --- /dev/null +++ b/src/core/agent-handoff.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { handoffInstruction, lastUserInstruction, MAX_HANDOFF_FIELD_CHARS } from './agent-handoff'; +import type { LogEntry } from './types'; + +const entry = (seq: number, kind: LogEntry['kind'], text: string): LogEntry => ({ + seq, + kind, + text, +}); + +describe('lastUserInstruction', () => { + it('最後のユーザー行を返す', () => { + const messages = [ + entry(1, 'user', 'まず調べて'), + entry(2, 'assistant_text', '調べました'), + entry(3, 'user', '次はテストを書いて'), + entry(4, 'tool_use', 'Edit(src/a.ts)'), + ]; + expect(lastUserInstruction(messages)).toBe('次はテストを書いて'); + }); + + it('ユーザー行が無ければ undefined', () => { + expect(lastUserInstruction([entry(1, 'system', 'started')])).toBeUndefined(); + expect(lastUserInstruction([])).toBeUndefined(); + }); +}); + +describe('handoffInstruction', () => { + it('材料が無ければ何も足さない', () => { + expect(handoffInstruction({ from: 'Claude' })).toBeUndefined(); + expect(handoffInstruction({ from: 'Claude', task: ' ', branch: '' })).toBeUndefined(); + }); + + it('引き継ぎ元・ブランチ・指示を載せる', () => { + const text = handoffInstruction({ + from: 'Claude', + branch: 'codiva/add-login', + task: 'ログイン画面を作る', + lastInstruction: 'テストも書いて', + }); + expect(text).toContain('taking over this session from Claude'); + expect(text).toContain('- Branch: codiva/add-login'); + expect(text).toContain('- Original task: ログイン画面を作る'); + expect(text).toContain('- Most recent instruction: テストも書いて'); + // 作業ツリーを自分で確かめてから続けさせる(要約を信じさせない)。 + expect(text).toContain('git status'); + }); + + it('直前の指示が最初の指示と同じなら重ねない', () => { + const text = handoffInstruction({ + from: 'Codex', + task: 'ログイン画面を作る', + lastInstruction: 'ログイン画面を作る', + }); + expect(text).toContain('- Original task: ログイン画面を作る'); + expect(text).not.toContain('Most recent instruction'); + }); + + it('長い指示は 1 行に畳んで切る(systemPrompt が本文より大きくならないように)', () => { + const text = handoffInstruction({ + from: 'Claude', + task: `${'あ'.repeat(MAX_HANDOFF_FIELD_CHARS + 50)}`, + lastInstruction: '複数\n行の\n指示', + }); + expect(text).toContain(`- Original task: ${'あ'.repeat(MAX_HANDOFF_FIELD_CHARS)}…`); + expect(text).toContain('- Most recent instruction: 複数 行の 指示'); + }); +}); diff --git a/src/core/agent-handoff.ts b/src/core/agent-handoff.ts new file mode 100644 index 0000000..11e706d --- /dev/null +++ b/src/core/agent-handoff.ts @@ -0,0 +1,98 @@ +import type { LogEntry } from './types'; + +/** + * エージェント切替(`/agent`)のときに、引き継ぐ側へ渡す最初の状況説明を組み立てる(純粋)。 + * + * なぜ要るか: 切替先は**前の会話を持たない**(モデル側の文脈は provider をまたげず、 + * 各 CLI が自分のトランスクリプトを持つ)。共有されているのは worktree だけなので、 + * 何も渡さないと切替先は「途中まで作業された作業ツリー」を白紙から見ることになり、 + * 済んだ作業をやり直したり、直前の指示を無視したりする。 + * + * **AI 向けの文字列なので i18n カタログには置かない**(`core/system-prompt.ts` の + * `SHARED_IGNORED_FILES_NOTICE` / `utils/title.ts` の `TITLE_INSTRUCTION` と同じ扱いで + * 英語固定)。渡す先は `AgentRunOptions.systemPrompt` で、`composeSystemPrompt` の + * 最後の節として 1 回だけ載る(次のターン以降には持ち越さない)。 + */ + +/** + * 1 項目に載せる最大文字数。指示文はファイルを丸ごと貼り付けたものになりうるので、 + * systemPrompt が本文より大きくなる(= 毎ターン全部読ませる)のを防ぐために切る。 + * 切ったことは `…` で示す(黙って切らない)。 + */ +export const MAX_HANDOFF_FIELD_CHARS = 600; + +export interface HandoffInput { + /** 引き継ぐ側の表示名(切替前のエージェント)。 */ + from: string; + /** セッションの worktree ブランチ。 */ + branch?: string; + /** セッションの最初の指示(そのセッションの目的)。 */ + task?: string; + /** 直前にユーザーが送った指示(最初の指示と同じなら省く)。 */ + lastInstruction?: string; +} + +/** 1 行に畳んで長すぎるものを切る(systemPrompt の箇条書きに収めるため)。 */ +function field(text: string | undefined): string | undefined { + const flat = text?.replace(/\s+/g, ' ').trim(); + if (!flat) { + return undefined; + } + return flat.length > MAX_HANDOFF_FIELD_CHARS + ? `${flat.slice(0, MAX_HANDOFF_FIELD_CHARS)}…` + : flat; +} + +/** + * ログから直前のユーザー指示を拾う(`kind: 'user'` の最後の 1 件)。復元した + * トランスクリプト由来の行も同じ kind なので、再起動をまたいでも拾える。 + */ +export function lastUserInstruction(messages: readonly LogEntry[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const entry = messages[i]; + if (entry?.kind === 'user') { + return entry.text; + } + } + return undefined; +} + +/** + * 引き継ぎの指示文。渡せる材料が何も無ければ `undefined`(`composeSystemPrompt` と + * 同じで、無いものは足さない)。 + * + * 「作業ツリーを自分で確かめてから続ける」ことを明示するのが要点 — 引き継ぎ先は + * 差分の中身までは知りようがなく、要約を信じて上書きするより `git status` / + * `git diff` を読んでもらった方が確実(実際の状況は codiva が文章にした瞬間から古い)。 + */ +export function handoffInstruction(input: HandoffInput): string | undefined { + const task = field(input.task); + const last = field(input.lastInstruction); + const branch = field(input.branch); + if (!task && !last && !branch) { + return undefined; + } + const lines = [ + '# Session handover (codiva)', + '', + `You are taking over this session from ${input.from}. The previous agent's conversation`, + 'history is NOT available to you — only the working tree it left behind is shared.', + '', + ]; + if (branch) { + lines.push(`- Branch: ${branch}`); + } + if (task) { + lines.push(`- Original task: ${task}`); + } + if (last && last !== task) { + lines.push(`- Most recent instruction: ${last}`); + } + lines.push( + '', + 'Before doing anything, inspect the working tree yourself (`git status`, `git diff`,', + '`git log`) to see what is already done, and continue from there. Do not redo or revert', + 'work that is already committed.', + ); + return lines.join('\n'); +} diff --git a/src/core/banner-lines.spec.ts b/src/core/banner-lines.spec.ts index 080a7df..c746ddb 100644 --- a/src/core/banner-lines.spec.ts +++ b/src/core/banner-lines.spec.ts @@ -36,6 +36,20 @@ describe('bannerLines', () => { expect(rows(bannerLines(m, { sessionCount: 0 }))).toHaveLength(2); }); + it('既定のエージェントはモデルと同じ行に並ぶ(行を増やさない)', () => { + // 一覧の行のエージェント列は混在時だけ出るので、単一 provider で使っている人が + // 「何で動くか」を確かめられるのはここになる。 + const lines = rows( + bannerLines(m, { sessionCount: 0, agent: 'Codex', model: 'gpt-5', cwd: '/tmp/repo' }), + ); + expect(lines[1]).toBe('Agent: Codex Model: gpt-5'); + expect(lines).toHaveLength(3); + }); + + it('エージェント未指定なら出さない', () => { + expect(rows(bannerLines(m, { sessionCount: 0 }))[1]).toBe('Model: CLI default'); + }); + it('プラン名はモデルと同じ行に並ぶ(行を増やさない)', () => { const lines = rows( bannerLines(m, { diff --git a/src/core/banner-lines.ts b/src/core/banner-lines.ts index e4d2c38..b10eeb2 100644 --- a/src/core/banner-lines.ts +++ b/src/core/banner-lines.ts @@ -35,6 +35,11 @@ export interface BannerInput { /** アプリのバージョン(package.json 由来)。ワードマークの右に `vX.Y.Z` で表示。 */ version?: string; sessionCount: number; + /** + * 新しいセッションを動かすエージェントの表示名(`/agent` の既定)。固有名詞なので + * カタログを通さずアダプタの `displayName` をそのまま渡す。取れなければ出さない。 + */ + agent?: string; totalCostUsd?: number; /** ログイン中のアカウント(プラン名・組織名)。SDK probe 由来で、無ければ出さない。 */ account?: AccountSummary; @@ -99,6 +104,12 @@ export function bannerLines(m: Messages, input: BannerInput): BannerLine[] { tone: 'dim', }); } + // 既定のエージェントもこの行に並べる(「何が、どのモデルで動くか」を 1 行で読む)。 + // 一覧の行のエージェント列は混在時だけ出るので、単一 provider で使っている人が + // 「今どれで動いているか」を確かめられる場所はここになる。 + if (input.agent) { + identity.push({ text: `${m.banner.agent(input.agent)}${FIELD_GAP}`, tone: 'dim' }); + } identity.push({ text: m.banner.model(input.model ?? m.banner.defaultModel), tone: 'dim' }); // 現在のブランチも**この行**に並べる(cwd 行ではなく)。理由は 2 つ: // (1) cwd は長くなりがちで、`wrap="truncate-end"` の行末に置くと狭い端末で真っ先に diff --git a/src/core/cost.spec.ts b/src/core/cost.spec.ts index c9ee1bb..68eed2f 100644 --- a/src/core/cost.spec.ts +++ b/src/core/cost.spec.ts @@ -26,6 +26,16 @@ describe('totalCostUsd', () => { it('is 0 for an empty list', () => { expect(totalCostUsd([])).toBe(0); }); + + it('金額を報告しない provider のセッションは数えない', () => { + // 混在時に「Claude ぶんの合計」を全体のコストとして出さないための明示的な分岐 + // (0 だから自然に消える、という偶然に頼らない)。 + const claude = { ...stateWithCost('a', 0.5), agent: 'claude' as const }; + const codex = { ...stateWithCost('b', 0.5), agent: 'codex' as const }; + expect(totalCostUsd([claude, codex], (agent) => agent === 'claude')).toBeCloseTo(0.5, 10); + // 述語なしは従来どおり全件を数える。 + expect(totalCostUsd([claude, codex])).toBeCloseTo(1, 10); + }); }); describe('formatUsd', () => { diff --git a/src/core/cost.ts b/src/core/cost.ts index ba926b4..cac3848 100644 --- a/src/core/cost.ts +++ b/src/core/cost.ts @@ -1,4 +1,4 @@ -import type { SessionState } from './types'; +import type { AgentId, SessionState } from './types'; /** * Running cost helpers. Each session's `totalCostUsd` is the SDK's cumulative @@ -6,9 +6,22 @@ import type { SessionState } from './types'; * these derive the run-wide total and a display string. Pure — no I/O. */ -/** Sum of every session's cost. Archived sessions are included — money was still spent. */ -export function totalCostUsd(states: SessionState[]): number { - return states.reduce((sum, s) => sum + (s.totalCostUsd ?? 0), 0); +/** + * Sum of every session's cost. Archived sessions are included — money was still spent. + * + * `reportsCost` は「その provider が金額を報告するか」(`AgentCapabilities.cost`)を + * 渡す口。持たない provider のセッションは**合計に数えない** — 数字が 0 だから自然に + * 消える、という偶然に頼っていると、Claude と Codex を混ぜたときに「Claude ぶんの + * 合計」を全体のコストとして出してしまう(省略時は全件を数える = 従来の挙動)。 + */ +export function totalCostUsd( + states: SessionState[], + reportsCost?: (agent: AgentId | undefined) => boolean, +): number { + return states.reduce( + (sum, s) => (reportsCost && !reportsCost(s.agent) ? sum : sum + (s.totalCostUsd ?? 0)), + 0, + ); } /** diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 9dd990b..8253bdb 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -76,7 +76,12 @@ export interface Messages { changesTitle: (branch: string) => string; noCommittedChanges: string; uncommitted: (n: number) => string; - followupPlaceholder: string; + /** + * 追加指示の入力欄プレースホルダ(agent = このセッションを駆動しているエージェントの + * 表示名)。詳細ビューはステータスヘッダを持たないので、「何で走っているか」を + * 1 行も増やさずに出せる場所がここ。 + */ + followupPlaceholder: (agent: string) => string; scrollHint: (newerBelow: number) => string; actionsTitle: string; mergeAction: string; @@ -227,6 +232,11 @@ export interface Messages { unsupported: (name: string) => string; /** `/agent` の行で `l` を押すとログインできる、のヒント */ loginKey: string; + /** + * 会話ログの中の切替の区切り行(name は以降を担当するエージェントの表示名)。 + * どこからが別のエージェントの発言かを 1 行で示す。 + */ + logDivider: (name: string) => string; }; /** TUI 内ログイン(login-dialog.tsx。`/login` と `/agent` の `l` で開く) */ login: { @@ -345,6 +355,11 @@ export interface Messages { banner: { /** 使用中モデルの表示(設定 model。未設定は CLI 既定)。プラン表示と同じ行に並ぶ。 */ model: (name: string) => string; + /** + * 新規セッションを動かすエージェントの表示(`/agent` の既定)。名前はアダプタ由来の + * 固有名詞なのでそのまま差し込む(モデル名と同じ扱い)。 + */ + agent: (name: string) => string; /** model 未設定時に表示するプレースホルダ(CLI 既定)。 */ defaultModel: string; /** @@ -432,6 +447,12 @@ export interface Messages { footer: { autoMode: string; confirmMode: string; + /** + * 確認モードだが、駆動中のエージェントが許可要求を上げられない + * (`AgentCapabilities.permissions === false`)ときのモード表示。ツールは + * 確認なしで実行されるので、`confirmMode` のまま出すと嘘になる。 + */ + confirmModeUnsupported: string; cycleHint: string; }; /** スラッシュコマンド(commands.ts / command-palette.tsx) */ @@ -563,7 +584,7 @@ const ja: Messages = { changesTitle: (branch) => `変更(${branch} vs ベース):`, noCommittedChanges: '(コミット済みの変更なし)', uncommitted: (n) => `未コミット ${n} 件`, - followupPlaceholder: '追加の指示を入力…', + followupPlaceholder: (agent) => `${agent} に追加の指示を入力…`, scrollHint: (n) => `▲ 過去ログを表示中 — 最新まで ${n} 行(↓/PgDn で下へ)`, actionsTitle: '操作', mergeAction: 'マージ(--no-ff)', @@ -651,6 +672,7 @@ const ja: Messages = { unavailable: 'エージェントを切り替えられませんでした', unsupported: (name) => `${name} はこの操作に対応していません`, loginKey: 'l: ログイン', + logDivider: (name) => `── ここから ${name} ──`, }, login: { title: (name) => `${name} にサインイン`, @@ -733,6 +755,7 @@ const ja: Messages = { }, banner: { model: (name) => `モデル: ${name}`, + agent: (name) => `エージェント: ${name}`, defaultModel: 'CLI 既定', plan: (plan, organization) => organization ? `プラン: ${plan} (${organization})` : `プラン: ${plan}`, @@ -781,6 +804,7 @@ const ja: Messages = { footer: { autoMode: '自動モード', confirmMode: '確認モード', + confirmModeUnsupported: '確認モード (非対応)', cycleHint: '(shift+tab で切替)', }, command: { @@ -864,7 +888,7 @@ const en: Messages = { changesTitle: (branch) => `Changes (${branch} vs base):`, noCommittedChanges: '(no committed changes)', uncommitted: (n) => `${n} uncommitted change${n === 1 ? '' : 's'}`, - followupPlaceholder: 'Enter a follow-up instruction…', + followupPlaceholder: (agent) => `Enter a follow-up instruction for ${agent}…`, scrollHint: (n) => `▲ Viewing older log — ${n} newer lines below (↓/PgDn to go down)`, actionsTitle: 'Actions', mergeAction: 'Merge (--no-ff)', @@ -948,6 +972,7 @@ const en: Messages = { unavailable: 'Could not switch the agent', unsupported: (name) => `${name} does not support this`, loginKey: 'l: sign in', + logDivider: (name) => `── ${name} from here ──`, }, login: { title: (name) => `Sign in to ${name}`, @@ -1031,6 +1056,7 @@ const en: Messages = { }, banner: { model: (name) => `Model: ${name}`, + agent: (name) => `Agent: ${name}`, defaultModel: 'CLI default', plan: (plan, organization) => organization ? `Plan: ${plan} (${organization})` : `Plan: ${plan}`, @@ -1075,6 +1101,7 @@ const en: Messages = { footer: { autoMode: 'auto mode on', confirmMode: 'confirm mode on', + confirmModeUnsupported: 'confirm mode (n/a)', cycleHint: '(shift+tab to cycle)', }, command: { diff --git a/src/core/index.ts b/src/core/index.ts index f13a1a9..4c2e572 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,6 +1,9 @@ export * from './account'; export * from './agent-availability'; +export * from './agent-capabilities'; +export * from './agent-display'; export * from './agent-events'; +export * from './agent-handoff'; export * from './agent-login'; export * from './agent-ports'; export * from './ansi'; diff --git a/src/core/layout.spec.ts b/src/core/layout.spec.ts index ed5fff7..0614b5e 100644 --- a/src/core/layout.spec.ts +++ b/src/core/layout.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { COMMANDS } from './commands'; import { + AGENT_COLUMN_CELLS, + AGENT_COLUMN_WIDTH, BANNER_ROWS, bannerGaugeWidth, COMPOSER_ROWS, @@ -15,10 +17,12 @@ import { listView, listViewportRows, logViewportRows, + MIN_AGENT_COLUMN_COLUMNS, MIN_BRANCH_COLUMN_COLUMNS, MIN_FULLSCREEN_ROWS, PALETTE_MIN_ROWS, paletteMaxRows, + showsAgentColumn, showsBranchColumn, } from './layout'; @@ -46,6 +50,26 @@ describe('showsBranchColumn', () => { }); }); +describe('showsAgentColumn', () => { + it.each<[number, boolean, boolean]>([ + // 混在していないときは幅があっても出さない(既定はヘッダに出ている)。 + [120, false, false], + [120, true, true], + [MIN_AGENT_COLUMN_COLUMNS, true, true], + [MIN_AGENT_COLUMN_COLUMNS - 1, true, false], + [0, true, false], + ])('columns=%d mixed=%s → %s', (columns, mixed, expected) => { + expect(showsAgentColumn(columns, mixed)).toBe(expected); + }); + + it('列が奪う幅は表示名 + 間隔 1 セル', () => { + // 描画(`width` + `marginRight`)とブランチ列の判定で同じ値を使うための番人。 + expect(AGENT_COLUMN_CELLS).toBe(AGENT_COLUMN_WIDTH + 1); + // 'Claude'(6 セル)が切り詰められない幅であること。 + expect(AGENT_COLUMN_WIDTH).toBeGreaterThanOrEqual('Claude'.length); + }); +}); + describe('dialogContentWidth', () => { it.each([ [100, 94], diff --git a/src/core/layout.ts b/src/core/layout.ts index 615b9bb..1334c77 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -34,6 +34,32 @@ export function showsBranchColumn(columns: number): boolean { return columns >= MIN_BRANCH_COLUMN_COLUMNS; } +/** + * 一覧のエージェント列の幅(セル)。表示名(`Claude` / `Codex` / `Grok`)は最長 6 セルで、 + * 右に 1 セルの間隔を足した固定幅列。 + */ +export const AGENT_COLUMN_WIDTH = 6; + +/** エージェント列が行から奪う幅(列 + 右の間隔)。ブランチ列の判定から差し引く。 */ +export const AGENT_COLUMN_CELLS = AGENT_COLUMN_WIDTH + 1; + +/** + * エージェント列を出すのに必要な最小の端末桁数。ブランチ列(80 桁)より緩いのは、 + * こちらは**混在しているときだけ**出る列で、狭ければブランチ列を先に落として席を作れるから。 + */ +export const MIN_AGENT_COLUMN_COLUMNS = 60; + +/** + * 一覧の行にエージェント名の列を出すか判定する純関数。`mixed` は + * `usesMultipleAgents(sessions)`(`core/agent-display.ts`)。 + * + * 全部同じ provider なら出さない: 既定のエージェントはヘッダに出ているので、 + * 同じ名前を全行に並べても情報が増えないのに title / branch から幅を奪う。 + */ +export function showsAgentColumn(columns: number, mixed: boolean): boolean { + return mixed && columns >= MIN_AGENT_COLUMN_COLUMNS; +} + /** * ヘッダ(`ui/banner.tsx`)の使用状況ゲージ以外に 1 行が使う幅の見積り(ja の最長ケース): * マスコット 15 + 余白 2 + 一覧のパディング 2 + 行頭のインデント 2 + 見出し 16 diff --git a/src/core/scroll.spec.ts b/src/core/scroll.spec.ts index 860edd3..507be5c 100644 --- a/src/core/scroll.spec.ts +++ b/src/core/scroll.spec.ts @@ -204,6 +204,46 @@ describe('logLines: クリックできる URL の範囲(links)', () => { }); }); +describe('logLines: エージェント切替の区切り行', () => { + const prefixFor = () => ''; + const divider = (agent: string) => `-- ${agent} --`; + + it('LogEntry.agent が変わる境界に 1 行だけ挿む', () => { + const rows = logLines( + [ + { seq: 1, kind: 'system', text: 'a' }, + { seq: 2, kind: 'system', text: 'b', agent: 'codex' }, + { seq: 3, kind: 'system', text: 'c', agent: 'codex' }, + { seq: 4, kind: 'system', text: 'd', agent: 'claude' }, + ], + 40, + prefixFor, + divider, + ); + expect(rows.map((r) => r.text)).toEqual(['a', '-- codex --', 'b', 'c', '-- claude --', 'd']); + // 区切りのキーは行のキーと衝突しない(描画キーは key で決まる)。 + expect(new Set(rows.map((r) => r.key)).size).toBe(rows.length); + }); + + it('切替を使っていないセッション(agent が全行 undefined)には 1 本も出さない', () => { + const rows = logLines( + [ + { seq: 1, kind: 'system', text: 'a' }, + { seq: 2, kind: 'system', text: 'b' }, + ], + 40, + prefixFor, + divider, + ); + expect(rows.map((r) => r.text)).toEqual(['a', 'b']); + }); + + it('dividerFor を渡さなければ従来どおり(行数を変えない)', () => { + const messages: LogEntry[] = [{ seq: 1, kind: 'system', text: 'a', agent: 'codex' }]; + expect(logLines(messages, 40, prefixFor)).toHaveLength(1); + }); +}); + describe('logLines (entries → physical rows)', () => { const prefixFor = (kind: LogKind) => (kind === 'user' ? '> ' : ''); diff --git a/src/core/scroll.ts b/src/core/scroll.ts index 5315c83..18b3073 100644 --- a/src/core/scroll.ts +++ b/src/core/scroll.ts @@ -2,7 +2,7 @@ import stringWidth from 'string-width'; import { GRAPHEMES } from './graphemes'; import { type RichLine, type RichSpan, renderMarkdown } from './markdown'; import { clamp } from './math'; -import type { LogEntry, LogKind } from './types'; +import type { AgentId, LogEntry, LogKind } from './types'; import { detectUrls, type LinkRange, linksInSlice, mergeLinks, spanLinks } from './url'; /** @@ -334,15 +334,32 @@ export function clearLogLinesCache(): void { * Memoized per entry (see {@link ENTRY_ROWS}) — the output is the same value the * unmemoized version produced, but appending a line only costs that one line. * Rows must therefore be treated as read-only by callers. + * + * `dividerFor` を渡すと、`LogEntry.agent` が変わる境界に 1 行の区切りを挿む + * (`/agent` でエージェントを切り替えたセッションで「どこからが誰の発言か」を出す)。 + * 文言は UI が持つ(カタログ + アダプタの表示名)ので、ここは行を差し込むだけ。 */ export function logLines( messages: readonly LogEntry[], width: number, prefixFor: (kind: LogKind) => string, + dividerFor?: (agent: AgentId) => string, ): DisplayLine[] { currentPass += 1; const out: DisplayLine[] = []; + // 直前の行を出したエージェント。`LogEntry.agent` は**切替が起きたあとだけ**入るので、 + // undefined → 'codex' の 1 回目も境界として拾える(切替を使っていないセッションでは + // 全行 undefined = 区切りは 1 本も出ない)。 + let spoken: AgentId | undefined; for (const entry of messages) { + if (dividerFor && entry.agent !== undefined && entry.agent !== spoken) { + // 区切りはメモ化しない(1 行・境界の数だけ)。text は切替のたびに 1 種類しか + // 増えないので、Ink の測定キャッシュにも溜まらない。 + out.push({ key: `${entry.seq}:agent`, kind: 'system', text: dividerFor(entry.agent) }); + } + if (entry.agent !== undefined) { + spoken = entry.agent; + } // Appended one at a time on purpose: `push(...rows)` passes every row as an // argument, which overflows the stack for an entry that wrapped into tens of // thousands of rows (a narrow terminal + a pasted file). diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index 5c45cb7..c6ab130 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -937,6 +937,7 @@ describe('Session.setAgent', () => { function recorder(id: AgentId) { const seen: string[] = []; const resumes: (string | undefined)[] = []; + const systemPrompts: (string | undefined)[] = []; const adapter: AgentAdapter = { id, displayName: id, @@ -944,6 +945,7 @@ describe('Session.setAgent', () => { capabilities: NO_CAPABILITIES, open(request: AgentRunRequest) { resumes.push(request.resume); + systemPrompts.push(request.options.systemPrompt); return { async *[Symbol.asyncIterator]() { for await (const text of request.prompt) { @@ -955,7 +957,7 @@ describe('Session.setAgent', () => { }; }, }; - return { adapter, seen, resumes }; + return { adapter, seen, resumes, systemPrompts }; } it('routes the next instruction to the new agent, not the old one', async () => { @@ -976,6 +978,32 @@ describe('Session.setAgent', () => { expect(session.getState().agent).toBe('codex'); }); + it('hands the new agent a handover briefing on the first run only', async () => { + // 切替先は前の会話を持たない(各 CLI が自分のトランスクリプトを持つ)ので、 + // worktree の状況を systemPrompt で 1 回だけ渡す(`core/agent-handoff.ts`)。 + const a = recorder('claude'); + const b = recorder('codex'); + const session = new Session({ agent: a.adapter, input: INPUT, now: () => 0 }); + session.start(); + await tick(); + // 切替前は引き継ぎの説明を渡さない。 + expect(a.systemPrompts).toEqual([undefined]); + + session.setAgent(b.adapter); + session.send('now you'); + await tick(); + + const briefing = b.systemPrompts[0]; + expect(briefing).toContain('taking over this session from claude'); + expect(briefing).toContain('- Branch: codiva/t'); + expect(briefing).toContain('- Original task: do the thing'); + + // 2 回目のターン(同じエージェント)には持ち越さない — 引き継ぎは済んでいる。 + session.send('and this'); + await tick(); + expect(b.systemPrompts.slice(1).every((p) => p === undefined)).toBe(true); + }); + it('resumes the previous conversation when switching back', async () => { const a = recorder('claude'); const b = recorder('codex'); diff --git a/src/core/session.ts b/src/core/session.ts index 8dbb565..581d771 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -1,4 +1,5 @@ import { applyAgentEvent } from './agent-events'; +import { handoffInstruction, lastUserInstruction } from './agent-handoff'; import type { AgentAdapter, AgentRun, PermissionDecision } from './agent-ports'; import { AsyncQueue } from './async-queue'; import { createClaudeAdapter, type QueryFn } from './claude-adapter'; @@ -126,6 +127,12 @@ export class Session { * いないユーザーには何も増えない)。 */ private attribution?: AgentId; + /** + * 切替直後の 1 回だけ systemPrompt に載せる引き継ぎの状況説明 + * (`core/agent-handoff.ts`)。**使い捨て**にするのは、引き継ぎが済んだ以降の + * ターンでも「前任者から引き継いだ」と言い続けないため。 + */ + private handoff?: string; private run?: AgentRun; /** * 未応答の許可要求の**待ち行列**(先頭 = いま UI に出ているもの)。 @@ -233,6 +240,14 @@ export class Session { if (adapter.id === this.adapter.id) { return; } + // 引き継ぎの状況説明は**切替前**に組み立てる(前任者の名前と、まだ消えていない + // ログから直前の指示を拾う必要がある)。実際に渡すのは次の `open()` で 1 回だけ。 + this.handoff = handoffInstruction({ + from: this.adapter.displayName, + branch: this.state.branch, + task: this.state.prompt, + lastInstruction: lastUserInstruction(this.state.messages), + }); // 走っているターンを畳んでからでないと、2 本のストリームが同じ worktree を // 触ることになる。保留中の許可も解決しておく(未応答の tool_use で終わる // トランスクリプトは後の resume を壊す)。 @@ -543,9 +558,15 @@ export class Session { : (this.deps.resume ?? this.state.sdkSessionId); // worktree の環境説明(symlink 共有の注意書き)とリポジトリ追加指示をまとめた // systemPrompt。どちらも無ければ undefined で、その場合は渡さない。 + // 引き継ぎの説明は**この 1 回だけ**載せる(切替直後の最初の run)。ここで + // 落としておかないと、通信断からの再起動でも「前任者から引き継いだ」と + // 言い続けることになる。 + const handoff = this.handoff; + this.handoff = undefined; const systemPrompt = composeSystemPrompt({ ignoredFiles: opts?.ignoredFiles, repoPrompt: opts?.appendSystemPrompt, + handoff, }); this.run = this.adapter.open({ cwd: this.state.worktreePath, diff --git a/src/core/system-prompt.ts b/src/core/system-prompt.ts index a340829..c02970c 100644 --- a/src/core/system-prompt.ts +++ b/src/core/system-prompt.ts @@ -95,22 +95,30 @@ never write into the main repository's working tree, where those shared targets live.`; /** - * worktree の環境説明とリポジトリ追加指示から systemPrompt を組み立てる。 + * worktree の環境説明・リポジトリ追加指示・引き継ぎの状況説明から systemPrompt を + * 組み立てる。 * - * 順序は「環境説明 → リポジトリ追加指示」。前者は前提条件の説明、後者は著者が書いた - * 指示なので、より具体的な指示を後ろに置く。 + * 順序は「環境説明 → リポジトリ追加指示 → 引き継ぎ」。前者は前提条件の説明、次は著者が + * 書いた常設の指示、最後がこのターン限りの状況(`core/agent-handoff.ts`)で、 + * より具体的で今すぐ効くものを後ろに置く。 * * `ignoredFiles` は合成レイヤが `resolveIgnoredFilesMode(config)` の結果を渡す。 * 未指定(テストや直接構築)は注意書きを載せない —— 実体が共有されているかどうかを * 知らないまま「共有されている」と告げる方が危険なため。 + * + * `handoff` は `/agent` でエージェントを切り替えた**直後の 1 回だけ**渡される + * (`Session` が使い捨てで保持する)。常設にすると、引き継ぎが済んだあとのターンでも + * 「前任者から引き継いだ」と言い続けることになる。 */ export function composeSystemPrompt(parts: { ignoredFiles?: IgnoredFilesMode; repoPrompt?: string; + handoff?: string; }): string | undefined { const sections = [ parts.ignoredFiles === 'symlink' ? SHARED_IGNORED_FILES_NOTICE : undefined, parts.repoPrompt, + parts.handoff, ].filter((section): section is string => section !== undefined && section.length > 0); return sections.length > 0 ? sections.join('\n\n') : undefined; } diff --git a/src/main.tsx b/src/main.tsx index afb1e6c..5f2c21d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,7 @@ import { createRequire } from 'node:module'; import { render } from 'ink'; import { + capabilityLookup, DEFAULT_AGENT_ORDER, errorMessage, formatMemoryUsage, @@ -10,6 +11,7 @@ import { resolveIgnoredFilesMode, resolveLang, type SessionManager, + showsAccountUsage, summarizeStatuses, } from '@/core'; import { @@ -193,6 +195,14 @@ async function main(): Promise { const stopUsagePolling = startUsagePolling({ fetch: () => fetchUsageSnapshot(claudeQuery, { cwd: repoRoot, signal: probeAbort.signal }), apply: (snapshot) => manager.applyUsage(snapshot), + // ゲージを出さない構成(Codex / Grok だけで作業している)では probe も立てない。 + // 判定は一覧の表示条件と**同じ純関数**を通す(表示と取得が食い違わないように)。 + enabled: () => + showsAccountUsage({ + sessions: manager.getSnapshot(), + defaultAgent: manager.getDefaultAgentId(), + capabilities: capabilityLookup(manager.listAgents()), + }), // カタログ取得の後にずらす(どちらも probe サブプロセスを立てるので、起動直後に // 2本同時に走らせない)。失敗しても取得は行う。 after: modelCatalog, diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index ea29368..0f40fe4 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -1,5 +1,5 @@ import { Box, type DOMElement, Text, useInput, useWindowSize } from 'ink'; -import { type FC, useEffect, useMemo, useRef, useState } from 'react'; +import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type AgentId, ARROW_SCROLL_LINES, @@ -212,6 +212,17 @@ export const SessionDetail: FC<{ command: a.loginCommand, availability: agentAvailability.get(a.id), })); + // 表示名は provider ごとの固有名詞なのでアダプタから引く(カタログには置かない)。 + const agentNames = useMemo( + () => new Map(manager.listAgents().map((a) => [a.id, a.displayName])), + [manager], + ); + // 会話ログの中の切替の区切り行。`logLines` のメモ化の依存に入るので、毎描画で + // 作り直さないよう useCallback で固定する(作り直すと全行を再展開してしまう)。 + const agentDivider = useCallback( + (agentId: AgentId) => m.agent.logDivider(agentNames.get(agentId) ?? agentId), + [m, agentNames], + ); // 進行中のターンがあるか(= Ctrl+C で中断できるか)。許可/質問待ちも対象 // (ターンは生きていて回答待ちで止まっているだけ)。中断を持たない provider では // そもそも出さない。 @@ -360,8 +371,8 @@ export const SessionDetail: FC<{ ? Math.max(1, Math.floor(measuredLogRows ?? logViewportRows(rows))) : Math.max(1, rows); const entryRows = useMemo( - () => (messages ? logLines(messages, logWidth, (kind) => LOG_PREFIX[kind]) : []), - [messages, logWidth], + () => (messages ? logLines(messages, logWidth, (kind) => LOG_PREFIX[kind], agentDivider) : []), + [messages, logWidth, agentDivider], ); // ストリーミング中の本文。**ログの末尾の行として**描くので、返ってきたぶんだけ // 下へ伸びていき、末尾にいなければ(アンカーが数値なら)視界は動かない。 @@ -1008,11 +1019,21 @@ export const SessionDetail: FC<{ maxRows={paletteMaxRows(rows, 'detail')} /> ) : null} - + )} - + {/* 許可要求を上げられない provider(Codex)では「確認モード」を言い切らない — + ツールは確認なしに実行されるので、待っていれば聞かれると読めてしまう。 */} + ); diff --git a/src/ui/session-list.tsx b/src/ui/session-list.tsx index 7c34e95..49428f3 100644 --- a/src/ui/session-list.tsx +++ b/src/ui/session-list.tsx @@ -1,8 +1,11 @@ import { Box, type DOMElement, Text, useInput, useWindowSize } from 'ink'; -import { type FC, useEffect, useRef, useState } from 'react'; +import { type FC, useEffect, useMemo, useRef, useState } from 'react'; import { + AGENT_COLUMN_CELLS, + AGENT_COLUMN_WIDTH, type AgentId, activeElapsedMs, + agentSupports, atFirstComposerRow, atLastComposerRow, type BannerLine, @@ -14,6 +17,7 @@ import { type CodivaConfig, type ConfigToggleId, canSelfUpdate, + capabilityLookup, configToggleRows, dialogMaxRows, errorMessage, @@ -44,6 +48,9 @@ import { resumeInstruction, rowLineAtPoint, type SessionManager, + sessionAgentId, + showsAccountUsage, + showsAgentColumn, showsBranchColumn, type TrainingOptIn, toggleConfigPatch, @@ -53,6 +60,7 @@ import { type UpdateRun, type UpdateService, type UpdateViewState, + usesMultipleAgents, } from '@/core'; import { AgentSelect } from './agent-select'; import { Banner } from './banner'; @@ -212,6 +220,14 @@ export const SessionList: FC<{ const mode = useRunMode(manager); const rateLimits = useRateLimit(manager); const account = useAccount(manager); + // 登録アダプタの capability と表示名。`AgentCapabilities` を見た**明示的な分岐**で + // 縮退させるためのもの(数字が 0 だから自然に消える、という偶然に頼らない)。 + // 登録は起動時(`bootstrap/build-manager.ts`)に決まって以後変わらないので manager で + // memo する(`listAgents()` は毎回新しい配列を返すため、それを依存にすると memo が効かない)。 + const agents = useMemo(() => manager.listAgents(), [manager]); + const capabilities = useMemo(() => capabilityLookup(agents), [agents]); + const agentNames = useMemo(() => new Map(agents.map((a) => [a.id, a.displayName])), [agents]); + const defaultAgent = manager.getDefaultAgentId(); const now = useClock(1000); // 端末幅は PR セル(行末の固定幅列)のクリック当たり判定に、端末高は一覧の // 内部スクロール(収まる行数の算出)に使う。いずれもリサイズ追従。 @@ -489,7 +505,12 @@ export const SessionList: FC<{ // 使っている間はそのぶん厳しく判定する — 1 行が桁数を超えると Yoga が固定幅の列を // 縮め、行が 2 行に折り返して**以降の行のクリック位置が全部ズレる**(rowLineAtPoint は // 「1 セッション = 1 行」前提)。落とす候補はブランチ列(内容が worktree 名で復元可能)。 - const showBranch = showsBranchColumn(columns - (prCell - PR_CELL_WIDTH)); + // エージェント列は**混在しているときだけ**出す(単一 provider なら既定はヘッダに + // 出ているので、全行に同じ名前を並べても情報が増えない)。 + const showAgent = showsAgentColumn(columns, usesMultipleAgents(sessions)); + const showBranch = showsBranchColumn( + columns - (prCell - PR_CELL_WIDTH) - (showAgent ? AGENT_COLUMN_CELLS : 0), + ); const listHeight = useBoxHeight(rowsRef); const listCap = fullscreen ? Math.max(1, listHeight ?? listViewportRows(termRows)) @@ -577,6 +598,11 @@ export const SessionList: FC<{ } }; + // 使用状況ゲージ(アカウント全体の枠)は `usage` を報告する provider を使っている + // ときだけ出す。Codex / Grok だけで作業している人に Claude の枠を出しても読みようが + // なく(埋めているのは別のツール)、5 分ごとの probe サブプロセスも無駄になる。 + const showUsage = showsAccountUsage({ sessions, defaultAgent, capabilities }); + // ヘッダの表示行。描画(Banner)と当たり判定(bannerCaretAt)で同じ配列を使う — // 行 index = 表示行という前提を共有しているので、片方だけ差し替えると選択がズレる。 const headerLines = bannerLines(m, { @@ -585,7 +611,10 @@ export const SessionList: FC<{ model, version, sessionCount: sessions.length, - totalCostUsd: totalCostUsd(sessions), + agent: defaultAgent ? agentNames.get(defaultAgent) : undefined, + // 金額を報告しない provider のセッションは合計に数えない(`cost` capability)。 + // 混ぜたときに「Claude ぶんの合計」を全体のコストとして出さないため。 + totalCostUsd: totalCostUsd(sessions, (agent) => agentSupports(capabilities, agent, 'cost')), account, updateLatest: updateInfo?.latest, }); @@ -1001,7 +1030,7 @@ export const SessionList: FC<{ + {/* どのエージェントで走っているか(混在時のみ。単一なら列ごと出さない)。 + 表示名はアダプタ由来の固有名詞なのでカタログを通さない。 */} + {showAgent ? ( + + + {agentNames.get(sessionAgentId(s)) ?? ''} + + + ) : null} {/* 各セッションが実際に走っているモデル(SDK 由来の解決済み値)。 バナーの設定モデルと異なりうる。未取得なら空欄。 */} diff --git a/src/ui/status-footer.spec.tsx b/src/ui/status-footer.spec.tsx index 036c8eb..c6fe5e1 100644 --- a/src/ui/status-footer.spec.tsx +++ b/src/ui/status-footer.spec.tsx @@ -27,6 +27,16 @@ describe('StatusFooter', () => { expect(lastFrame()).toContain('確認モード'); }); + it('許可要求を上げられないエージェントでは確認モードを言い切らない', () => { + // Codex セッションでは確認ダイアログが原理的に出ない(`permissions: false`)。 + // `confirm mode on` のまま出すと「待っていれば聞かれる」と読めてしまう。 + const { lastFrame } = renderFooter({ mode: 'confirm', confirmSupported: false }); + expect(lastFrame()).toContain('confirm mode (n/a)'); + // 自動モードは capability に関係なく従来どおり(自動実行は嘘にならない)。 + const auto = renderFooter({ mode: 'auto', confirmSupported: false }); + expect(auto.lastFrame()).toContain('auto mode on'); + }); + it('画面固有のヒントをモード行の後ろに繋げる', () => { const { lastFrame } = renderFooter({ mode: 'auto', hint: 'Tab: list' }); expect(lastFrame()).toContain('Tab: list'); diff --git a/src/ui/status-footer.tsx b/src/ui/status-footer.tsx index 145ff51..76da7c9 100644 --- a/src/ui/status-footer.tsx +++ b/src/ui/status-footer.tsx @@ -16,19 +16,31 @@ import { glyph, theme } from './theme'; * * **The footer is exactly one line at any width.** モード表示は縮まず(ツールが * 自動実行かどうかは常に読めるべき)、ヒントだけが末尾で切り詰められる。 + * + * `confirmSupported={false}`(駆動中のエージェントが許可要求を上げられない = + * `AgentCapabilities.permissions === false`)のときは、確認モードでも「非対応」と + * 明示する。ツールは確認なしに実行されるので、`confirm mode on` をそのまま出すと + * **待っていれば聞かれる**と読めてしまう(Codex セッションで実際にそうなっていた)。 */ export const StatusFooter: FC<{ mode: RunMode; hint?: string; -}> = ({ mode, hint }) => { + /** 駆動中のエージェントが許可要求を上げられるか。省略 = 上げられる(一覧など対象が定まらない画面)。 */ + confirmSupported?: boolean; +}> = ({ mode, hint, confirmSupported = true }) => { const m = useMessages(); const auto = mode === 'auto'; + const modeLabel = auto + ? m.footer.autoMode + : confirmSupported + ? m.footer.confirmMode + : m.footer.confirmModeUnsupported; return ( {/* モード表示は縮まない(ツールが自動実行かどうかは常に読めるべき)。 */} - {auto ? glyph.auto : glyph.confirm} {auto ? m.footer.autoMode : m.footer.confirmMode} + {auto ? glyph.auto : glyph.confirm} {modeLabel} {/* ヒントだけが縮む枠(溢れは末尾で切り詰め、折り返さない)。 */}