diff --git a/.claude/rules/ink-components.md b/.claude/rules/ink-components.md index 8def0f6..777e794 100644 --- a/.claude/rules/ink-components.md +++ b/.claude/rules/ink-components.md @@ -30,6 +30,14 @@ `paletteMaxRows`(コマンドパレット)と同じ構図で、**黙って切らない**のと **当たり判定は描いた窓で逆算する**(上端のインジケータ 1 行ぶんずれる)のが要点。 +- **一覧の行のセルは必ず `wrap="truncate-end"`**。1 セッション = 1 行が前提の当たり判定 + (`rowLineAtPoint` / `listView` の行数計算)が全部これに乗っているので、1 つでも折り返すと + **そこから下のクリックが 1 行ずつズレ、最後の行はクリックできなくなる**(PR セルの + クリックも別セッションの PR を開く)。固定幅の列に入る文言は**カタログ側でも幅に収める** + (バッジは `BADGE_COLUMN_WIDTH` = 12 セル。番人は `i18n.spec.ts`)— 切れて読めない語を + 出さないため。英語の `Awaiting permission`(19 セル)が実際に折り返して、この不具合を + 起こしていた(日本語はちょうど 12 セルに収まっていたので気付けなかった)。 + ## 入力ハンドリング - **1画面につき `useInput` は1つ**(view コンポーネントに置く)。`PromptInput` 等は presentational にして、キー処理は view 側の単一ハンドラに集約する(複数 `useInput` の競合を避ける)。 diff --git a/.claude/rules/sdk-integration.md b/.claude/rules/sdk-integration.md index 093b0e5..55674d1 100644 --- a/.claude/rules/sdk-integration.md +++ b/.claude/rules/sdk-integration.md @@ -241,6 +241,12 @@ provider のメッセージ ──[アダプタの parse]──▶ AgentEvent[] - ルーチンツール(Write/Edit/Bash 等)は `auto` モードで自動 allow、`confirm` モードで UI に上げる。 判定は `core/run-mode.ts` の `createModePolicy`。`acceptEdits` でも `Write` が `canUseTool` に落ちてくる(実測)ので「編集系は自動許可」を前提にしない。 +- **質問かどうかを「ツール名」で判定しない。** 質問の名前は provider ごとに違う + (Claude は `AskUserQuestion`、Grok は `_x.ai/ask_user_question`)。ポリシーが見るのは + アダプタが正規化した `kind: 'question'` で、判定は `core/session.ts` の `isQuestion` に + 1 箇所だけ置く。ツール名だけを見ていたので、**Grok の質問が既定(`auto`)モードで + 自動 allow され、`answers` の無い「承諾」が返って質問がダイアログに一度も出なかった** + (provider 側には「ユーザーは答えなかった」と同じに見える)。 ## result の解釈 diff --git a/.claude/rules/session-domain.md b/.claude/rules/session-domain.md index 02ebe45..6e1ebed 100644 --- a/.claude/rules/session-domain.md +++ b/.claude/rules/session-domain.md @@ -115,8 +115,15 @@ UI・永続・通知は**この表を参照**し、独自の集合(`TERMINAL` ## 永続化(`core/persistence.ts` / `utils/state-store.ts`) - 保存対象の条件(`toPersistedSession`): `restorableStatus(status)` が定義済み **かつ** - `worktreePath` あり **かつ** `sdkSessionId` あり。`creating` / `conflict` / `archived`、および + `worktreePath` あり **かつ** **どこかに resume 用の id がある**(`sdkSessionId` + または `agentSessions` のいずれか)。`creating` / `conflict` / `archived`、および init 前に落ちて resume 不能なものは保存しない。 + - **「現在の `sdkSessionId` があること」を条件にしてはいけない。** `agent_switched` は + 切替先が初めての provider だと `sdkSessionId` を undefined にするので、 + **切替直後に何も送らずに終了したセッションが state.json から丸ごと消えていた** + (戻るための `agentSessions.claude` は残っているのに、worktree だけが孤児になり + タイトル・コスト・PR 参照も失われる)。読み込み側(`toPersistedSessionJson`)も + 同じ条件で受理する。 - 読み込み側(`fromPersistedJson`)は `completed` / `interrupted` / `failed` のみ受理し、 壊れた JSON は空状態へフォールバックする(TUI を落とさない)。 - **エージェントも保存する**: `agent`(最後に駆動していた provider)と `agentSessions` @@ -173,6 +180,21 @@ UI・永続・通知は**この表を参照**し、独自の集合(`TERMINAL` 実行**してしまい、捨てるとユーザーの指示が黙って消える(ログには `user_input` として 残るのに実行されない)。番人は `session.spec.ts` の 「stops the in-flight turn and hands queued follow-ups to the NEW agent」。 + - **移せるのは「まだ誰にも渡していない」ぶんだけ**。Claude Agent SDK は + プロンプトの `AsyncIterable` を**先読み**する(常に `next()` が張られている)ため、 + ターン中の追加指示はその場で CLI の stdin へ書かれ、`AsyncQueue.pending` は 0 のまま。 + つまり Claude では**切替直前に送った追加指示が古い CLI 側のキューに残る** + (SDK の `interrupt()` はそれを `still_queued` の uuid で教えてくるが、 + `cancel_async_message` で個別に取り消すには uuid を打って送る必要があり未対応)。 + Codex / Grok は消費が遅延評価なので移し替えが効く。 +- **切替で畳んだ run のイベントは畳まない**(`Session` の世代カウンタ `epoch`)。 + `for await` は捨てた `run` のイテレータを掴んだままなので、古い provider のイベントが + 切替後にも届く。帰属(`attribution`)はもう切替先なので、そのまま `applyAgentEvent` に + 通すと**前任者の resume id が切替先の id として `agentSessions` と state.json に焼き付き** + (以後 `codex exec resume ` を投げ続ける)、古いターンの `turn_completed` で + セッションが `completed` に戻って auto-PR まで走る。世代が変わったら `break` して + ストリームを閉じる(番人は `session.spec.ts` の + 「ignores events that the folded run emits after the switch」)。 - 1 エージェントセッション 1 ライター。codiva 以外(外部 `claude --resume` 等)から同じ セッションに繋がない。**同時に 2 つの provider を 1 つの worktree で走らせない**。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1fc44bc..1d8ab98 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -241,6 +241,25 @@ Claude のログインは env / 資格情報ファイルで分かるときだけ 使えない(あれはセッションごと終わらせるため)。畳んだループが終わった時点でキューに積み残しが あれば(`AsyncQueue.pending`)、新しいエージェントで消費し直す。 +**畳んだ run のイベントは畳み込まない**(`Session` の世代カウンタ `epoch`)。`for await` は捨てた +イテレータを掴んだままなので、古い provider のイベントは切替後にも届く。帰属(`attribution`)は +もう切替先なので、そのまま `applyAgentEvent` に通すと 2 つの実害が出る: + +1. 遅れて届いた `session_started`(Claude の `system/init` は起動に 1〜3 秒かかる)で + **前任者の resume id が切替先の id として保存される** → 以後 `codex exec resume ` + を投げ続け、`agentSessions` と `state.json` にも焼き付いて往復切替でも直らない。 +2. 古いターンの `turn_completed` で切替済みのセッションが `completed` に戻り、auto-PR と + 完了通知まで走る(`run.interrupt()` は best-effort なのでこの競走は普通に負ける)。 + +世代が変わったら `break` して(= `run.return()` で古いストリームも閉じて)読み捨てる。 +`catch` の失敗記録と「最後の砦」も同じ世代ガードの内側に置く。 + +**積み残しを移せるのは「まだ誰にも渡していない」ぶんだけ**という限界もある。Claude Agent SDK は +プロンプトの `AsyncIterable` を先読みするので、ターン中の追加指示はその場で CLI の stdin へ渡り、 +`AsyncQueue.pending` は 0 のまま(= 古い CLI 側のキューに残る)。SDK の `interrupt()` は +`still_queued` として uuid を返すが、取り消しには送信時に uuid を打つ必要があり未対応。 +Codex / Grok は消費が遅延評価なので移し替えが効く。 + | 引き継がれるもの | 引き継がれないもの | |---|---| | worktree・ブランチ・作業ツリーの内容 | provider 固有のセッションそのもの(各 CLI が別のトランスクリプトを持つ) | @@ -279,11 +298,16 @@ provider ごとの resume id を控え、**これは永続化する**(`state.j systemPrompt を読み直さない provider があり、往復切替でだけ引き継ぎが消える。 アダプタを増やすときは `request.options.handoff` の扱いを必ず実装する(番人は 3 つの `*-adapter.spec.ts`)。 - - **使い捨て**にする(`Session` が次の `open()` で消費する)。常設にすると、引き継ぎが済んだ - あとのターンや通信断からの再起動でも「前任者から引き継いだ」と言い続けることになる。 - ただし**アダプタ側では「実際に provider へ渡るまで」持つ** — 立ち上げ前に中断された - ターン(Grok)や `thread.started` 前に落ちたターン(Codex)で捨てると、1 回きりの - 引き継ぎを空振りで使い切ってしまう。 + - **使い捨て**にするが、落とすのは**渡ったと確認できたときだけ**。`Session` は + `open()` では捨てず、アダプタからの `onHandoffDelivered()` を待つ + (`AgentRunRequest`)。`open()` で捨てていたので、「未ログインの CLI へ切り替えて + 失敗 → ログインして送り直す」という普通の経路で引き継ぎが**黙って消えていた** + (Claude は run が throw して終わるだけなのでアダプタ側の保険も効かない)。 + 報告する点は provider ごとに違う: Claude はプロンプトを `yield` した後(SDK が + 取り出した時点)、Codex は `thread.started`、Grok は `session/prompt` の応答が + 合成エラーでないとき(プロセス死で届かなかったターンを「渡した」と数えない)。 + 常設にしないのは、引き継ぎが済んだあとのターンや通信断からの再起動でも + 「前任者から引き継いだ」と言い続けることになるため。 - **キューへ指示として積まない**。積むと切替直後に「状況を読むだけのターン」が 1 本走り、 provider のプロセスを無駄に立てる(ユーザーが次の指示を出すまで何も起こらないのが正しい)。 - 各項目は 1 行に畳んで `MAX_HANDOFF_FIELD_CHARS` で切り、会話は @@ -291,6 +315,12 @@ provider ごとの resume id を控え、**これは永続化する**(`state.j 1 行で明示する)。**文字数ではなくバイト数**なのは、Codex が指示文を argv で渡すため (Linux の `MAX_ARG_STRLEN` = 131,072 バイト。日本語なら文字数の 3 倍になる。 docs/TECH_NOTES.md 参照)。 + - **予算は「会話ぶん」だけでは足りない**。Codex の argv には systemPrompt + (`.codiva/prompt.md` は無制限)とユーザーの指示文も同じ 1 引数として載るので、 + アダプタが送る直前に `fitHandoff(handoff, 残りバイト)` で会話の古い側から削る + (1 ターンも載らなければ引き継ぎを諦めて `notice` を 1 行残す)。削らないと `spawn` が + `E2BIG` で落ち、`thread.started` が来ない = 引き継ぎが解除されないので**以後どの + ターンも同じ理由で落ち続けてセッションが詰む**。 - **往復切替では重複を許す**。切替先が自分のスレッドを resume できるときはそのぶん文脈が 重なるが、resume が失敗した・圧縮で落ちた場合に「足りない」方が害が大きいので全部渡し、 重複が新しい指示ではないことは引き継ぎ文の中で断る。 diff --git a/docs/TASKS.md b/docs/TASKS.md index b698f67..ef3fde1 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -1378,6 +1378,53 @@ zsh: abort codiva --- +## Phase D の後追い修正(マージ後レビューで見つけた不具合) + +`/agent`(エージェント切替)と会話引き継ぎを入れたあと、切替まわり・provider アダプタ・ +一覧の描画を通しでレビューして見つかった実害のあるものを直した。 + +- [x] **切替で畳んだ run のイベントを畳み込まない**(`Session` の世代カウンタ `epoch`)。 + 遅れて届く `session_started` で前任者の resume id が切替先の id として保存され、 + 以後 `codex exec resume ` を投げ続けていた(`state.json` にも焼き付く)。 + 古いターンの `turn_completed` で completed → auto-PR まで走る経路も同時に消えた +- [x] **未使用の provider へ切り替えたセッションが `state.json` から消えるのを修正**。 + `agent_switched` は `sdkSessionId` を undefined にするので、保存条件が + 「現在の id があること」だと切替直後に終了したセッションが丸ごと落ちていた + → 条件を「**どこかに** resume 用の id がある」に変更(読み込み側も同じ) +- [x] **引き継ぎは「provider へ渡った」と報告されたときだけ落とす** + (`AgentRunRequest.onHandoffDelivered`)。`Session` が `open()` で捨てていたため、 + 未ログインの CLI へ切り替えて失敗 → ログインして送り直す経路で引き継ぎが消えていた。 + Grok は「プロセス死で届かなかったターン」を渡した扱いにしていたのも直した +- [x] **Codex の argv 上限に収める**(`fitHandoff`)。`MAX_HANDOFF_TRANSCRIPT_BYTES` は会話ぶんの + 予算で、systemPrompt(`.codiva/prompt.md` は無制限)と指示文を足すと `MAX_ARG_STRLEN` を + 超えうる。超えると `thread.started` が来ず引き継ぎも解除されないので**毎ターン落ち続ける** +- [x] **切替直後の `Ctrl+C` が効かないのを修正**(予約された再起動を取り消す) +- [x] `stripHandoff` は**最後の**境目で切る(会話ログに同じ見出しが混ざると残骸が + ユーザー発言として復元され、次の切替で入れ子になる) +- [x] **質問の判定を `kind` に統一**(`isQuestion`)。ツール名で見ていたので Grok の + `_x.ai/ask_user_question` が既定(auto)モードで自動 allow され、**空の回答で「承諾」を + 返して質問が一度もダイアログに出ていなかった** +- [x] Grok: 未応答要求の待ち行列を**接続ごと**に持つ(死んだプロセスの後片付けが次の + プロセスの `initialize` を失敗させ、健全なプロセスを殺していた)。readLoop が異常終了 + したときに子を殺す(同じ worktree に 2 本目が立つ)。拒否の選択肢が見つからないときは + `cancelled` に倒す(`options[0]` = `allow-once` へ落ちて**拒否が実行に化ける**)。 + プロセス死の判定を JSON-RPC の code ではなく接続のフラグで行う(`-32000` は Grok 自身も使う)。 + `GROK_HOME=""` で `auth.json` を相対パスで読んでいたのも修正 +- [x] Codex: スレッド id が分かっているクラッシュ(panic / SIGKILL)を `failed`(終端)ではなく + resumable な `connection` に倒す。`reasoning` / `agent_message` / `command_execution` の + 受理ガードに、パーサが無条件に読むフィールドを追加(TypeError でターンごと死ぬ経路) +- [x] 一覧: **バッジ列の溢れで行のクリック判定がズレるのを修正**(`truncate-end` + + 英語文言を 12 セル以内に + `i18n.spec.ts` の番人)。英語 UI で `awaiting_permission` の + 行が 2 行に折り返し、以降のクリックが 1 行ずつズレて最後の行は選べなかった +- [x] 一覧: 準備中(worktree 作成中)のセッションに既定エージェントを載せる(`Claude` と + 嘘の名前が出て、エージェント列が一瞬現れて一覧が組み直されていた) +- [x] 詳細: `/agent` で同じエージェントを選び直したときにエラーを出さない +- [x] `/login` の未対応メッセージにエージェント名を差し込む / `/agent` のヒントの全角 + 区切りを `theme.ts` の記号に / ログインダイアログ表示中のフッタヒントを合わせる / + ダイアログのアンマウントでログインプロセスを畳む(孤児を残さない) + +--- + ## 各 Phase 共通の完了チェック 1. `npm run lint` / `npm test` が通る diff --git a/src/bootstrap/restore-sessions.ts b/src/bootstrap/restore-sessions.ts index 5357afe..91b0045 100644 --- a/src/bootstrap/restore-sessions.ts +++ b/src/bootstrap/restore-sessions.ts @@ -28,7 +28,10 @@ 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')) { + // 現在の provider にまだ会話が無いセッション(切替直後に保存されたもの)は + // 読むトランスクリプトが無い。控え(`agentSessions`)の id は**別 provider の + // もの**なので代わりに読まない。 + if (!agentSupports(capabilities, p.agent, 'transcript') || p.sdkSessionId === undefined) { continue; } const text = await loadTranscriptText(p.worktreePath, p.sdkSessionId); diff --git a/src/core/agent-handoff.spec.ts b/src/core/agent-handoff.spec.ts index 7db2ba0..7034ade 100644 --- a/src/core/agent-handoff.spec.ts +++ b/src/core/agent-handoff.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { attachHandoff, + fitHandoff, handoffInstruction, handoffTranscript, lastUserInstruction, @@ -177,6 +178,54 @@ describe('handoffTranscript', () => { }); }); +describe('fitHandoff', () => { + /** 会話をたっぷり載せた引き継ぎ(Codex の argv 上限の検証用)。 */ + const briefing = (): string => { + const messages = Array.from({ length: 30 }, (_, i) => + entry(i + 1, i % 2 === 0 ? 'user' : 'assistant_text', `turn${i} ${'x'.repeat(3000)}`), + ); + const text = handoffInstruction({ from: 'Claude', branch: 'codiva/t', messages }); + if (!text) { + throw new Error('fixture'); + } + return text; + }; + + it('予算に収まっているものはそのまま返す', () => { + const text = briefing(); + expect(fitHandoff(text, bytes(text))).toBe(text); + }); + + // Codex は systemPrompt(`.codiva/prompt.md` は無制限)とユーザーの指示文まで + // **同じ argv 1 本**に載せる。合計が MAX_ARG_STRLEN を超えると spawn が E2BIG で + // 落ち、`thread.started` が来ないので引き継ぎが解除されず**毎ターン落ち続ける**。 + it('予算を超えたら会話の古い側から削って収める', () => { + const text = briefing(); + const budget = Math.floor(bytes(text) / 2); + const fitted = fitHandoff(text, budget); + expect(fitted).toBeDefined(); + expect(bytes(fitted ?? '')).toBeLessThanOrEqual(budget); + // 見出し・箇条書き・続け方の指示は残す。 + expect(fitted).toContain('# Session handover (codiva)'); + expect(fitted).toContain('- Branch: codiva/t'); + expect(fitted).toContain('inspect the working tree yourself'); + // 直近の会話は残り、古い会話は落ちて省略が明示される。 + expect(fitted).toContain('turn29'); + expect(fitted).not.toContain('turn0 '); + expect(fitted).toContain('Older conversation omitted'); + }); + + it('会話を 1 ターンも載せられなければ諦める(undefined)', () => { + expect(fitHandoff(briefing(), 100)).toBeUndefined(); + }); + + it('会話ブロックの無い引き継ぎは削りようがない(undefined)', () => { + const text = handoffInstruction({ from: 'Claude', branch: 'codiva/t' }); + expect(text).toBeDefined(); + expect(fitHandoff(text ?? '', 10)).toBeUndefined(); + }); +}); + describe('attachHandoff / stripHandoff', () => { it('引き継ぎが無ければ素通し', () => { expect(attachHandoff('やって', undefined)).toBe('やって'); @@ -204,4 +253,25 @@ describe('attachHandoff / stripHandoff', () => { const text = '# Current instruction after the switch\n\nこれは普通の指示'; expect(stripHandoff(text)).toBe(text); }); + + // 引き継ぎの本文には会話ログがそのまま入るので、境目の見出しと同じ行が**中に** + // 現れうる。最初の一致で切ると引き継ぎの残骸がユーザー発言として復元され、 + // それが `lastUserInstruction` に拾われて次の切替で入れ子に写る。 + it('会話ログの中に境目と同じ行があっても、最後の境目で切る', () => { + const handoff = handoffInstruction({ + from: 'Claude', + task: '最初の指示', + messages: [ + entry(1, 'user', '最初の指示'), + // エージェントが見出しをそのまま書き写した(引用した)ケース。 + entry( + 2, + 'assistant_text', + 'こう書きました:\n\n# Current instruction after the switch\n\nおわり', + ), + ], + }); + const sent = attachHandoff('次はこれ', handoff); + expect(stripHandoff(sent)).toBe('次はこれ'); + }); }); diff --git a/src/core/agent-handoff.ts b/src/core/agent-handoff.ts index efe5b45..95c7a51 100644 --- a/src/core/agent-handoff.ts +++ b/src/core/agent-handoff.ts @@ -65,6 +65,59 @@ function utf8Length(text: string): number { return UTF8.encode(text).length; } +/** 会話履歴ブロックの開始・終了タグ({@link fitHandoff} が削る範囲の目印)。 */ +const HISTORY_OPEN = ''; +const HISTORY_CLOSE = ''; + +/** + * 組み立て済みの引き継ぎを、指定バイト数(UTF-8)に収める(純粋)。 + * + * なぜ要るか: {@link MAX_HANDOFF_TRANSCRIPT_BYTES} は**会話だけ**の予算で、Codex は + * systemPrompt(`.codiva/prompt.md` は無制限)とユーザーの指示文まで**同じ argv 1 本**に + * 載せる。合計が Linux の `MAX_ARG_STRLEN`(131,072 バイト)を超えると `spawn` が + * `E2BIG` で落ち、Codex アダプタは `thread.started` を見るまで引き継ぎを持ち続けるので + * **以後どのターンも同じ理由で落ち続けてセッションが詰む**。 + * + * 削るのは**会話履歴の古い側だけ**(見出し・箇条書き・続け方の指示は残す)。ユーザーの + * 指示文や systemPrompt は削らない — そちらを黙って切ると指示の意味が変わる。 + * 会話を全部落としても収まらないときは undefined(= 引き継ぎ無しで送る)。 + */ +export function fitHandoff(handoff: string, budgetBytes: number): string | undefined { + if (utf8Length(handoff) <= budgetBytes) { + return handoff; + } + const open = handoff.indexOf(`${HISTORY_OPEN}\n`); + const close = handoff.indexOf(`\n${HISTORY_CLOSE}`); + if (open === -1 || close === -1 || close < open) { + return undefined; + } + const head = handoff.slice(0, open + HISTORY_OPEN.length + 1); + const tail = handoff.slice(close); + const fixed = utf8Length(head) + utf8Length(tail); + const turns = handoff.slice(open + HISTORY_OPEN.length + 1, close).split('\n\n'); + // 新しい側から詰め直す(切替直後に効くのは直近の文脈)。 + const kept: string[] = []; + let bytes = fixed + utf8Length(OMITTED_MARKER) + SEPARATOR_BYTES; + for (let i = turns.length - 1; i >= 0; i -= 1) { + const turn = turns[i]; + if (turn === undefined) { + continue; + } + const size = utf8Length(turn) + (kept.length === 0 ? 0 : SEPARATOR_BYTES); + if (bytes + size > budgetBytes) { + break; + } + kept.unshift(turn); + bytes += size; + } + if (kept.length === 0) { + // 1 ターンも入らない = 会話を載せる余地が無い。引き継ぎ自体を諦める + // (黙って壊れるより、渡せなかったことをはっきりさせる)。 + return undefined; + } + return `${head}${[OMITTED_MARKER, ...kept].join('\n\n')}${tail}`; +} + export interface HandoffInput { /** 引き継ぐ側の表示名(切替前のエージェント)。 */ from: string; @@ -104,7 +157,12 @@ export function stripHandoff(text: string): string { return text; } const marker = `\n\n${CURRENT_INSTRUCTION_HEADING}\n\n`; - const at = text.indexOf(marker); + // **最後の**境目で切る。`attachHandoff` はこの見出しを最上位の区切りとして + // 末尾に 1 回だけ置くが、引き継ぎの本文には会話ログ(= 任意のユーザー・ + // アシスタント発話)がそのまま入るので、同じ行が中に現れることがある。 + // `indexOf` だとそこで切ってしまい、**引き継ぎの残骸がユーザー発言として** + // ログに載る(それが `lastUserInstruction` に拾われ、次の切替で入れ子に写る)。 + const at = text.lastIndexOf(marker); return at === -1 ? text : text.slice(at + marker.length); } @@ -234,9 +292,9 @@ export function handoffInstruction(input: HandoffInput): string | undefined { 'the switch, some of it may already be in your own context — repeated lines are not new', 'instructions.', '', - '', + HISTORY_OPEN, transcript, - '', + HISTORY_CLOSE, ); } lines.push( diff --git a/src/core/agent-ports.ts b/src/core/agent-ports.ts index eb41bdb..8a5c17e 100644 --- a/src/core/agent-ports.ts +++ b/src/core/agent-ports.ts @@ -117,6 +117,16 @@ export interface AgentRunRequest { * 解決するまでエージェントはブロックされてよい。 */ requestPermission: (request: Omit) => Promise; + /** + * `options.handoff` を **provider へ実際に渡した**という報告(省略可)。 + * + * 引き継ぎは 1 回きりなので、`Session` はこれが来るまで持ち続ける。渡す前に run が + * 死んだ経路(CLI 未導入・未ログイン・起動中の `Ctrl+C`・spawn 失敗)で落としてしまうと、 + * ログインし直して送り直しても**切替の文脈が二度と渡らない**。アダプタは + * 「CLI が受け取った」と言える点でだけ呼ぶ(Claude なら SDK がそのメッセージを + * 取り出したとき、Codex なら `thread.started`、Grok なら `session/prompt` の応答)。 + */ + onHandoffDelivered?: () => void; abortController: AbortController; } diff --git a/src/core/claude-adapter.ts b/src/core/claude-adapter.ts index 9963280..16b0d21 100644 --- a/src/core/claude-adapter.ts +++ b/src/core/claude-adapter.ts @@ -68,11 +68,19 @@ function toUserMessage(text: string): SDKUserMessage { async function* toSdkPrompt( prompt: AsyncIterable, handoff?: string, + onHandoffDelivered?: () => void, ): AsyncIterable { let pending = handoff; for await (const text of prompt) { yield toUserMessage(attachHandoff(text, pending)); - pending = undefined; + // **`yield` の**後**で落とす**。async generator の続きが走るのは消費側(SDK)が + // 次を要求したとき = 今のメッセージを取り出したときなので、ここまで来れば + // 「確かに渡した」と言える。起動に失敗して query が throw する経路では + // ここへ来ないため、引き継ぎは `Session` に残り次の run で渡し直される。 + if (pending !== undefined) { + pending = undefined; + onHandoffDelivered?.(); + } } } @@ -135,7 +143,7 @@ export function createClaudeAdapter(deps: { const opts = request.options; const handle = deps.queryFn({ - prompt: toSdkPrompt(request.prompt, request.options.handoff), + prompt: toSdkPrompt(request.prompt, request.options.handoff, request.onHandoffDelivered), options: { cwd: request.cwd, permissionMode: opts.permissionMode ?? 'acceptEdits', diff --git a/src/core/codex-adapter.spec.ts b/src/core/codex-adapter.spec.ts index 20c6079..ca52e89 100644 --- a/src/core/codex-adapter.spec.ts +++ b/src/core/codex-adapter.spec.ts @@ -215,6 +215,35 @@ describe('createCodexAdapter turn loop', () => { await done; }); + // 指示文は systemPrompt と引き継ぎもろとも **argv 1 本**として渡る。Linux の + // `MAX_ARG_STRLEN`(131,072 バイト)を超えると `spawn` が E2BIG で落ち、 + // `thread.started` が来ないので引き継ぎが解除されず**以後のターンも同じ理由で + // 落ち続ける**(= セッションが詰む)。収まらない引き継ぎは削って、削ったことを残す。 + it('argv の上限を超えるときは引き継ぎを削って通す', async () => { + const codex = makeFakeCodex(); + const adapter = createCodexAdapter({ spawn: codex.spawn }); + const options: AgentRunOptions = { + // 巨大な `.codiva/prompt.md` を持つリポジトリ。 + systemPrompt: 'S'.repeat(130_000), + handoff: + '# Session handover (codiva)\n\n\nUser:\nold\n', + }; + const { prompts, events, done } = drive(adapter, { options }); + + prompts.push('go'); + await waitFor(() => codex.requests.length === 1, 'the spawn'); + const sent = codex.requests[0]?.prompt ?? ''; + expect(new TextEncoder().encode(sent).length).toBeLessThanOrEqual(131_072); + // 削ったのは引き継ぎだけ(ユーザーの指示と systemPrompt はそのまま)。 + expect(sent).not.toContain('Session handover'); + expect(sent.endsWith('go')).toBe(true); + expect(events.some((e) => e.kind === 'notice' && /handover skipped/.test(e.text))).toBe(true); + + codex.at(0).end(); + prompts.close(); + await done; + }); + it('prepends the systemPrompt to the first prompt only', async () => { const codex = makeFakeCodex(); const adapter = createCodexAdapter({ spawn: codex.spawn }); @@ -345,6 +374,32 @@ describe('createCodexAdapter completion fallback', () => { detail: 'codex exited with code 127', }); }); + + // `failed` は**終端**(`STATUS_META.failed.resumable === false`)なので、再開できる + // 死に方をそこへ落とすと `codex exec resume ` で続けられるのに導線が + // 消える。スレッド id が分かっているクラッシュ(Rust の panic = exit 101、 + // 外からの SIGKILL = code null)は resumable な `connection` に倒す。 + it('スレッドが分かっているクラッシュは再開可能な停止にする', async () => { + const codex = makeFakeCodex([ + { code: 101, stderr: "thread 'main' panicked at src/lib.rs:1:1" }, + ]); + const adapter = createCodexAdapter({ spawn: codex.spawn }); + const { prompts, events, done } = drive(adapter); + + prompts.push('go'); + await waitFor(() => codex.requests.length === 1, 'the spawn'); + codex.at(0).emit({ type: 'thread.started', thread_id: 'th-1' }); + await waitFor(() => events.some((e) => e.kind === 'session_started'), 'thread.started'); + codex.at(0).end(); + + prompts.close(); + await done; + expect(events.at(-1)).toMatchObject({ + kind: 'turn_stopped', + cause: 'connection', + detail: "thread 'main' panicked at src/lib.rs:1:1", + }); + }); }); describe('createCodexAdapter interrupt', () => { diff --git a/src/core/codex-adapter.ts b/src/core/codex-adapter.ts index 6201023..662c69d 100644 --- a/src/core/codex-adapter.ts +++ b/src/core/codex-adapter.ts @@ -1,5 +1,5 @@ import type { AgentEvent } from './agent-events'; -import { attachHandoff } from './agent-handoff'; +import { attachHandoff, fitHandoff } from './agent-handoff'; import type { AgentAdapter, AgentAvailability, @@ -106,6 +106,25 @@ function withSystemPrompt(prompt: string, systemPrompt: string | undefined): str return systemPrompt ? `${systemPrompt}\n\n---\n\n${prompt}` : prompt; } +/** + * 指示文 1 本(= argv の 1 引数)に載せてよい最大バイト数。 + * + * Linux の `execve` は引数 1 本あたり `MAX_ARG_STRLEN`(32 ページ = 131,072 バイト)で + * 打ち切り、超えると `E2BIG` で**起動そのもの**が失敗する(macOS には per-arg の + * 上限が無いので手元では再現しない)。余白は引き継ぎの境目の見出しなど数百バイトぶん。 + */ +const MAX_PROMPT_BYTES = 128_000; + +/** `attachHandoff` が挟む境目(見出し + 空行)ぶんの概算バイト数。 */ +const MARKER_BYTES = 64; + +const UTF8 = new TextEncoder(); + +/** UTF-8 のバイト長(argv に載る実サイズ)。 */ +function utf8Length(text: string): number { + return UTF8.encode(text).length; +} + /** `AgentAdapter` を Codex 用に組み立てる。`spawn` は DI(テストはフェイクを注入)。 */ export function createCodexAdapter(deps: { spawn: CodexSpawn; @@ -242,10 +261,33 @@ export function createCodexAdapter(deps: { // 付かず、次のターンは**新しいスレッド**として始まる。latch していると // そこで前置されず、systemPrompt を一度も渡せないセッションになる // (symlink 共有の注意書きが落ちると、リンク越しに元リポジトリを壊しうる)。 - const userPrompt = attachHandoff(text, handoff); - const prompt = threadId - ? userPrompt - : withSystemPrompt(userPrompt, request.options.systemPrompt); + const systemPrompt = threadId ? undefined : request.options.systemPrompt; + + // **argv 1 本の上限に収める**。指示文は systemPrompt と引き継ぎもろとも + // 1 つの引数として渡るので(`utils/codex.ts` の `codexArgs`)、超えると + // `spawn` が `E2BIG` で落ちる。しかも引き継ぎの解除点は `thread.started` + // なので、落ちるとスレッドが付かず**以後のターンも同じ理由で落ち続ける** + // (= セッションが詰む)。削るのは引き継ぎの会話履歴だけで、ユーザーの + // 指示文と systemPrompt は削らない。 + const overhead = + utf8Length(text) + (systemPrompt ? utf8Length(systemPrompt) + 7 : 0) + MARKER_BYTES; + const fitted = + handoff === undefined + ? undefined + : fitHandoff(handoff, Math.max(0, MAX_PROMPT_BYTES - overhead)); + if (handoff !== undefined && fitted === undefined) { + // 会話を 1 ターンも載せられない(巨大な `.codiva/prompt.md` / 長文の指示)。 + // 黙って捨てず 1 行残す。引き継ぎはここで諦める(持ち続けても毎ターン + // 同じ理由で落とすだけ)。 + yield { + kind: 'notice', + text: 'handover skipped: the prompt would exceed the argument size limit', + }; + handoff = undefined; + request.onHandoffDelivered?.(); + } + const userPrompt = attachHandoff(text, fitted); + const prompt = withSystemPrompt(userPrompt, systemPrompt); const proc = deps.spawn({ cwd: request.cwd, @@ -276,7 +318,12 @@ export function createCodexAdapter(deps: { // 既存スレッドへ戻る場合もここを通る(実測: codex 0.147 系。同じ // thread_id が返る)。ここが唯一の解除点なので、出さなくなったら // 引き継ぎが毎ターン前置され続ける。 - handoff = undefined; + if (handoff !== undefined) { + handoff = undefined; + // `Session` 側も同じ条件で落とす(渡る前に run が死んだら + // 次の run で渡し直せるように、持ち主は Session のまま)。 + request.onHandoffDelivered?.(); + } probeDuringTurn(event.thread_id); } else if (event.type === 'turn.completed' || event.type === 'turn.failed') { sawTerminal = true; @@ -301,9 +348,20 @@ export function createCodexAdapter(deps: { if (!interrupted && !request.abortController.signal.aborted && !sawTerminal) { const { code, stderr } = proc.result(); const detail = stderr.trim() || `codex exited with code ${code ?? 'null'}`; + // **終端イベントを出さずに死んだプロセスは `failed` に落とさない。** + // Rust の panic(exit 101)や外からの SIGKILL(code null)は + // どの分類パターンにも当たらないので `failed` = **終端**になり、 + // `codex exec resume ` で続けられるのに再開の導線が消える。 + // スレッド id が分かっているなら resumable な `connection` に倒す + // (Grok アダプタが `EXITED_CODE` でやっているのと同じ床)。 + const cause = classifyCodexError(detail); yield code === 0 ? { kind: 'turn_completed', text: '' } - : { kind: 'turn_stopped', cause: classifyCodexError(detail), detail }; + : { + kind: 'turn_stopped', + cause: cause === 'failed' && threadId ? 'connection' : cause, + detail, + }; } // 問い合わせの回収は**終端イベントを流したあと**。`model_resolved` は diff --git a/src/core/codex-events.spec.ts b/src/core/codex-events.spec.ts index c3b83d7..1ed1cb5 100644 --- a/src/core/codex-events.spec.ts +++ b/src/core/codex-events.spec.ts @@ -104,6 +104,13 @@ describe('toCodexEvent', () => { // parse 側が無条件に配列として触るフィールドが無いもの。 [{ type: 'item.started', item: { id: 'i0', type: 'file_change' } }], [{ type: 'item.completed', item: { id: 'i0', type: 'todo_list' } }], + // parse 側が `text.trim()` として無条件に読むフィールドが無いもの + // (通すと TypeError がターンのストリームごと殺す)。 + [{ type: 'item.completed', item: { id: 'i0', type: 'agent_message' } }], + [{ type: 'item.completed', item: { id: 'i0', type: 'reasoning' } }], + [{ type: 'item.completed', item: { id: 'i0', type: 'reasoning', text: 42 } }], + // `$ ` として表示に使うので、無いものは受けない("$ undefined" を出さない)。 + [{ type: 'item.completed', item: { id: 'i0', type: 'command_execution' } }], ])('rejects %j', (value) => { expect(toCodexEvent(value)).toBeUndefined(); }); diff --git a/src/core/codex-events.ts b/src/core/codex-events.ts index 77ba801..d2505ea 100644 --- a/src/core/codex-events.ts +++ b/src/core/codex-events.ts @@ -116,6 +116,16 @@ function hasItem(value: object): boolean { if (detail.type === 'todo_list') { return Array.isArray(detail.items); } + // 本文をそのまま(`text.trim()` で)読む型は `text` の型まで見る。欠けた行を + // 通すと `parseCodexEvent` が TypeError で落ち、上と同じくターンごと死ぬ。 + if (detail.type === 'reasoning' || detail.type === 'agent_message') { + return hasString(item, 'text'); + } + // `$ ` として表示に使うので、文字列でなければ受けない + // (`"$ undefined"` というログ行を出さない)。 + if (detail.type === 'command_execution') { + return hasString(item, 'command'); + } return true; } diff --git a/src/core/grok-adapter.spec.ts b/src/core/grok-adapter.spec.ts index e8f8e9f..b0e6840 100644 --- a/src/core/grok-adapter.spec.ts +++ b/src/core/grok-adapter.spec.ts @@ -439,6 +439,33 @@ describe('createGrokAdapter', () => { }); }); + // `kind` は optional。CLI が名前を変えた・落としたときに「1 番目の選択肢」へ + // 落ちると、実データの先頭は `allow-once` なので**拒否したのに実行される**。 + it('拒否できる選択肢が見当たらなければ cancelled で返す(allow に化けさせない)', async () => { + const grok = makeFakeGrok(); + const { harness } = run(grok.spawn); + harness.prompts.push('rm it'); + const proc = await grok.at(0); + await handshake(proc); + proc.emit({ + jsonrpc: '2.0', + id: 906, + method: 'session/request_permission', + params: { + sessionId: 'sess-1', + toolCall: { toolCallId: 'c1', rawInput: {} }, + // `kind` の無い(= CLI 側の語彙が変わった)選択肢。先頭は実行系。 + options: [{ optionId: 'allow-once' }, { optionId: 'reject-once' }], + }, + }); + await waitFor(() => harness.permissions.length === 1, 'permission dialog'); + harness.permissions[0]?.resolve({ behavior: 'deny', message: 'no' }); + await waitFor(() => proc.sent.some((m) => m.id === 906), 'permission reply'); + expect(proc.sent.find((m) => m.id === 906)?.result).toEqual({ + outcome: { outcome: 'cancelled' }, + }); + }); + it('質問は QuestionSpec へ写り、回答は質問文をキーにしたマップで返す', async () => { const grok = makeFakeGrok(); const { harness } = run(grok.spawn); @@ -624,6 +651,62 @@ describe('createGrokAdapter', () => { await waitFor(() => second.find('session/prompt') !== undefined, 'prompt on the new process'); }); + // 未応答要求の待ち行列は**接続ごと**に持つ。1 本にまとめていた頃は、死んだ + // プロセスの後片付け(stdout が閉じたときに待ち人を全部起こす処理)が + // **次のプロセスの** `initialize` まで「1 本目の stderr」で失敗させ、健全な + // プロセスを殺していた(実プロセスの SIGTERM は stdout が閉じるまで少し遅れる)。 + it('死んだプロセスの後片付けが次のプロセスの要求を巻き込まない', async () => { + const procs: FakeProcess[] = []; + const spawn: GrokSpawn = () => { + const fake = makeFakeProcess(); + procs.push(fake); + // kill しても stdout が閉じるのは少し後(= 実プロセスと同じタイミング)。 + return { + [Symbol.asyncIterator]: () => fake.proc[Symbol.asyncIterator](), + send: (message: unknown) => fake.proc.send(message), + kill: () => { + setTimeout(() => fake.proc.kill(), 5); + }, + result: () => fake.proc.result(), + }; + }; + const { harness } = run(spawn); + harness.prompts.push('one'); + harness.prompts.push('two'); + + await waitFor(() => procs.length > 0, 'first process'); + const first = procs[0]; + if (!first) { + throw new Error('no first process'); + } + first.setExit({ code: 1, stderr: 'first process stderr' }); + await waitFor(() => first.find('initialize') !== undefined, 'initialize'); + first.reply('initialize', { protocolVersion: 1 }); + await waitFor(() => first.find('session/new') !== undefined, 'session/new'); + first.replyError('session/new', { + code: -32000, + message: 'Authentication required', + data: 'no auth method id provided', + }); + + // 2 本目が起きる(1 本目の stdout はこの後で閉じる)。 + await waitFor(() => procs.length > 1, 'second process'); + const second = procs[1]; + if (!second) { + throw new Error('no second process'); + } + await waitFor(() => second.find('initialize') !== undefined, 'initialize on #2'); + await new Promise((r) => setTimeout(r, 20)); // 1 本目の後片付けを通す + await handshake(second, { sessionId: 'sess-2' }); + await waitFor(() => second.find('session/prompt') !== undefined, 'prompt on #2'); + + // 2 本目は生きたまま。止まったのは 1 本目のターンだけ。 + expect(second.wasKilled()).toBe(false); + const stops = harness.events.filter((e) => e.kind === 'turn_stopped'); + expect(stops).toHaveLength(1); + expect(stops[0]).toMatchObject({ cause: 'auth' }); + }); + it('ターンの最中にプロセスが死んだら、stderr を理由にして再開可能な停止にする', async () => { const grok = makeFakeGrok(); const { harness } = run(grok.spawn); diff --git a/src/core/grok-adapter.ts b/src/core/grok-adapter.ts index c99b1c4..df01622 100644 --- a/src/core/grok-adapter.ts +++ b/src/core/grok-adapter.ts @@ -110,6 +110,29 @@ interface Pending { resolve(message: GrokReply): void; } +/** + * 1 本の `grok agent stdio` 接続(プロセス + **その接続の**未応答要求)。 + * + * `pending` を接続ごとに持つのが要点。1 つの Map を共有していると、死んだプロセスの + * 後片付け(stdout が閉じたときに待ち人を全部起こす処理)が**次のプロセスの** + * 未応答要求まで「プロセスが死んだ」エラーで解決してしまう — 実際に、1 本目の + * `session/new` が失敗して 2 本目を起こした直後に 1 本目の stdout が閉じると、 + * 2 本目の `initialize` が 1 本目の stderr で失敗し、健全なプロセスまで殺していた。 + * 送信も接続に紐づけて、古い readLoop の応答が新しいプロセスへ流れ込むのを防ぐ。 + */ +interface GrokConn { + stream: GrokProcess; + pending: Map; + /** + * このプロセスは死んだ(応答は codiva が合成したもの)。 + * + * **`EXITED_CODE` で見分けない。** あれは JSON-RPC の実装定義レンジの値で、Grok 自身も + * 同じレンジを使う(実測: 未ログインの `session/new` が `-32000` を返す)。合成かどうかは + * ここで持つ(「指示が届いたか」「再開できる停止か」の判定がこれに乗っている)。 + */ + dead?: boolean; +} + /** * JSON-RPC のエラーを 1 本の文字列に。**両方を残す** — `message` は総称 * (`Internal error` / `Authentication required`)、`data` に実際の理由が入るので、 @@ -171,14 +194,19 @@ function toGrokAnswers(input: Record | undefined): Record o.kind === wanted)?.optionId ?? - options.find((o) => o.kind === fallback)?.optionId ?? - options[0]?.optionId - ); + const byKind = (kind: string): string | undefined => + options.find((o) => o.kind === kind)?.optionId; + if (allow) { + // 許可は当てが外れても「1 番目 = 実行系」で概ね正しい(実データの先頭は + // `allow-once`)。 + return byKind('allow_once') ?? byKind('allow_always') ?? options[0]?.optionId; + } + // **拒否は当てずっぽうで選ばない。** `kind` は optional なので、CLI が名前を変えたり + // 落としたりすると `options[0]` へ落ちるが、それは実データでは `allow-once` = + // **「n」を押したのにツールが実行される**。見つからなければ undefined を返し、 + // 呼び出し側の `cancelled`(安全側)に倒す。 + return byKind('reject_once') ?? byKind('reject_always'); } /** `AgentAdapter` を Grok 用に組み立てる。`spawn` は DI(テストはフェイクを注入)。 */ @@ -206,8 +234,8 @@ export function createGrokAdapter(deps: { open(request: AgentRunRequest): AgentRun { const events = new AsyncQueue(); const parser = createGrokParser(); - const pending = new Map(); - let proc: GrokProcess | undefined; + /** いま担当している接続(プロセスが死ぬと undefined に戻り、次の指示で起こし直す)。 */ + let conn: GrokConn | undefined; let sessionId = request.resume; let model = request.options.model; /** codiva 側から中断したか(そのターンの `cancelled` を静かに終わらせる)。 */ @@ -219,19 +247,21 @@ export function createGrokAdapter(deps: { let nextId = 1; const send = (message: unknown): void => { - proc?.send(message); + conn?.stream.send(message); }; - /** 要求を 1 本投げて応答を待つ。プロセスが死んだら reject される。 */ + /** 要求を 1 本投げて応答を待つ。プロセスが居なければ reject される。 */ const rpc = (method: string, params: unknown): Promise => { const id = nextId++; return new Promise((resolve, reject) => { - if (!proc) { + const target = conn; + if (!target) { reject(new Error('grok agent is not running')); return; } - pending.set(id, { resolve }); - send({ jsonrpc: '2.0', id, method, params }); + // **その接続の**待ち行列に登録する(応答も同じ接続の readLoop が返す)。 + target.pending.set(id, { resolve }); + target.stream.send({ jsonrpc: '2.0', id, method, params }); }); }; @@ -239,13 +269,22 @@ export function createGrokAdapter(deps: { send({ jsonrpc: '2.0', method, params }); }; - /** 許可要求 → codiva のダイアログ → ACP の応答。 */ - const answerPermission = async (id: number | string, raw: unknown): Promise => { + /** + * 許可要求 → codiva のダイアログ → ACP の応答。 + * + * `reply` は**要求が来た接続**へ返す関数(`send` のように「今の接続」へ送ると、 + * ユーザーが答えている間にプロセスが入れ替わったとき別のプロセスへ流れ込む)。 + */ + const answerPermission = async ( + reply: (message: unknown) => void, + id: number | string, + raw: unknown, + ): Promise => { const params = toGrokPermissionParams(raw); if (!params) { // 選択肢が読めない要求には答えようがない。断って先へ進ませる(放置すると // エージェントが永久に待つ)。 - send({ jsonrpc: '2.0', id, result: { outcome: { outcome: 'cancelled' } } }); + reply({ jsonrpc: '2.0', id, result: { outcome: { outcome: 'cancelled' } } }); return; } const tool = params.toolCall; @@ -255,7 +294,7 @@ export function createGrokAdapter(deps: { kind: 'tool', }); const optionId = pickOption(params, decision.behavior === 'allow'); - send({ + reply({ jsonrpc: '2.0', id, result: @@ -266,10 +305,14 @@ export function createGrokAdapter(deps: { }; /** 質問要求 → codiva の質問ダイアログ → ACP の応答。 */ - const answerQuestion = async (id: number | string, raw: unknown): Promise => { + const answerQuestion = async ( + reply: (message: unknown) => void, + id: number | string, + raw: unknown, + ): Promise => { const params = toGrokQuestionParams(raw); if (!params) { - send({ jsonrpc: '2.0', id, result: { outcome: 'cancelled' } }); + reply({ jsonrpc: '2.0', id, result: { outcome: 'cancelled' } }); return; } const questions = toQuestionSpecs(params); @@ -282,10 +325,10 @@ export function createGrokAdapter(deps: { if (decision.behavior !== 'allow') { // 断りは `cancelled`(`declined` という値は無い)。エージェントは // 「答えないので自分の判断で進めろ」と受け取る。 - send({ jsonrpc: '2.0', id, result: { outcome: 'cancelled' } }); + reply({ jsonrpc: '2.0', id, result: { outcome: 'cancelled' } }); return; } - send({ + reply({ jsonrpc: '2.0', id, result: { outcome: 'accepted', answers: toGrokAnswers(decision.input) }, @@ -293,16 +336,18 @@ export function createGrokAdapter(deps: { }; /** stdout を読み続け、通知はイベントへ、要求は応答へ回す。 */ - const readLoop = async (stream: GrokProcess): Promise => { - for await (const raw of stream) { + const readLoop = async (target: GrokConn): Promise => { + // 応答は**この接続へ**返す(同じプロセスに向けて答える)。 + const reply = (message: unknown): void => target.stream.send(message); + for await (const raw of target.stream) { const message = toGrokMessage(raw); if (!message) { continue; } if (message.kind === 'response' || message.kind === 'error') { - const waiter = pending.get(Number(message.id)); + const waiter = target.pending.get(Number(message.id)); if (waiter) { - pending.delete(Number(message.id)); + target.pending.delete(Number(message.id)); waiter.resolve(message); } continue; @@ -311,20 +356,20 @@ export function createGrokAdapter(deps: { if (message.method === 'session/request_permission') { // 投げても**必ず答える**。放置するとエージェントは待ち続け、ターンが // 永久に終わらない(`awaiting_permission` のまま出口が無い)。 - void answerPermission(message.id, message.params).catch(() => { - send({ + void answerPermission(reply, message.id, message.params).catch(() => { + reply({ jsonrpc: '2.0', id: message.id, result: { outcome: { outcome: 'cancelled' } }, }); }); } else if (message.method === '_x.ai/ask_user_question') { - void answerQuestion(message.id, message.params).catch(() => { - send({ jsonrpc: '2.0', id: message.id, result: { outcome: 'cancelled' } }); + void answerQuestion(reply, message.id, message.params).catch(() => { + reply({ jsonrpc: '2.0', id: message.id, result: { outcome: 'cancelled' } }); }); } else { // 知らない要求は「未実装」で返す。黙って捨てるとエージェントが待ち続ける。 - send({ + reply({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'Method not found' }, @@ -360,22 +405,30 @@ export function createGrokAdapter(deps: { */ const start = async (): Promise => { const stream = deps.spawn({ cwd: request.cwd, effort: request.options.effort }); - proc = stream; - void readLoop(stream) + const target: GrokConn = { stream, pending: new Map() }; + conn = target; + void readLoop(target) .catch(() => {}) .finally(() => { - // プロセスが死んだら待っている要求を全部起こす(ぶら下がったままにしない)。 + // **必ず殺す**。読み取りループは EOF 以外でも抜ける(stdout の `'error'` = + // 非同期イテレータの reject、パーサの throw)。そこで殺さないと `grok` が + // 生き残ったまま担当が空に戻り、次の指示で**同じ worktree に 2 本目**が + // 立って両方が書き込む(Codex 側は `finally { proc.kill() }` で守っている)。 + stream.kill(); // **今の担当が自分のときだけ**空に戻す — 起動に失敗して既に別のプロセスへ - // 差し替わっているとき(`fail()` 後の再起動)に、新しい方を殺してしまわない。 - if (proc === stream) { - proc = undefined; + // 差し替わっているとき(`fail()` 後の再起動)に、新しい方を消してしまわない。 + if (conn === target) { + conn = undefined; } // 理由は**プロセスから**取る(`grok agent exited` のような合成文言だと // 分類が効かず、一過性のクラッシュが `failed`(再開不可の終端)になる)。 + target.dead = true; const { code, stderr } = stream.result(); const detail = stderr.trim() || `grok agent exited with code ${code ?? 'null'}`; - for (const [id, waiter] of pending) { - pending.delete(id); + // 起こすのは**この接続の**待ち人だけ(共有していた頃は、死んだプロセスの + // 後片付けが次のプロセスの `initialize` まで失敗させていた)。 + for (const [id, waiter] of target.pending) { + target.pending.delete(id); waiter.resolve({ kind: 'error', id, @@ -388,11 +441,11 @@ export function createGrokAdapter(deps: { .catch(() => {}); // 立ち上げに失敗したら**自分で畳む**(起こしっぱなしの `grok` が worktree を - // 触り続けないように)。次のターンで起こし直せるよう `proc` は空に戻す。 + // 触り続けないように)。次のターンで起こし直せるよう担当は空に戻す。 const fail = (detail: string): never => { stream.kill(); - if (proc === stream) { - proc = undefined; + if (conn === target) { + conn = undefined; } throw new Error(detail); }; @@ -451,8 +504,9 @@ export function createGrokAdapter(deps: { /** * 1 ターン。`session/prompt` の応答が終わりを告げる。 * - * 戻り値は「そのターンを実際に投げたか」。中断で捨てたターンと投げたターンを - * 呼び出し側が区別できないと、1 回きりの引き継ぎを空振りで使い切ってしまう。 + * 戻り値は「そのターンを**エージェントが実際に受け取ったか**」。中断で捨てた + * ターンや、プロセスが死んで届かなかったターンと区別できないと、1 回きりの + * 引き継ぎを空振りで使い切ってしまう。 */ const runTurn = async (text: string): Promise => { // **中断されたターンは始めない**。`Ctrl+C` はセッションの立ち上げ @@ -466,15 +520,19 @@ export function createGrokAdapter(deps: { } turnInFlight = true; try { - await runPrompt(text); + return await runPrompt(text); } finally { turnInFlight = false; } - return true; }; - /** `session/prompt` の 1 往復。 */ - const runPrompt = async (text: string): Promise => { + /** + * `session/prompt` の 1 往復。戻り値は「エージェントが受け取ったか」 + * (プロセスが死んで応答が合成エラーになった場合は false)。 + */ + const runPrompt = async (text: string): Promise => { + // どのプロセスへ投げたかを覚えておく(応答が合成かどうかの判定に使う)。 + const target = conn; const response = await rpc('session/prompt', { sessionId, prompt: [{ type: 'text', text }], @@ -487,17 +545,20 @@ export function createGrokAdapter(deps: { const detail = errorText(response.error); if (interrupted || request.abortController.signal.aborted) { // 中断は Session 側で既に確定させてある(二重にログを出さない)。 - return; + return false; } // プロセスが黙って死んだ(終端イベントも応答も無い)ときは、文言から // 何も読み取れなくても**再開できる中断**に倒す。`failed` は終端 = // 再開の導線が消えるので、一過性のクラッシュをそこへ落とさない。 + const exited = target?.dead === true; const cause = - grokStopCause(detail, lastErrorType) === 'failed' && response.error.code === EXITED_CODE + grokStopCause(detail, lastErrorType) === 'failed' && exited ? 'connection' : grokStopCause(detail, lastErrorType); events.push({ kind: 'turn_stopped', cause, detail }); - return; + // 応答が**合成された**エラー(プロセス死)なら、この指示はエージェントへ + // 届いていない。引き継ぎを落とさず次の run で渡し直す。 + return !exited; } const result = response.result as GrokPromptResult | undefined; const model_ = result?._meta?.modelId; @@ -514,13 +575,13 @@ export function createGrokAdapter(deps: { detail: 'turn cancelled by the agent', }); } - return; + return true; } if (interrupted || request.abortController.signal.aborted) { // **中断が競り勝つ**。`Ctrl+C` と `end_turn` が同時に届くことがあり、 // ここで完了にすると `interrupted` が `completed` に化けて auto-PR まで // 走ってしまう(ユーザーは止めたつもりでいる)。 - return; + return true; } if (stop !== undefined && stop !== 'end_turn') { // `max_tokens` / `max_turn_requests` / `refusal`。失敗ではないので @@ -528,6 +589,7 @@ export function createGrokAdapter(deps: { events.push({ kind: 'notice', text: `stopped: ${stop}` }); } events.push({ kind: 'turn_completed', text: '' }); + return true; }; /** プロンプトの流れを 1 本のターン列として回す。 */ @@ -540,7 +602,7 @@ export function createGrokAdapter(deps: { } interrupted = false; lastErrorType = undefined; - if (!proc) { + if (!conn) { try { await start(); } catch (error) { @@ -559,8 +621,11 @@ export function createGrokAdapter(deps: { // 落とすと切替の文脈だけが黙って消える(`Session` 側の使い捨ては // `open()` の時点で済んでいるので、二度と渡らない)。 const sent = await runTurn(attachHandoff(text, handoff)); - if (sent) { + if (sent && handoff !== undefined) { handoff = undefined; + // `Session` 側の使い捨てもここで初めて成立する(渡る前に死んだ run の + // ぶんは Session に残り、次の run で渡し直される)。 + request.onHandoffDelivered?.(); } } } catch (error: unknown) { @@ -580,7 +645,7 @@ export function createGrokAdapter(deps: { }; const abort = () => { - proc?.kill(); + conn?.stream.kill(); events.close(); }; request.abortController.signal.addEventListener('abort', abort, { once: true }); @@ -600,7 +665,7 @@ export function createGrokAdapter(deps: { } finally { request.abortController.signal.removeEventListener('abort', abort); // 消費側に捨てられたら CLI も畳む(worktree を触り続けさせない)。 - proc?.kill(); + conn?.stream.kill(); } }, @@ -617,7 +682,7 @@ export function createGrokAdapter(deps: { setModel: async (next) => { model = next; - if (!sessionId || !proc || next === undefined) { + if (!sessionId || !conn || next === undefined) { // 「既定に戻す」は次のセッションから(ACP に「既定へ戻す」要求は無い)。 return; } diff --git a/src/core/grok-events.spec.ts b/src/core/grok-events.spec.ts index 3f5e16e..a589ccf 100644 --- a/src/core/grok-events.spec.ts +++ b/src/core/grok-events.spec.ts @@ -196,6 +196,17 @@ describe('toGrokUpdate', () => { ['content が文字列の chunk', { sessionUpdate: 'agent_thought_chunk', content: 'hi' }], ['entries が配列でない plan', { sessionUpdate: 'plan', entries: { a: 1 } }], ['entries の無い plan', { sessionUpdate: 'plan' }], + // `e.content` / `e.status` を無条件に読むので、要素まで見る。 + ['entries の要素が object でない plan', { sessionUpdate: 'plan', entries: [null] }], + // `content` は for-of で回す(配列でない truthy は TypeError = ターンごと死ぬ)。 + [ + 'content が配列でない tool_call_update', + { sessionUpdate: 'tool_call_update', toolCallId: 'c1', content: { text: 'x' } }, + ], + [ + 'content の要素が object でない tool_call_update', + { sessionUpdate: 'tool_call_update', toolCallId: 'c1', content: ['x'] }, + ], ['message の無い retry_state', { sessionUpdate: 'retry_state', type: 'failed' }], ])('%s を捨てる', (_name, value) => { expect(toGrokUpdate(value)).toBeUndefined(); diff --git a/src/core/grok-events.ts b/src/core/grok-events.ts index 2c3517c..aae6ff8 100644 --- a/src/core/grok-events.ts +++ b/src/core/grok-events.ts @@ -236,9 +236,19 @@ export function toGrokUpdate(value: unknown): GrokSessionUpdate | undefined { return isObject(value.content) ? (value as GrokSessionUpdate) : undefined; case 'tool_call': case 'tool_call_update': - return value as GrokSessionUpdate; + // `content` は `toolOutputText` が **for-of で回す**ので、来ているなら + // 「オブジェクトの配列」であることまで見る(配列でない truthy を通すと + // for-of が TypeError を投げ、それがアダプタの generator を突き抜けて + // **ターンのストリームごと死ぬ** = `grok` が孤児として残る)。 + return value.content === undefined || + (Array.isArray(value.content) && value.content.every(isObject)) + ? (value as GrokSessionUpdate) + : undefined; case 'plan': - return Array.isArray(value.entries) ? (value as GrokSessionUpdate) : undefined; + // 各要素は `e.content` / `e.status` として無条件に読む。 + return Array.isArray(value.entries) && value.entries.every(isObject) + ? (value as GrokSessionUpdate) + : undefined; case 'retry_state': return typeof value.message === 'string' ? (value as GrokSessionUpdate) : undefined; default: diff --git a/src/core/i18n.spec.ts b/src/core/i18n.spec.ts index 64b6ed8..c25ed3d 100644 --- a/src/core/i18n.spec.ts +++ b/src/core/i18n.spec.ts @@ -1,3 +1,4 @@ +import stringWidth from 'string-width'; import { describe, expect, it } from 'vitest'; import { detectLocaleLang, @@ -8,6 +9,7 @@ import { normalizeLang, resolveLang, } from '@/core/i18n'; +import { BADGE_COLUMN_WIDTH } from '@/core/layout'; describe('detectLocaleLang', () => { it.each([ @@ -80,6 +82,19 @@ describe('message catalogs', () => { expect(jaKeys).toEqual(enKeys); }); + // 一覧のバッジ列は固定幅(`BADGE_COLUMN_WIDTH`)。溢れる文言を入れると + // `truncate-end` で切れて読めず、折り返させれば 1 セッション = 1 行を前提にした + // クリック判定がズレる(英語の 'Awaiting permission' が実際に 19 セルあった)。 + it.each(LANGS)('%s のバッジは一覧の列幅に収まる', (lang: Lang) => { + const b = messages[lang].badge; + const labels = Object.values(b).map((v) => + typeof v === 'function' ? (v as (a: number, t: number) => string)(12, 34) : v, + ); + for (const label of labels) { + expect(stringWidth(label), label).toBeLessThanOrEqual(BADGE_COLUMN_WIDTH); + } + }); + it.each(LANGS)('%s renders dynamic strings without leftover placeholders', (lang: Lang) => { const m: Messages = messages[lang]; expect(m.list.sessionCount(1)).toContain('1'); diff --git a/src/core/i18n.ts b/src/core/i18n.ts index 890fe01..d6131af 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -921,12 +921,15 @@ const en: Messages = { creating: 'Preparing', running: 'Running', step: (done, total) => `Step ${done}/${total}`, - awaitingPermission: 'Awaiting permission', + // **バッジは一覧の 12 セル固定幅の列に入る**(`session-list.tsx`)。溢れると + // `truncate-end` で切れて読めなくなるので、12 セル以内に収める + // (番人は `i18n.spec.ts` の「バッジは一覧の列幅に収まる」)。 + awaitingPermission: 'Permission', awaitingInput: 'Question', completed: 'Completed', interrupted: 'Interrupted', rateLimited: 'Rate limited', - needsLogin: 'Login required', + needsLogin: 'Login needed', failed: 'Failed', conflict: 'Conflict', archived: 'Archived', diff --git a/src/core/layout.ts b/src/core/layout.ts index 1334c77..5f29d15 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -34,6 +34,13 @@ export function showsBranchColumn(columns: number): boolean { return columns >= MIN_BRANCH_COLUMN_COLUMNS; } +/** + * 一覧のステータスバッジ列の幅(セル)。**固定幅**なので、カタログのバッジ文言は + * この幅に収める必要がある(溢れると `truncate-end` で切れて読めない。折り返させると + * 1 セッションが 2 行になり、行のクリック判定が全部ズレる)。番人は `i18n.spec.ts`。 + */ +export const BADGE_COLUMN_WIDTH = 12; + /** * 一覧のエージェント列の幅(セル)。表示名(`Claude` / `Codex` / `Grok`)は最長 6 セルで、 * 右に 1 セルの間隔を足した固定幅列。 diff --git a/src/core/persistence.spec.ts b/src/core/persistence.spec.ts index c25298f..fb62ca3 100644 --- a/src/core/persistence.spec.ts +++ b/src/core/persistence.spec.ts @@ -221,10 +221,33 @@ describe('toPersistedSession', () => { expect(toPersistedSession(s, { slug: 'x', base: 'm' }, NOW)).toBeUndefined(); }); - it('drops sessions without an sdkSessionId (nothing to resume)', () => { + it('drops sessions without any resume id (nothing to resume)', () => { const s = state({ status: 'completed' }); // initialState leaves sdkSessionId undefined expect(toPersistedSession(s, { slug: 'x', base: 'm' }, NOW)).toBeUndefined(); }); + + it('keeps a session switched to a provider it never used yet', () => { + // `agent_switched` は「切替先が初めての provider」なら `sdkSessionId` を + // undefined にする(そこにはまだ会話が無い)。現在値だけを見て捨てると、 + // 切替直後に何も送らずに終了したセッションが **state.json から丸ごと消える** — + // 戻るための id(`agentSessions.claude`)は残っているのに、worktree だけが + // 孤児になりタイトル・コスト・PR 参照も失われる。 + const s = state({ + status: 'completed', + agent: 'codex', + sdkSessionId: undefined, + agentSessions: { claude: 'sdk-claude' }, + }); + const p = toPersistedSession(s, { slug: 'x', base: 'm' }, NOW); + expect(p).toBeDefined(); + expect(p?.sdkSessionId).toBeUndefined(); + expect(p?.agent).toBe('codex'); + expect(p?.agentSessions).toEqual({ claude: 'sdk-claude' }); + // 読み込み側も同じ条件で受理する(往復できる)。 + const back = fromPersistedJson({ version: 1, sessions: [p] }); + expect(back.sessions).toHaveLength(1); + expect(back.sessions[0]?.agentSessions).toEqual({ claude: 'sdk-claude' }); + }); }); describe('restoredSessionState', () => { diff --git a/src/core/persistence.ts b/src/core/persistence.ts index 32efe38..bb85e55 100644 --- a/src/core/persistence.ts +++ b/src/core/persistence.ts @@ -28,8 +28,17 @@ export interface PersistedSession { worktreePath: string; /** Base branch this session was cut from / merges back into. */ base: string; - /** SDK session id for `resume`. Always present — only sessions that reached init (and are thus truly resumable) are persisted. */ - sdkSessionId: string; + /** + * 現在のエージェントの resume 用セッション id。 + * + * **未設定のことがある**: `/agent` で一度も使っていない provider へ切り替えると + * `sdkSessionId` は undefined になる(その provider にはまだ会話が無い)。それでも + * `agentSessions` に他 provider の id が残っていれば「切替前の会話へ戻れる」ので + * 保存する — ここを必須にしていたために、**切替直後に何も送らずに終了した + * セッションが state.json から丸ごと消えていた**(worktree だけが孤児として残り、 + * タイトル・コスト・PR 参照・戻るための resume id が全部失われる)。 + */ + sdkSessionId?: string; /** * このセッションを最後に駆動していたエージェント。この項目が無い(=切替対応より * 前に書かれた)スナップショットは `'claude'` として復元する。 @@ -100,10 +109,15 @@ export function restorableStatus( /** * Build a PersistedSession from live state + worktree meta, or undefined if this - * session isn't worth persisting. We require an `sdkSessionId`: without it there's + * session isn't worth persisting. We require **some** resume id: without any there's * nothing to `resume`, and restoring such a session would silently start a brand-new * conversation on the first follow-up (losing the original prompt). The worktree is * still kept on disk regardless, so no work is lost — only the codiva session entry. + * + * 「いずれかの provider の id」で判定するのが要点。`agent_switched` は切替先が初めての + * provider だと `sdkSessionId` を undefined にするので、現在値だけを見ると + * **切替直後のセッションが保存対象から外れて state.json から消える**(`agentSessions` + * には戻るための id が残っているのに)。 */ export function toPersistedSession( state: SessionState, @@ -111,7 +125,10 @@ export function toPersistedSession( now: number, ): PersistedSession | undefined { const status = restorableStatus(state.status); - if (!status || !state.worktreePath || !state.sdkSessionId) { + const resumable = + state.sdkSessionId !== undefined || + Object.values(state.agentSessions ?? {}).some((id) => typeof id === 'string' && id.length > 0); + if (!status || !state.worktreePath || !resumable) { return undefined; } return { @@ -126,9 +143,12 @@ export function toPersistedSession( agent: state.agent, // 現在のエージェントの id も控えに畳んでおく(`agent_switched` は切替の瞬間に // しか畳まないので、切替せずに終了したセッションの id がここから漏れる)。 - agentSessions: state.agent - ? { ...state.agentSessions, [state.agent]: state.sdkSessionId } - : state.agentSessions, + // 現在の provider がまだ会話を持っていない(切替直後)ときは畳まない — + // `undefined` を書き込むと控えの他 provider の id と紛れる。 + agentSessions: + state.agent && state.sdkSessionId + ? { ...state.agentSessions, [state.agent]: state.sdkSessionId } + : state.agentSessions, status, startedAt: state.startedAt, finishedAt: state.finishedAt, @@ -324,13 +344,15 @@ function toPersistedSessionJson(v: unknown): PersistedSession | undefined { ? o.status : undefined; const startedAt = num(o.startedAt); - // These are the minimum needed to rebuild + resume a session. sdkSessionId is - // required — a persisted session without it can't be resumed (see toPersistedSession). + const agentSessions = toAgentSessions(o.agentSessions); + // These are the minimum needed to rebuild + resume a session. **どこかに** + // resume 用の id が要る(現在の provider のものが無くても、`agentSessions` に + // 切替前のものが残っていれば戻れる。詳細は `toPersistedSession`)。 if ( id === undefined || worktreePath === undefined || status === undefined || - sdkSessionId === undefined + (sdkSessionId === undefined && Object.keys(agentSessions ?? {}).length === 0) ) { return undefined; } @@ -347,7 +369,7 @@ function toPersistedSessionJson(v: unknown): PersistedSession | undefined { base: base ?? 'HEAD', sdkSessionId, agent: toAgentId(o.agent), - agentSessions: toAgentSessions(o.agentSessions), + agentSessions, status, startedAt: startedAt ?? 0, finishedAt: num(o.finishedAt), diff --git a/src/core/run-mode.spec.ts b/src/core/run-mode.spec.ts index c7d3a9c..b995f42 100644 --- a/src/core/run-mode.spec.ts +++ b/src/core/run-mode.spec.ts @@ -7,6 +7,17 @@ describe('createModePolicy', () => { expect(createModePolicy(() => 'confirm')('AskUserQuestion', {})).toBe('ask'); }); + // 質問の見分けは**種別**(アダプタが正規化した `kind`)で行う。ツール名は provider + // ごとに違い、Claude の `AskUserQuestion` だけを見ていたので Grok の + // `_x.ai/ask_user_question` は既定(auto)モードで自動 allow され、**空の回答で + // 「承諾した」と返して質問が一度もダイアログに出ていなかった**。 + it('escalates any question kind, whatever the provider calls the tool', () => { + expect(createModePolicy(() => 'auto')('ask_user_question', {}, 'question')).toBe('ask'); + expect(createModePolicy(() => 'auto')('whatever', {}, 'question')).toBe('ask'); + // ツール実行の許可はモードどおり。 + expect(createModePolicy(() => 'auto')('ask_user_question', {}, 'tool')).toBe('allow'); + }); + it('auto-allows other tools in auto mode, asks in confirm mode', () => { expect(createModePolicy(() => 'auto')('Bash', {})).toBe('allow'); expect(createModePolicy(() => 'confirm')('Bash', {})).toBe('ask'); diff --git a/src/core/run-mode.ts b/src/core/run-mode.ts index 32b0fb8..5096a2c 100644 --- a/src/core/run-mode.ts +++ b/src/core/run-mode.ts @@ -1,4 +1,4 @@ -import type { PermissionPolicy } from './session'; +import { isQuestion, type PermissionPolicy } from './session'; /** * Global tool-approval mode, toggled with shift+tab (à la Claude Code). @@ -11,11 +11,15 @@ export type RunMode = 'auto' | 'confirm'; /** * Build the default permission policy from a live `getMode` accessor. Reading the * mode at call time means a shift+tab toggle takes effect on already-running - * sessions. AskUserQuestion always escalates — it *is* the ask-the-user channel. + * sessions. 質問は常にユーザーへ上げる — それ*が*「ユーザーに聞く」経路なので、 + * `auto` で自動 allow すると**空の回答で承諾を返して質問が黙って消える**。 + * 判定は `kind`(アダプタが正規化した種別)で行う: ツール名は provider ごとに違い、 + * Claude の `AskUserQuestion` だけを見ていたので Grok の `_x.ai/ask_user_question` が + * 既定モードでは一度もダイアログに出ていなかった。 */ export function createModePolicy(getMode: () => RunMode): PermissionPolicy { - return (toolName) => { - if (toolName === 'AskUserQuestion') { + return (toolName, _input, kind) => { + if (isQuestion(toolName, kind)) { return 'ask'; } return getMode() === 'auto' ? 'allow' : 'ask'; diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index edad132..208fece 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -379,14 +379,22 @@ export class SessionManager { const id = String(this.seq); const title = makeTitle(prompt); const startedAt = this.now(); - const placeholder = initialState({ - id, - title, - prompt, - branch: `codiva/${makeSlug(prompt)}`, - worktreePath: '', - startedAt, - }); + // **どのエージェントで走るのかを最初から載せる。** worktree を作っている間の + // 行は `agent` 無しだと `sessionAgentId` の既定(`'claude'`)として表示されるので、 + // 既定が Codex / Grok のときに「準備中 / Claude」と嘘の名前が出るうえ、他の行と + // 値が食い違って `usesMultipleAgents` が立ち、**エージェント列が一瞬現れて + // 一覧全体が組み直される**(準備が済むと消える)。 + const placeholder = { + ...initialState({ + id, + title, + prompt, + branch: `codiva/${makeSlug(prompt)}`, + worktreePath: '', + startedAt, + }), + agent: this.getDefaultAgentId(), + }; this.store.append(id, placeholder); this.deps.onPersist?.(); void this.provision(id, prompt, title, startedAt); diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index 8e1e22f..86237a5 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -1,6 +1,6 @@ import type { Options, PermissionResult, Query, SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import { describe, expect, it, vi } from 'vitest'; -import type { AgentAdapter, AgentRunRequest } from '@/core/agent-ports'; +import type { AgentAdapter, AgentRunRequest, PermissionDecision } from '@/core/agent-ports'; import { NO_CAPABILITIES } from '@/core/agent-ports'; import { AsyncQueue } from '@/core/async-queue'; import type { QueryFn } from '@/core/claude-adapter'; @@ -765,6 +765,43 @@ describe('Session', () => { expect(result?.behavior).toBe('allow'); }); + it('escalates a provider question whatever the tool is called (default policy)', async () => { + // 質問の見分けを**ツール名**でやっていたので、Grok の `_x.ai/ask_user_question` は + // 既定(auto)モードで自動 allow されていた。`answers` の無い allow は provider には + // 「ユーザーは答えなかった」と同じなので、**質問が一度もダイアログに出ないまま + // 「承諾した」と返っていた**。判定は `kind`(アダプタが正規化した種別)で行う。 + let decision: PermissionDecision | undefined; + const adapter: AgentAdapter = { + id: 'grok', + displayName: 'grok', + loginCommand: 'grok', + capabilities: NO_CAPABILITIES, + open: (request: AgentRunRequest) => ({ + async *[Symbol.asyncIterator]() { + for await (const _text of request.prompt) { + decision = await request.requestPermission({ + toolName: 'ask_user_question', + input: {}, + kind: 'question', + questions: [{ question: 'どっち?', header: '', multiSelect: false, options: [] }], + }); + yield { kind: 'turn_completed', text: '' } as const; + } + }, + }), + }; + const session = new Session({ agent: adapter, input: INPUT, now: () => 0 }); + session.start(); + await tick(); + expect(session.getState().status).toBe('awaiting_input'); + expect(decision).toBeUndefined(); + + session.answerPending({ 'どっち?': 'A' }); + await tick(); + expect(decision?.behavior).toBe('allow'); + expect(decision?.input).toEqual({ answers: { 'どっち?': 'A' } }); + }); + it('does not emit aborted when already completed', async () => { const fake = makeFakeQuery(); const onChange = vi.fn(); @@ -1131,6 +1168,141 @@ describe('Session.setAgent', () => { expect(session.getState().status).toBe('running'); }); + it('ignores events that the folded run emits after the switch', async () => { + // 切替前の provider は畳んだ**あと**にもイベントを出しうる(Claude の + // `system/init` は起動に 1〜3 秒かかるので、その間に `/agent` を押せる)。 + // 帰属(`attribution`)はもう切替先なので、畳むと**前任者の resume id が + // 切替先の id として保存され**、次のターンで `codex exec resume ` を + // 投げて必ず失敗する(`agentSessions` にも state.json にも焼き付くので往復しても + // 直らない)。古いターンの `turn_completed` で completed → auto-PR も走ってしまう。 + const late = new AsyncQueue(); + const a: AgentAdapter = { + id: 'claude', + displayName: 'claude', + loginCommand: 'claude', + capabilities: NO_CAPABILITIES, + open: (request) => ({ + async *[Symbol.asyncIterator]() { + for await (const _text of request.prompt) { + yield { kind: 'assistant_message' } as const; + for await (const id of late) { + // 切替後に届く init(= resume id の通知)と完了。 + yield { kind: 'session_started', sessionId: id } as const; + yield { kind: 'turn_completed', text: 'done' } as const; + } + } + }, + }), + }; + const b = recorder('codex'); + const session = new Session({ agent: a, input: INPUT, now: () => 0 }); + session.start(); + await tick(); + + session.setAgent(b.adapter); + late.push('claude-thread'); + late.close(); + await tick(); + await tick(); + + const state = session.getState(); + expect(state.sdkSessionId).toBeUndefined(); + expect(state.agentSessions?.codex).toBeUndefined(); + // 古いターンの完了で completed(→ auto-PR / 完了通知)にならない。 + expect(state.status).toBe('interrupted'); + }); + + it('keeps the handover when the first run dies before delivering it', async () => { + // 引き継ぎは 1 回きりなので、`open()` の時点で落とすと「切替先の CLI が未ログイン」 + // のような**渡る前に死ぬ**経路で黙って消える(ログインし直して送り直しても + // 二度と渡らない)。落とすのはアダプタが渡したと言ったときだけ。 + const a = recorder('claude'); + const handoffs: (string | undefined)[] = []; + const seen: string[] = []; + let failing = true; + const b: AgentAdapter = { + id: 'codex', + displayName: 'codex', + loginCommand: 'codex', + capabilities: NO_CAPABILITIES, + open: (request) => { + handoffs.push(request.options.handoff); + return { + async *[Symbol.asyncIterator]() { + if (failing) { + // プロンプトを読む前に落ちる(未ログイン・CLI 未導入と同じ形)。 + throw new Error('Not logged in'); + } + for await (const text of request.prompt) { + seen.push(text); + // 実アダプタと同じ契約: 実際に渡したときだけ報告する。 + request.onHandoffDelivered?.(); + yield { kind: 'turn_completed', text: '' } as const; + } + }, + }; + }, + }; + const session = new Session({ agent: a.adapter, input: INPUT, now: () => 0 }); + session.start(); + await tick(); + + session.setAgent(b); + session.send('now you'); + await tick(); + expect(handoffs[0]).toContain('taking over this session from claude'); + expect(session.getState().status).toBe('failed'); + + // ログインし直して送り直す → 引き継ぎは**まだ生きている**(積み残しの指示も + // キューに残っているので、そのまま新しい run が拾う)。 + failing = false; + session.send('retry'); + await tick(); + expect(seen).toEqual(['now you', 'retry']); + expect(handoffs[1]).toContain('taking over this session from claude'); + // run は張り替わっていない(= 引き継ぎを渡し直した回数はこの 2 回だけ)。 + expect(handoffs).toHaveLength(2); + }); + + it('lets Ctrl+C cancel the instruction queued right after a switch', async () => { + // 切替直後は「畳んだループが終わったら新しいエージェントを起こす」予約状態で、 + // まだ run が無い。予約を消さないと `Ctrl+C` で `interrupted` にした直後に + // 新しいエージェントが起動してしまい、**切替後の最初の指示だけ中断できない**。 + const held = new AsyncQueue(); + const a: AgentAdapter = { + id: 'claude', + displayName: 'claude', + loginCommand: 'claude', + capabilities: NO_CAPABILITIES, + open: (request) => ({ + async *[Symbol.asyncIterator]() { + for await (const _text of request.prompt) { + yield { kind: 'assistant_message' } as const; + for await (const _ of held) { + // 中断されるまで返らない + } + } + }, + interrupt: async () => { + held.close(); + }, + }), + }; + const b = recorder('codex'); + const session = new Session({ agent: a, input: INPUT, now: () => 0 }); + session.start(); + await tick(); + + session.setAgent(b.adapter); + session.send('now you'); + await session.interrupt(); + await tick(); + await tick(); + + expect(b.seen).toEqual([]); + expect(session.getState().status).toBe('interrupted'); + }); + it('is a no-op when the agent is unchanged (keeps the running stream)', async () => { const a = recorder('claude'); const session = new Session({ agent: a.adapter, input: INPUT, now: () => 0 }); diff --git a/src/core/session.ts b/src/core/session.ts index a502b1d..2b811a4 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -28,20 +28,37 @@ import type { } from './types'; import type { IgnoredFilesMode } from './worktree'; -/** Decide whether a tool runs automatically or is escalated to the user. */ +/** + * Decide whether a tool runs automatically or is escalated to the user. + * + * `kind` は「ツール実行の許可」か「ユーザーへの質問」か(アダプタが正規化した値)。 + * **ツール名で質問を見分けてはいけない** — `AskUserQuestion` は Claude の名前で、 + * Grok は `_x.ai/ask_user_question` を、他の provider はまた別の名前を使う。 + */ export type PermissionPolicy = ( toolName: string, input: Record, + kind?: PermissionRequest['kind'], ) => 'allow' | 'ask'; /** * Default policy: run everything automatically so sessions are autonomous. - * AskUserQuestion is always escalated — it *is* the "ask the user" channel. + * 質問(`kind: 'question'`)は常にユーザーへ上げる — それ*が*「ユーザーに聞く」経路で、 + * 自動 allow すると**空の回答で「承諾した」と返してしまう**(provider には + * 「ユーザーは答えなかった」と同じに見え、質問が黙って無視される)。 * (Phase 1 showed even Write reaches canUseTool under acceptEdits, so relying on * permissionMode alone would stall autonomy; we auto-allow here instead.) */ -const defaultPolicy: PermissionPolicy = (toolName) => - toolName === 'AskUserQuestion' ? 'ask' : 'allow'; +const defaultPolicy: PermissionPolicy = (toolName, _input, kind) => + isQuestion(toolName, kind) ? 'ask' : 'allow'; + +/** + * 質問かどうか。`kind` を第一の根拠にし、`kind` を持たない古い呼び出し元のために + * Claude のツール名も見る(provider 固有の名前をここに増やさない)。 + */ +export function isQuestion(toolName: string, kind?: PermissionRequest['kind']): boolean { + return kind === 'question' || toolName === 'AskUserQuestion'; +} /** Per-session knobs forwarded to the SDK query (sourced from the config file). */ export interface SessionOptions { @@ -135,6 +152,22 @@ export class Session { */ private handoff?: string; private run?: AgentRun; + /** + * 走っている run の**世代**。`setAgent()` で 1 つ上がる。 + * + * なぜ要るか: `setAgent()` はキューを閉じて `this.run` の参照を捨てるが、consume の + * `for await` は**そのイテレータを掴んだまま**なので、古い provider が遅れて出す + * イベントがまだ届く。`applyAgentEvent` に渡す帰属(`this.attribution`)は既に切替先へ + * 変わっているため、そのまま畳むと + * - 遅れて届いた `session_started`(Claude の `system/init` は起動に 1〜3 秒かかる)で + * **前任者の resume id が切替先の id として保存される** → 次のターンで + * `codex exec resume ` を投げて必ず失敗し、`agentSessions` にも + * `state.json` にも焼き付く(往復切替しても直らない)。 + * - 古いターンの `turn_completed` で切替済みのセッションが `completed` になり、 + * auto-PR と完了通知まで走る。 + * 世代が変わった run のイベントは**畳まずに読み捨てて**ループを閉じる。 + */ + private epoch = 0; /** * 未応答の許可要求の**待ち行列**(先頭 = いま UI に出ているもの)。 * @@ -276,6 +309,9 @@ export class Session { } this.restartAfterSwitch = this.consuming; this.run = undefined; + // 世代を上げる。畳んだ run が遅れて出すイベントは、これで「古い世代のもの」と + // 分かるので畳まずに読み捨てる(詳細は {@link epoch})。 + this.epoch += 1; // 畳んだのが**進行中のターン**なら、状態も「止まった」ことにする。`agent_switched` // は status を動かさないので、これが無いと積み残しが無いときに `running`(や応答 // できない許可待ち)のまま張り付く — ストリームはもう無いので誰も先へ進めない。 @@ -404,8 +440,18 @@ export class Session { return; } this.dispatch({ kind: 'interrupted', error: USER_INTERRUPT_DETAIL, at: this.now() }); + // **まだ始まっていないターンは投げない**(Grok アダプタが立ち上げ中の中断で + // 指示ごと捨てるのと同じ理由)。エージェント切替の直後は、畳んだループが終わるまで + // 新しい run が立たない予約状態(`restartAfterSwitch`)で、ここに `this.run` は無い。 + // 予約を消さないと、`Ctrl+C` で `interrupted` にした直後に古いループの `finally` が + // 新しいエージェントを起こしてしまい、**切替後の最初の指示だけ中断できない**。 + if (!this.run) { + this.restartAfterSwitch = false; + this.inputQueue.drain(); + return; + } try { - await this.run?.interrupt?.(); + await this.run.interrupt?.(); } catch { // best-effort: サブプロセスがもう居ない transport への write は reject する // (setModel と同じ)。中断できなかった場合もストリームは生きているので、 @@ -521,7 +567,7 @@ export class Session { * ので、ここは codiva 自身のポリシー(`core/run-mode.ts`)だけを見る。 */ private requestPermission = (req: Omit): Promise => { - if (this.policy(req.toolName, req.input) === 'allow') { + if (this.policy(req.toolName, req.input, req.kind) === 'allow') { return Promise.resolve({ behavior: 'allow', input: req.input }); } this.reqSeq += 1; @@ -539,6 +585,9 @@ export class Session { }; private async consume(): Promise { + // この run の世代。`setAgent()` が上げたら、ここから先のイベントは + // 「切替前の provider の残響」なので畳まない(詳細は {@link epoch})。 + const epoch = this.epoch; try { const opts = this.deps.options; // A per-session /model override wins over the configured default. @@ -565,13 +614,17 @@ export class Session { // 画面のログには元の入力だけを積むので、この内部添付が二重表示されることはない。 // CLI のトランスクリプトには**ユーザー発言として**残るが、復元は // `stripHandoff` を通るので詳細ビューにも `lastUserInstruction` にも漏れない。 + // + // **ここで捨てない**。1 回きりの引き継ぎなので、`open()` の時点で落とすと + // provider へ渡る前に run が死んだ経路(CLI 未導入・未ログイン・起動直後の + // `Ctrl+C`)で黙って消える(ログインし直して送り直しても、もう引き継がれない)。 + // 落とすのは**アダプタが「確かに渡した」と言ったとき**だけ(`onHandoffDelivered`)。 const handoff = this.handoff; - this.handoff = undefined; const systemPrompt = composeSystemPrompt({ ignoredFiles: opts?.ignoredFiles, repoPrompt: opts?.appendSystemPrompt, }); - this.run = this.adapter.open({ + const run = this.adapter.open({ cwd: this.state.worktreePath, prompt: this.inputQueue, resume, @@ -583,10 +636,24 @@ export class Session { systemPrompt, handoff, }, + // 引き継ぎが provider へ渡ったという報告。**世代が変わっていたら無視する** — + // 切替後の `this.handoff` は次のエージェント向けの別物なので、畳んだ run の + // 遅れた報告でそれを消してはいけない。 + onHandoffDelivered: () => { + if (epoch === this.epoch) { + this.handoff = undefined; + } + }, requestPermission: this.requestPermission, abortController: this.abortController, }); - for await (const event of this.run) { + this.run = run; + for await (const event of run) { + // 切替で畳んだ run の残響。**畳まずに抜ける**(`break` で run.return() が走り、 + // 古いストリームも閉じる)。 + if (epoch !== this.epoch) { + break; + } // Account-wide subscription usage is surfaced out-of-band (it isn't // per-session state) so the manager can aggregate it for the banner. if (event.kind === 'usage') { @@ -596,7 +663,10 @@ export class Session { this.commit(applyAgentEvent(this.state, event, this.now(), this.attribution)); } } catch (err) { - if (!this.abortController.signal.aborted) { + // 世代が変わっていれば、投げたのは切替前の provider(もう関係ない)。 + // その失敗を今のエージェントの失敗として記録すると、切替直後のセッションが + // 身に覚えのないエラーで `failed` になる。 + if (!this.abortController.signal.aborted && epoch === this.epoch) { const error = errorMessage(err); // 文言から分類するのはアダプタの仕事(provider ごとに言い回しが違う)。 // 認証切れが最優先なのは、CLI の認証エラーがタイムアウトに*言及する*ことが @@ -638,9 +708,14 @@ export class Session { // ここで新しいエージェントを起こして拾い直す。 const switched = this.restartAfterSwitch; this.restartAfterSwitch = false; + const stale = epoch !== this.epoch; if (switched && this.inputQueue.pending > 0) { this.ensureConsuming(); - } else if (!this.abortController.signal.aborted && isInterruptible(this.state.status)) { + } else if ( + !stale && + !this.abortController.signal.aborted && + isInterruptible(this.state.status) + ) { // **最後の砦**: ストリームが終端イベント(`turn_completed` / `turn_stopped`)を // 出さずに終わった。streaming input mode ではプロンプト源を閉じるまで終わらない // のが正常なので、ここに来るのは想定外の終了(CLI が黙って落ちた等)。状態機械 diff --git a/src/ui/agent-select.tsx b/src/ui/agent-select.tsx index 15604fc..bf67ed1 100644 --- a/src/ui/agent-select.tsx +++ b/src/ui/agent-select.tsx @@ -131,7 +131,11 @@ export const AgentSelect: FC<{ {mode === 'session' ? m.agent.warning : m.agent.defaultHint} - {onLogin ? `${m.agent.help} ・ ${m.agent.loginKey}` : m.agent.help} + {/* 区切りは `theme.ts` の記号を使う(全角の `・` を直書きすると英語 UI に + 混ざる。ヒント行の区切りは他の画面と同じ `·`)。 */} + + {onLogin ? `${m.agent.help} ${glyph.dot} ${m.agent.loginKey}` : m.agent.help} + ); diff --git a/src/ui/login-dialog.tsx b/src/ui/login-dialog.tsx index 463ba37..45ee2ec 100644 --- a/src/ui/login-dialog.tsx +++ b/src/ui/login-dialog.tsx @@ -62,6 +62,12 @@ export const LoginDialog: FC<{ }); return () => { live = false; + // **アンマウントでもプロセスを畳む。** Esc(`cancel()`)だけに任せていると、 + // ダイアログを開いたまま codiva を終了したとき ` login --device-auth` が + // 孤児として残り、自分のタイムアウトまでポーリングし続ける(終了処理も + // クラッシュハンドラもこれを回収しない)。成功後の閉じでも呼ぶが、 + // 終わったプロセスへの kill は no-op。 + proc.cancel(); }; }, []); diff --git a/src/ui/progress-badge.spec.tsx b/src/ui/progress-badge.spec.tsx index 20b5249..7a620d4 100644 --- a/src/ui/progress-badge.spec.tsx +++ b/src/ui/progress-badge.spec.tsx @@ -31,7 +31,10 @@ describe('badgeFor', () => { expect(badgeFor({ ...base, status: 'completed' }, m).label).toBe('Completed'); expect(badgeFor({ ...base, status: 'interrupted' }, m).label).toBe('Interrupted'); expect(badgeFor({ ...base, status: 'rate_limited' }, m).label).toBe('Rate limited'); - expect(badgeFor({ ...base, status: 'needs_login' }, m).label).toBe('Login required'); + // 一覧のバッジ列は 12 セル固定なので、英語も 12 セル以内の語を選ぶ + // (番人は `i18n.spec.ts`)。 + expect(badgeFor({ ...base, status: 'needs_login' }, m).label).toBe('Login needed'); + expect(badgeFor({ ...base, status: 'awaiting_permission' }, m).label).toBe('Permission'); }); it('shows Step n/m when a running session has progress', () => { const s: SessionState = { ...base, status: 'running', progress: { done: 4, total: 7 } }; diff --git a/src/ui/progress-badge.tsx b/src/ui/progress-badge.tsx index e7e405d..6d0c190 100644 --- a/src/ui/progress-badge.tsx +++ b/src/ui/progress-badge.tsx @@ -41,7 +41,11 @@ export const ProgressBadge: FC<{ state: SessionState }> = ({ state }) => { const m = useMessages(); const { label, color } = badgeFor(state, m); return ( - + // **`truncate-end` は必須**(`PrCell` と同じ理由)。一覧の列は固定幅(12 セル)で、 + // 折り返すと 1 セッションが 2 行になり「1 セッション = 1 行」を前提にした + // `rowLineAtPoint` 以降のクリックが全部ズレる(英語の `Awaiting permission` は + // 19 セルあるので実際に溢れる。日本語は 12 セルに収まっていたので気付けなかった)。 + {label} ); diff --git a/src/ui/session-detail.tsx b/src/ui/session-detail.tsx index 0f40fe4..a811f22 100644 --- a/src/ui/session-detail.tsx +++ b/src/ui/session-detail.tsx @@ -783,15 +783,18 @@ export const SessionDetail: FC<{ const footerHint = modelSelect ? m.model.help - : agentSelect - ? m.agent.help - : pending - ? dialogActive - ? m.detail.helpPending - : m.detail.helpLog - : panel === 'actions' - ? m.detail.helpActions - : m.detail.helpInput; + : // ログインダイアログは Esc しか効かないので、ヒントもそれに合わせる。 + loginAgent !== null + ? m.login.help + : agentSelect + ? m.agent.help + : pending + ? dialogActive + ? m.detail.helpPending + : m.detail.helpLog + : panel === 'actions' + ? m.detail.helpActions + : m.detail.helpInput; // コマンドとして解決される入力か(`/` 付き、または詳細で使える名前と完全一致)。 const commandPreview = commands.preview(buffer.value); @@ -932,10 +935,16 @@ export const SessionDetail: FC<{ agents={agentChoices} onSelect={(next) => { setAgentSelect(false); + // 「今と同じ」かはマネージャに聞く(`session.agent` はスロットルされた + // 購読値で、切替対応より前のセッションでは undefined にもなる)。 + const unchanged = manager.getSessionAgent(session.id)?.id === next; if (manager.setSessionAgent(session.id, next)) { const name = manager.getSessionAgent(session.id)?.displayName ?? ''; recovery.setNotice(m.agent.switched(name)); - } else { + } else if (!unchanged) { + // **同じエージェントを選び直したのはエラーではない**(カーソルは + // 今のエージェントから始まるので `/agent` → Enter が最も打ちやすい)。 + // 一覧側の `setDefaultAgent` も同じ false を「何もしない」と扱っている。 setActionError(m.agent.unavailable); } applyAnchor('bottom'); diff --git a/src/ui/session-list.tsx b/src/ui/session-list.tsx index f296b1c..9b3c7fe 100644 --- a/src/ui/session-list.tsx +++ b/src/ui/session-list.tsx @@ -8,6 +8,7 @@ import { agentSupports, atFirstComposerRow, atLastComposerRow, + BADGE_COLUMN_WIDTH, type BannerLine, bannerCaretAt, bannerLines, @@ -413,7 +414,9 @@ export const SessionList: FC<{ if (id && manager.canLogin(id)) { setLoginAgent(id); } else { - setActionError(m.login.unsupported('')); + // 文言はエージェント名を差し込む形(空だと先頭に空白だけが残る)。 + const name = manager.listAgents().find((a) => a.id === id)?.displayName ?? ''; + setActionError(m.login.unsupported(name)); } }, // `/prompt` はリポジトリ追加指示(.codiva/prompt.md)のエディタを開く。 @@ -1005,26 +1008,30 @@ export const SessionList: FC<{ const footerHint = modelSelect ? m.model.help - : agentSelect - ? m.agent.help - : promptEdit - ? m.prompt.help - : configView - ? m.config.help - : // ダイアログがキーを持っている間だけダイアログ用のヒント。list ゾーンでは - // ダイアログが見えていても操作対象は一覧なので、通常の一覧ヒントを出す。 - dialogActive - ? m.list.helpPending - : zone === 'list' - ? // 認証切れの行はまず「別ターミナルで claude にログイン」を促す(r だけ - // 見せても再開できないため)。それ以外の再開可能な行は再開キー(r)を - // 含むヒントに切り替える。 - target?.status === 'needs_login' - ? m.auth.listHint(targetAgentLabel) - : target && isResumable(target.status) - ? m.resume.listHint - : m.list.helpList - : m.list.helpComposer; + : // ログインダイアログもキーを飲む(Esc だけ効く)。ここを抜かすと、下の + // 一覧ヒント(Enter: 送信 / Tab: 一覧…)が出たまま何も効かない。 + loginAgent !== null + ? m.login.help + : agentSelect + ? m.agent.help + : promptEdit + ? m.prompt.help + : configView + ? m.config.help + : // ダイアログがキーを持っている間だけダイアログ用のヒント。list ゾーンでは + // ダイアログが見えていても操作対象は一覧なので、通常の一覧ヒントを出す。 + dialogActive + ? m.list.helpPending + : zone === 'list' + ? // 認証切れの行はまず「別ターミナルで claude にログイン」を促す(r だけ + // 見せても再開できないため)。それ以外の再開可能な行は再開キー(r)を + // 含むヒントに切り替える。 + target?.status === 'needs_login' + ? m.auth.listHint(targetAgentLabel) + : target && isResumable(target.status) + ? m.resume.listHint + : m.list.helpList + : m.list.helpComposer; // `/help` の全一覧はコマンドが増えるほど縦に伸びる(14 個で 24 行の端末に入らず、 // Yoga がパレットの枠を潰して行が消えた)。ヘッダは装飾なので、開いている間だけ @@ -1074,7 +1081,7 @@ export const SessionList: FC<{ {s.title} - + {/* どのエージェントで走っているか(混在時のみ。単一なら列ごと出さない)。 diff --git a/src/utils/grok.ts b/src/utils/grok.ts index 696147b..7ad8ed5 100644 --- a/src/utils/grok.ts +++ b/src/utils/grok.ts @@ -141,9 +141,16 @@ export function spawnGrok(request: GrokSpawnRequest, command = 'grok'): GrokProc }; } -/** `grok` の設定・資格情報の置き場(`GROK_HOME` で移せる)。 */ +/** + * `grok` の設定・資格情報の置き場(`GROK_HOME` で移せる)。 + * + * **空文字は「未設定」として扱う**(`codexHome()` と同じ)。`?? ` だけだと + * `GROK_HOME=""` で `auth.json` を**相対パス**で読みに行き、リポジトリに同名の + * ファイルがあるだけで「ログイン済み」と誤判定する。 + */ function grokHome(): string { - return process.env.GROK_HOME ?? join(homedir(), '.grok'); + const home = process.env.GROK_HOME?.trim(); + return home && home.length > 0 ? home : join(homedir(), '.grok'); } /**