Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/rules/ink-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` の競合を避ける)。
Expand Down
6 changes: 6 additions & 0 deletions .claude/rules/sdk-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 の解釈

Expand Down
24 changes: 23 additions & 1 deletion .claude/rules/session-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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 <claude の uuid>` を投げ続ける)、古いターンの `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 で走らせない**。

Expand Down
40 changes: 35 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <claude の uuid>`
を投げ続け、`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 が別のトランスクリプトを持つ) |
Expand Down Expand Up @@ -279,18 +298,29 @@ 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` で切り、会話は
**`MAX_HANDOFF_TRANSCRIPT_BYTES` = UTF-8 バイトの予算**で新しい方から詰める(切ったことは
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 が失敗した・圧縮で落ちた場合に「足りない」方が害が大きいので全部渡し、
重複が新しい指示ではないことは引き継ぎ文の中で断る。
Expand Down
47 changes: 47 additions & 0 deletions docs/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,53 @@ zsh: abort codiva

---

## Phase D の後追い修正(マージ後レビューで見つけた不具合)

`/agent`(エージェント切替)と会話引き継ぎを入れたあと、切替まわり・provider アダプタ・
一覧の描画を通しでレビューして見つかった実害のあるものを直した。

- [x] **切替で畳んだ run のイベントを畳み込まない**(`Session` の世代カウンタ `epoch`)。
遅れて届く `session_started` で前任者の resume id が切替先の id として保存され、
以後 `codex exec resume <claude の uuid>` を投げ続けていた(`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` が通る
Expand Down
5 changes: 4 additions & 1 deletion src/bootstrap/restore-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading