実装時に参照する技術リファレンス。SDK に関する記述は公式ドキュメント(https://code.claude.com/docs/en/agent-sdk/ 配下)を v0.3.214 時点で確認したもの。着手前に Phase 1 のスパイクで実挙動を必ず検証すること(「要スパイク検証」印の項目は特に)。
| パッケージ | 用途 | 備考 |
|---|---|---|
@anthropic-ai/claude-agent-sdk |
Claude Code セッションの起動・制御 | ESM専用。CLIバイナリ同梱(別途 claude インストール不要)。Node 18+ |
ink (v7) + react (v19) |
TUI | |
| 入力欄 | useInput で自作(ui/input.ts + presentational PromptInput) |
ink-text-input は不採用(Ink 7 互換リスク回避) |
vitest |
テスト | カバレッジは @vitest/coverage-v8 |
@biomejs/biome |
lint + format | ESLint/Prettier は使わない |
tsx |
開発時実行 | npm run dev / spike 用 |
tsup |
ビルド(esbuild) | dist/index.js に単一ファイルバンドル。banner で shebang 付与 |
tsconfig:module: ESNext/moduleResolution: bundler/verbatimModuleSyntax: true/noEmit: true。- import は拡張子なし(
@/core、./app)。nodenext+.js拡張子は使わない(バンドラ前提の解決に統一)。 - 型チェック =
tsc --noEmit(npm run typecheck)、ビルド =tsup(npm run build)。tsup はdependenciesを external 扱いにするので SDK 同梱 CLI バイナリは実行時に解決される。
import { query } from '@anthropic-ai/claude-agent-sdk';
const q = query({
prompt, // string | AsyncIterable<SDKUserMessage> — codiva は常に後者(streaming input mode)
options,
});
// q は AsyncGenerator<SDKMessage> かつ以下のメソッドを持つ:
// interrupt(): Promise<void> ← streaming mode のみ
// setPermissionMode(mode) ← streaming mode のみ
// setModel(model) ← streaming mode のみ
// supportedModels(): Promise<ModelInfo[]> ← streaming mode のみ。モデル一覧
// initializationResult() ← models/commands/agents/account をまとめて取得
// close(): Promise<void>- streaming input mode(prompt に AsyncIterable を渡す)でのみ、追加メッセージ投入・interrupt・setPermissionMode が使える。エラー result 後もストリームが生き続ける。codiva は必ずこのモードを使う。
- string prompt の単発モードはエラー時に throw して終わるため使わない。
/model の選択肢は Query.supportedModels() を唯一の出所にする。モデル ID・表示名・
説明文をアプリ側に直書きすると、リリースごとに陳腐化するだけでなく、アカウント種別・
サブスクリプション・エンタープライズの availableModels ポリシー・CLI バージョンで
実際に選べるモデルが変わるため、ユーザーが使えないモデルを出してしまう。
// prompt は「何も yield しない AsyncIterable」で良い(init ハンドシェイクだけで完結する)。
// モデル推論は走らないのでトークン消費もコストも無い。実測 0.3〜2 秒。
const models = await q.supportedModels();ModelInfo の主要フィールド:
| フィールド | 内容 |
|---|---|
value |
query({ options: { model } }) へ渡す文字列。'default' は「CLI 既定」の番兵 |
resolvedModel |
value が解決される正規 ID('sonnet' → 'claude-sonnet-5')。保存済みの明示 ID をエイリアス行に突き合わせるのに使う |
displayName / description |
表示用(英語のみ) |
supportsEffort / supportedEffortLevels / supportsAdaptiveThinking / supportsFastMode / supportsAutoMode |
能力フラグ |
実測出力(SDK v0.3.214 / Claude Team / 2026-07-28)— value がエイリアスと
[1m] 付きフル ID の混在になる点、既定行にも resolvedModel が付く点に注意:
落とし穴: 既定行の resolvedModel で突き合わせてはいけない。'default' は
「未設定」専用として扱う(そうしないと明示的に Opus を選んだ設定が「デフォルト」行に
チェックされる)。core/models.ts の isCurrentModel がこれを担保している。
配線: 取得は utils/model-catalog.ts(fetchModelCatalog、I/O・throw しない)、
変換と突き合わせは core/models.ts(純粋)、起動時の発火は src/index.tsx(合成ルート)で
await せずに投げ、App の useModelCatalog が state に解決する。取得失敗時は
FALLBACK_MODEL_OPTIONS(バージョンを含まないファミリーエイリアスのみ)へ落ちる。
const options = {
cwd: worktreePath, // ツールの作業ディレクトリ = セッションの worktree
permissionMode: 'acceptEdits', // 既定。設定 permissionMode で上書き可
canUseTool, // 下記参照
abortController, // セッション強制終了用
maxTurns: 200, // 暴走防止の上限(要調整)
settingSources, // 既定 ['project'](対象リポジトリの CLAUDE.md / settings)。
// 設定 claudeSettingSources で 'user' / 'local' を追加できる(下記)
// Phase 6 で公開済み(設定ファイル ~/.codiva/config.json 由来、SessionOptions 経由で注入):
model, effort, maxBudgetUsd, // それぞれ存在時のみ付与
systemPrompt, // composeSystemPrompt() の結果。中身があるときのみ付与(下記メモ参照)
resume: sdkSessionId, // 復元時のみ付与。モデル側の会話コンテキストを引き継いで継続
};settingSources と Claude Code のプラグイン(実測):
プラグインの有効化(enabledPlugins)は claude plugin install が ~/.claude/settings.json(user 層)
へ書く。settingSources に 'user' が無いとその層自体が読まれないため、プラグインは 1 つもロードされない。
同一マシン・同一プラグイン構成(frontend-design@… / rust-analyzer-lsp@… の 2 件が有効)で
claude --print --output-format stream-json --verbose の system/init を比較した実測:
| 設定ソース | init の plugins |
プラグイン由来の slash command |
|---|---|---|
--setting-sources project(codiva の既定) |
[] |
なし |
| 既定(user + project + local) | 2 件ロード | /frontend-design:frontend-design |
そこで設定 claudeSettingSources(core/config.ts)で層を選べるようにし、
resolveClaudeSettingSources() → createClaudeAdapter({ settingSources }) で注入する
(配線は bootstrap/build-manager.ts の buildAgents。codexSandbox と同じ「provider 固有の
設定はアダプタ工場へ」の形)。'project' は指定に関わらず必ず含める — CLAUDE.md はこの層でしか
読まれず、設定ミスで落ちると「リポジトリの決まりを知らないセッション」が黙って生まれるため。
既定を広げなかったのは、user 層を読むと hooks / permissions / statusLine といった手元の Claude Code
用の設定まで worktree のセッションに載るから(オプトインにして README に副作用を明記した)。
なお probe(utils/sdk-probe.ts = モデルカタログと /usage)は ['project'] 固定のまま。
init と control channel しか読まない短命プロセスで、プラグインも CLAUDE.md も要らない一方、
user 層を読むとポーリングのたびにユーザーの SessionStart hook が走ることになる。
Phase 6 実装メモ(Options 関連):
model(string) /effort('low'|'medium'|'high'|'xhigh'|'max') /permissionMode('default'|'acceptEdits'|'bypassPermissions'|'plan'|'dontAsk'|'auto') /maxBudgetUsd(number>0) は~/.codiva/config.jsonから読み、core/config.tsのtoConfig()で検証。SessionOptionsに束ねて注入。resumeは復元セッションの最初の追加指示で付与(遅延 resume)。sdkSessionIdはsystem/init.session_id。systemPrompt(リポジトリ追加指示):<repo>/.codiva/prompt.mdをutils/repo-prompt.tsのloadRepoPrompt()で読み、core/repo-prompt.tsのtoRepoPrompt()で正規化(BOM 除去+trim、空は無し)。 存在時のみoptions.systemPromptに載せる。SDK はsystemPrompt省略時に空文字("")へ写像し、 claude_code プリセットは使わない(sdk.mjsの内部変換uOで確認:i===undefined → f=""、{type:'preset',preset:'claude_code',append}を渡すと逆にプリセットが有効化される)。よって文字列を そのまま渡すのは「空への追記」と等価で、追加指示が無い場合の現挙動を一切変えない。CLAUDE.md はsettingSources: ['project']経由なので systemPrompt とは独立に効き続ける(両立)。将来ベースの systemPrompt を導入する場合はこの単純代入では上書きになるため、array / preset-append 形へ要変更。- 編集は
/promptから: 一覧画面の/promptでui/repo-prompt-editor.tsx(現在値をシード)を開き、 保存するとSessionManager.setRepoPrompt()がoptions.appendSystemPromptを差し替え(以降の新規 セッションに適用。既存の稼働中セッションは systemPrompt が query 開始時に確定済みなので不変)、onRepoPromptChange→utils/saveRepoPrompt()が.codiva/prompt.mdを書き戻す(空保存で削除)。
- 編集は
systemPromptの組み立て: 純関数core/system-prompt.tsのcomposeSystemPrompt()が 「worktree の環境説明」→「リポジトリ追加指示」の順で連結する(両方無ければundefined= 付与しない)。- 環境説明 = 共有 symlink の注意書き(
SHARED_IGNORED_FILES_NOTICE):ignoredFiles: 'symlink'のときだけ載る。実測(このリポジトリ自身のセッション worktree)でnode_modules/dist/coverageが元リポジトリを指すリンクになっており、worktree 内でnpm run buildすると メインチェックアウトのdist/を書き換えてしまう(他セッションのビルド結果も踏む)。 ※dist/coverageのような既知の生成物は後に引き継ぎ対象から外した(issue #81 /DEFAULT_IGNORED_EXCLUDES)ので現在はリンクにならないが、プロジェクト固有の生成物は 依然リンクとして現れ得るため注意書き自体は必要。 エージェントは「自分の worktree の中だから安全」と判断するのでこれは防げず、環境として 伝えるしかない。必須要素は「書き込む前に該当パスだけリンクを切る」「rm -rf <path>/や<path>/*はリンクを辿って共有先を消すので禁止」「読むだけ・触らない作業では何もしない」の 3点で、system-prompt.spec.tsが意味アンカー(test -L/readlink/rm -rf等)で固定する。 言語・ツールチェイン非依存にするため対象はnode_modules等の名前ではなくtest -Lの結果で 判定させる。AI 向けプロンプトなので英語(i18n カタログ対象外。utils/title.tsと同じ扱い)。 - 切り離し手順の実測(macOS BSD / GNU coreutils 両方で確認): 渡している手順は
target="$(readlink <path>)" && rm <path> && cp -Rp "$target/." <path>。cp -RL(リンクを全て実体化)は採用しない。ツリー内に循環リンク(packages/*を 指す相互リンクや..を指すリンク)や壊れたリンクがあると exit 1 で途中終了し、 半端なコピーが残る(BSD:directory causes a cycle/ GNU:cannot copy cyclic symbolic link)。"$target/."なら最上位リンクだけを辿るので、内部の symlink は symlink のまま残り、循環・壊れたリンク込みでも exit 0(実測)。mv <path> <path>.bakで退避する形も採用しない。前回の失敗で<path>.bak(ディレクトリを指すリンク)が残っているとmvがその中=共有先へ移動してしまう。 加えて<path>.bakは.gitignoreのディレクトリパターン(node_modules/)に マッチせず untracked として現れる。readlink方式なら一時名が不要。-pを付けるのはモードの保全のため(.envの 600 が umask で 644 に落ちるのを防ぐ)。rm <path>(-rなし・末尾スラッシュなし)はリンクだけを消す。逆にrm -rf <path>/は リンクを辿って共有先のディレクトリごと消える(BSD / GNU 両方で実測)ので、 注意書きで明示的に禁じている。
- symlink は
.gitignoreの末尾スラッシュパターンに載らない(実測。使い捨てリポジトリで再現):.gitignoreのnode_modules/はディレクトリにしかマッチせず symlink にはマッチしないため、 symlink モードの worktree ではgit check-ignore node_modulesが exit 1 で、git statusに?? node_modulesとして現れる。git add -Aすると mode 120000(symlink)でステージされ、 コミット→マージすると絶対パス入りのリンクが base ブランチに入る。注意書きで 「git add -A/git add .を使わずパス指定でステージする」ことを明示している。 副作用としてdiffStat().uncommitted(一覧の表示用)にもリンクが混ざる(表示のみ。 auto-PR はコミット済み差分だけを見るので誤発火はしない)。
- 環境説明 = 共有 symlink の注意書き(
resumeはモデル側の会話コンテキストのみ復元する。過去メッセージはストリームに再送出されない (検証済み: 復元直後の consumer には何も流れない)。そのため UI の会話ログは CLI が書く トランスクリプト~/.claude/projects/<cwd の非英数字を '-' 化したもの>/<sessionId>.jsonlから再構築する (純粋変換core/transcript.tsのtranscriptLogEntries、読み込みはutils/transcript.ts)。 codiva 自身は会話ログを永続しない(state.json はメタデータのみ)。詳細は ARCHITECTURE.md「Phase 6 機能」。
permissionMode の全値: 'default' | 'dontAsk' | 'acceptEdits' | 'plan' | 'bypassPermissions' | 'auto'。
'acceptEdits' は Edit/Write + ファイル操作系 Bash(mkdir/touch/rm/mv/cp/sed)を自動許可する。
// 1. セッションID取得(resume 用に保存)
{ type: 'system', subtype: 'init', session_id: string }
// 2. assistant ターン(テキスト / tool_use を含む)
{ type: 'assistant', message: { content: Array<
| { type: 'text', text: string }
| { type: 'tool_use', id: string, name: string, input: any }
| { type: 'thinking', ... }
>}}
// 3. 最終結果
{ type: 'result',
subtype: 'success' | 'error_max_turns' | 'error_max_budget_usd' | 'error_during_execution' | ...,
result?: string, // success 時のみ最終テキスト
session_id: string,
total_cost_usd: number,
usage: { input_tokens, output_tokens, ... },
num_turns: number }注意: streaming input mode では result はターンの区切りごとに届き、セッション終了を意味しない。result 受信 = 「Claude のターンが終わり入力待ちになった」と解釈する(completed 判定はこのタイミング)。
サブエージェント(Task ツール): 本体エージェントが Task ツールで作業を委譲すると、サブエージェントのメッセージは parent_tool_use_id(= Task の tool_use id)付きで流れてくる(トップレベルは null)。サブエージェント自身は独自の result を出さず、result/success は最後にトップレベル1件だけ。ライフサイクルは system/task_started → system/task_progress → system/task_updated(patch: { status: 'completed', end_time })→ system/task_notification(status: 'completed'|'failed'|'stopped')。決着が 2 系統で届く点が重要で、codiva は両方を task_settled に写す(通知が来ないまま終わるタスクでも完了ゲートが解けるように)。patch.status の union は pending | running | completed | failed | killed | paused で、paused は「走っている」側(決着扱いにすると再開後に追跡できなくなる)。さらに system/background_tasks_changed が生きているタスクの全集合を REPLACE セマンティクスで運ぶ(SDK 自身が「エッジの取りこぼしで running 表示が wedge しないよう集合ごと差し替えろ」と明示)。起動時には出ないので、CLI プロセスが起き直ったら集合を空に戻す必要がある。Task がバックグラウンド実行されると tool_result が即返り本体ターンが続行するため、サブエージェント稼働中に result/success が先に届きうる。このとき素直に completed 判定すると「作業中なのに Completed」になる。対策は task_started と決着イベントで稼働中タスクを追跡し、稼働中に届いた result を保留 → 全タスク settle 後に completed 確定(ARCHITECTURE.md「完了ゲート」参照)。逆にゲートが解けなくなるとセッションが永久に running に張り付くので、決着の判定は取りこぼさない側(「まだ走っている状態の否定」)に倒してある。実データは scripts/spike.ts の subagent シナリオで採取(__fixtures__/session-subagent.jsonl)。
includePartialMessages: true にすると { type: 'stream_event', event }(SDKPartialAssistantMessage)で生のストリーミングデルタが届く。Phase 6 で採用:event.type === 'content_block_delta' かつ delta.type === 'text_delta' のときのみ delta.text を state.streamingText に連結し、詳細ビューにタイピング風プレビューを出す。確定 assistant メッセージ / result / 追加入力で streamingText はクリア(確定ログが正)。streamingText は transient で永続しない。非テキストデルタ(input_json_delta・thinking 等)は状態を変えない。~100ms スロットル(useSessions)で再描画コストを抑える。
Claude Code のタスク管理ツールは世代交代中。両対応必須:
// 旧: TodoWrite — todos 配列で毎回全置換
{ type: 'tool_use', name: 'TodoWrite',
input: { todos: Array<{ content: string, status: 'pending'|'in_progress'|'completed', activeForm?: string }> } }
// 新: TaskCreate / TaskUpdate — 増分更新
{ type: 'tool_use', name: 'TaskCreate',
input: { subject: string, description?: string, activeForm?: string } }
{ type: 'tool_use', name: 'TaskUpdate',
input: { taskId: string, status?: 'pending'|'in_progress'|'completed'|'deleted', subject?: string, ... } }- TaskCreate はツール結果(
type: 'user'メッセージ内の tool_result)に生成された taskId が入るはず。reducer は tool_use と tool_result を突き合わせる必要がある。実際の tool_result の形はスパイクで確認。 - どちらのツールが流れてくるかは SDK バージョン / env(
CLAUDE_CODE_ENABLE_TASKS)依存。スパイクで確認し、確認結果をこのファイルに追記すること。
type CanUseTool = (
toolName: string,
input: any,
opts: { signal?: AbortSignal, suggestions?: PermissionUpdate[] },
) => Promise<
| { behavior: 'allow', updatedInput?: any, updatedPermissions?: PermissionUpdate[] }
| { behavior: 'deny', message: string }
>;- Promise を解決するまでセッションはブロックされる(公式保証)。UI がユーザー応答を得るまで pending のままにしてよい。
- 自動許可されたツール(acceptEdits 対象や allowedTools マッチ)ではコールバックは呼ばれない。
AskUserQuestionは allow ルールに関係なく必ずコールバックに届く。これが「質問あり」機能の実装点:toolName === 'AskUserQuestion'→awaiting_input状態にし、inputに入っている質問・選択肢を UI 表示- ユーザーの回答を
updatedInputに反映して{ behavior: 'allow', updatedInput }を返す - AskUserQuestion の input スキーマと回答の返し方はスパイクで実物を確認すること(要スパイク検証)
- deny 時の
messageは Claude に伝わり、別アプローチを試みる。
許可評価の優先順位: hooks → deny rules → ask rules → permissionMode → allow rules → canUseTool。
Session 内部に push 可能な async キューを持ち、それを generator として渡す:
class AsyncQueue<T> implements AsyncIterable<T> {
push(item: T): void; // UI から呼ぶ
end(): void;
// [Symbol.asyncIterator]() は push を待って yield し続ける
}
function toUserMessage(text: string): SDKUserMessage {
return {
type: 'user',
message: { role: 'user', content: text },
parent_tool_use_id: null,
};
}- generator は開きっぱなしで問題ない(SDK 側から close されない)。
- 追加メッセージは順次処理される。ターン実行中に push した場合の割り込み挙動はスパイクで確認(要スパイク検証。必要なら
interrupt()してから送る)。
query()1本 =claudeサブプロセス1本。cwd を分けている限り相互干渉なし。- メモリはセッションあたり最大 ~1GiB を見込む(公式ホスティングガイドの目安)。10並列なら開発機で現実的。
- セッションにタイムアウトはない。
maxTurnsで暴走を抑止する。 - 認証はサブプロセスが
~/.claudeの既存ログインを継承する。ANTHROPIC_API_KEYがあればそれも使える。
タイトル生成(utils/title.ts)のような要約 1 回きりの呼び出しでも、Options を既定のままにすると
Claude Code のプリセット(全ツール定義)と拡張思考が丸ごと積まれる。同一プロンプトでの実測:
| Options | 所要時間 | コスト/回 | 入力トークン |
|---|---|---|---|
| 既定のまま | 8.4 〜 11.1 秒 | $0.086 | 56,144(cache_creation_input_tokens) |
tools: [] + settingSources: [] + 文字列 systemPrompt + thinking: {type:'disabled'} |
2.3 〜 4.3 秒 | $0.0011 | わずか |
約 80 倍のコスト差で、要約の質は変わらない。さらに遅さが実害になっていた: タイトル生成は
セッション開始と同時に走る(本体の claude サブプロセスと競合する)ため、既定のままだと
20 秒のタイムアウトを超えて null に落ち、一覧のタイトルが指示文のまま残ることが実際に
あった(失敗は握り潰す設計なので何も表示されない)。タイムアウトは 30 秒へ広げた。
教訓: エージェントではない補助呼び出しには、ツールも設定も思考も渡さない。
- Ink 7 は React 19 前提。コンポーネントは通常の React。
render(<App/>)で起動。 - 全画面(100dvh 相当): Ink はコンテンツの高さぶんしか描画しないインラインレンダラ。root Box に
useWindowSize()の rows をheight指定すると Ink 7 がフルスクリーンフレームとして扱う(末尾改行なし・インクリメンタル消去。ただしフレームが端末高さを超えると全画面クリアにフォールバックしてちらつくので、root にoverflow="hidden"を付けて超過を防ぐ)。端末が極端に低い(MIN_FULLSCREEN_ROWS未満)ときは height 固定をやめてインライン描画へフォールバックする — クリップで入力欄・フッタが消えて操作不能になるより、端末スクロールに任せる方が安全。 <Static>は全画面レイアウトと両立しない: Static はスクロールバック側に書き出すため、フレームが画面いっぱいだとビューポート外に消える。メッセージログは末尾ビューポート(flexGrow +justifyContent="flex-end"+overflowY="hidden")+logWindow(lines, rows, anchor)(core/scroll.ts)で再描画コストに上限を掛ける方式にした。スクロールの単位は物理行: エントリはlogLines(messages, width, prefixFor)で CJK 幅(string-width)を考慮して折り返したDisplayLine[]に展開してから window する(複数行メッセージ 1 件でビューポートが埋まったり、スクロール量が実際の行数とズレるのを防ぐ)。anchorは'bottom'(末尾追従)か絶対 end index(上スクロール中は固定=新着で view がぶれない)。PgUp/PgDn(半画面)と ↑/↓(1行)でscrollUp/scrollDown。alt screen でスクロールバックを無効化しているため、過去ログはこのアプリ内スクロールでのみ辿れる。- Yoga は溢れた子を「クリップ」せず「縮小」する:
overflowY="hidden"+justifyContent="flex-end"の箱に可視高さより多くの行を入れると、上端でクリップされるのではなく子が縮小され、行が虫食いで欠落する(L1, L3, L5…のように 1 行おきに消える)。詳細ログが「上へスクロールできない/読めない」原因はこれだった。対策は (1) 行の入れ物にflexShrink={0}を付けて縮小を禁じる(溢れは flex-end で上端クリップになる)、(2)logWindowに渡す行数をuseBoxHeightで実測した可視高さに合わせる(見積りlogViewportRowsは初回描画までのフォールバック。見積りは必ず実測以下に倒す)。加えてアンカーは 1 画面ぶんで下限を打ち、最上部でも 1 ページ分が埋まるようにする。 - 空文字の
<Text>は高さ 0(実測 / ink 7): Ink のmeasureText()はtext.length === 0のとき{width: 0, height: 0}を返す(node_modules/ink/build/measure-text.js)。そのため<Text>{''}</Text>は行として一切場所を取らない(squashTextNodesの結果が空なら描画もスキップされる)。ログの空行(Markdown の段落間、コードブロック内の空行)がこれに当たり、スクロール計算(core/scroll.tsは空行も 1 物理行として数える)が確保した高さより実際の描画が短くなって、末尾寄せのビューポート上端に空行の本数ぶんの隙間が残る(「表示できる行があるのに上が空いている」)。同時に段落の区切りも消えて行が詰まって見える。対策は行の描画側(ui/session-detail.tsxのLogLine)で空行を半角スペース 1 つに置き換え、必ず 1 行ぶんの高さを確保すること。PromptInputは各行を非空のプレフィックス<Text>と同じ<Box>に入れているため元から影響を受けない。 - 複数行入力: 純粋モデルは
core/text-buffer.ts(value + cursor、insert/backspace/move*/visibleLineRange)。キー→操作の対応はui/input.ts(editText/resolveEnter)。Shift/Meta+Enter か末尾バックスラッシュ+Enter で改行、それ以外は送信(バックスラッシュは Shift+Enter を送れない端末向けの堅牢なフォールバック)。一覧ビューは矢印を行選択に温存するためカーソル移動なし(末尾編集+改行のみ)、詳細ビューは矢印でフルにカーソル移動。PromptInputはINPUT_MAX_ROWSまで伸び、超過分はvisibleLineRangeでカーソル付近を内部スクロール(空/1行時は従来どおり1行高)。幅を超えた行はソフト折り返しする(core/composer-layout.tsのcomposerLayout。空白があれば単語境界、無ければ表示幅で強制改行。CJK は 2 セル)。折り返し幅は Box の computed layout を実測して得る(useComposerWidth= 実測幅 −❯の 2 セル。ダイアログ内は端末幅と一致しないため引き算では求まらない)。以降「行」は表示行の意味になり、visibleLineRange/caretIndexAtClick/rowSelection/ ↑↓(moveRowUp・moveRowDown)はすべて同じ幅の同じ幾何を通す。 - SGR マウス ?1002 は「セルが変わったときだけ」移動を報告する(実測): ボタンを押したまま静止していると drag レポートが来ない。そのため「可視域の外へドラッグしたらスクロールを続ける」自動スクロールは、レポート駆動だけでは端で止まってしまう —
setInterval(LOG_EDGE_SCROLL_MS= 50ms で 1 行)で回す必要がある。タイマーは向きが変わったときだけ張り替え、最新のステップ関数は ref 経由で渡す(ログの追記や再描画ごとに張り替えると 1 tick も進まないことがある)。スクロール位置(アンカー)も ref に持つ — 1 tick は「次のアンカー」から選択の終点を組み、「動かなかったか」でタイマーを止める判定もするため、setStateの関数形(次の描画まで値が見えない)では書けない(値の同期的な読みが必要)。ログの範囲選択そのものは行 index + 桁(core/log-selection.tsのLogPoint)で持つので、スクロールしても選択位置の意味が変わらない。 - 日本語 IME がオンのあいだ、素の
Spaceは全角スペース(U+3000)で届く(macOS の日本語入力の仕様。かな入力モードで未変換のままSpaceを押すと全角スペースが確定して入り、半角スペースを入れるにはShift+Spaceを押す必要がある)。端末は IME が確定した文字列をそのまま送ってくるだけなので、アプリ側からは「押されたのは Space キー」だとは分からず、input === ' 'の比較は必ず外れる。トグル系のキー操作(質問ダイアログの複数選択チェック・/configの ON/OFF)でShift+Spaceを強いられていたのはこれが原因で、判定は共通のisSpaceKey(core/key-sequence.ts。半角と U+3000 の両方を Space とみなす)に寄せてある。文字として挿入する経路(コンポーザ)はこの正規化を通さない — 打った全角スペースは全角のまま入るのが正しい。なおy/nのような英字キーは IME が preedit に吸うため原理的に届かない(回避策は IME を切ること)。 useInput: グローバルキーハンドラ。フォーカス管理はuseFocusもあるが、MVP はビュー単位の単純な状態分岐で足りる。useApp().exit(): 終了。終了前に SessionManager.dispose()(全 abort)を呼ぶ。- 再描画スロットリング: コアからの onChange を UI 側で ~100ms デバウンス。
useSyncExternalStoreの getSnapshot が返す参照が変わらなければ再描画されない点を利用する。 - alt screen(代替スクリーンバッファ): 全画面レイアウトでも通常バッファのままだとシェルの過去出力がスクロールバックに残り、上へスクロールできてしまう。起動時に
\x1b[?1049hで alt screen に入り、終了時に\x1b[?1049lで抜ける(utils/alt-screen.ts)。alt screen にはスクロールバックが存在しないため vim / htop と同様にスクロールがロックされ、終了すると元の画面が復元される。enter するのは「TTY かつ起動時の rows がMIN_FULLSCREEN_ROWS以上」のときだけ(インライン描画フォールバック時はスクロールバックに頼るため通常バッファのまま)。終了時の残存 worktree 案内は leave 後に書き、通常バッファに残す。クラッシュ時の取り残し防止にprocess.on('exit')で leave を保険登録する。
- 強制終了では
process.on('exit')が走らない。V8 のヒープ枯渇(FATAL ERROR: Reached heap limit … JavaScript heap out of memory)はabort()で即死し、SIGKILL/SIGSEGVも同様に JS を一切実行しない。よってtoggleEscapeの exit フックは捕捉できる死に方にしか効かない。 取り残されるのはマウスレポート(?1002/?1006)で、症状は「ターミナルに戻ったあと、スクロールすると[<64;12;5Mのような文字列が大量に入力される」(端末が SGR マウスレポートを送り続けている)。 - 復旧手段は 2 つ用意した。(1) 起動時に無効化してから alt screen へ入る
(
setupTerminal()冒頭のdisableMouseReports()。次回起動で勝手に治る)、 (2) 保守用フラグcodiva --reset-terminal(resetTerminalModes()= ?1000/?1002/?1003/?1006/?1015 → ?2004l → ?25h → ?1049l を 1 回の write で送る)。off を送るのは冪等なので、有効でないモードへ 送っても無害。 process.report(Node 診断レポート)は fatal error でも書かれる。C++ 層が abort の前に 書くため、JS ハンドラでは絶対に拾えない OOM / ネイティブクラッシュの唯一の記録になる。 実行時にprocess.report.directoryを設定してreportOnFatalError = trueにするだけで有効 (reportOnSignal/reportOnUncaughtExceptionは自前のハンドラと二重になるので off)。 出力はreport.<日時>.<pid>.<tid>.<seq>.jsonで、header.trigger(OOM ならOOMError)・javascriptHeap(heapTotal / heapUsed / 各 space の使用量)・nativeStack・resourceUsage・ 環境変数まで含む(実測: Node 24 で--max-old-space-size=24を枯渇させて確認)。 環境変数はANTHROPIC_API_KEYを含み得るので、process.report.excludeEnv(Node 23.3+)が あるときは必ず立てる(古い Node には無いので'excludeEnv' in reportで判定する)。- alt screen の中に stderr を出しても残らない。leave すると画面ごと復元されるため、例外の スタックはユーザーの目に触れない(「突然ターミナルに戻った」に見えていた原因)。クラッシュ ハンドラは先に端末を戻してから書く。
- Ink 7 は children を ErrorBoundary で包み
onErrorでexit(error)するので、描画中の throw はwaitUntilExit()の reject として出てくる(=main()の reject → クラッシュ経路)。 この場合index.tsxの shutdown 列(ポーリング停止・persist.flushAsync()・teardown)は 丸ごとスキップされるため、クラッシュハンドラ側にも同期 flush と端末復元を持たせている。
詳細ログの URL を「Cmd+クリックで開く」を端末任せにできるか調べた結論と、その根拠。
xterm の仕様が定義する修飾ビットは shift=4 / meta(alt)=8 / ctrl=16 だけ。Super/Cmd は無い。
- Ghostty (
src/input/mouse_encode.zig):shift→4/alt→8/ctrl→16のみ。Cmd は一切 エンコードされないので、Cmd+クリックは素のクリックとバイト列が同一。 - iTerm2 (
sources/VT100/VT100Output.m): Cmd → bit 8(meta) に割り当て、Option は エンコードしない(Option がマウスレポートのバイパスキーだから)。 → bit 8 の意味が端末ごとに違う(Ghostty では Option、iTerm2 では Cmd)ので、 bit 8 で「Cmd」を判定することはできない。
| 流派 | 端末 | 素の Cmd/Ctrl+クリック | 必要な操作 | アプリにもクリックが届くか |
|---|---|---|---|---|
| マウスレポート優先 | Ghostty / kitty / WezTerm | 効かない(ホバー下線も出ない) | Shift+Cmd+クリック(Shift がバイパス) | 届かない(端末が飲む) |
| ハイパーリンク優先 | iTerm2 / VTE(gnome-terminal) / Windows Terminal / VS Code | 効く | Cmd / Ctrl+クリック | iTerm2・VS Code は届くこともある |
Ghostty の該当箇所(src/Surface.zig の cursorPosCallback)は、リンクのホバー判定を
「マウスレポートが off、または shift が押されていて shift をアプリへ送らない設定のとき」に
限っている。mouseRefreshLinks が mouse.over_link を立てる唯一の経路で、
mouseButtonCallback はそれを見てリンクを開くので、捕捉中は素の Cmd+クリックでは
ホバーもクリックも成立しない。裸 URL の自動検出(link-url)も同じ経路なので同様。
→ 主端末(Ghostty)が最悪ケースで、設定でも直せない(mouse-reporting = false にするか
Shift を足すしかない)。一方 素の左クリックはどの端末でもアプリに SGR レポートとして届く。
そこで codiva は自分でクリックを取って開く方式にした(logLinkAt → utils/open-url.ts)。
OSC 8 は「対応端末では端末側の Cmd+クリックも使える」上乗せとして併せて出す。
\x1b]8;;URL\x1b\\text\x1b]8;;\x1b\\ を実際に測った結果(このリポジトリの依存で確認):
| パッケージ | 版 | 結果 |
|---|---|---|
string-width |
8.2.2 | stringWidth(OSC8('click')) = 5(= 表示テキストぶんだけ) |
@alcalzone/ansi-tokenize |
0.3.0 | セル数 5、再構築して OSC 8 が保持される |
ansi-regex / wrap-ansi / slice-ansi / cli-truncate |
6.2.2 / 10.0.0 / 9.0.0 / 6.1.1 | OSC 8 対応済み |
必要な最低版は ansi-regex ≥6.1.0 / wrap-ansi ≥10 / slice-ansi ≥8 / ansi-tokenize ≥0.3.0
(2024-09 以前のエコシステムは ST 終端の OSC 8 を扱えず壊れていた)。
ただし codiva 自身の折り返しは通らない。 core/scroll.ts の wrapDisplayLines は
Intl.Segmenter でグラフェム単位に分けて 1 つずつ measure するので、OSC 8 を混ぜた文字列を
渡すとエスケープのバイトを可視幅として数える(幅 20 で測ったら可視 21 セルの文字列が 4 行に
割れ、URI が行の途中で断ち切られた)。よって OSC 8 は LogEntry.text / RichSpan.text に
入れず、描画時(ui/log-line.tsx)にだけ包む。パラメータ形(id=)は wrap-ansi@10 が
壊す(ANSI_ESCAPE_LINK が ]8;; 決め打ち)ので使わない。
- tmux は 3.4+ が OSC 8 を自前で解釈して再送する(パススルーではない)。ただし
Hlsを送る相手を自動判定するのは tmux / iTerm2 / foot / WezTerm / ghostty だけで、 kitty・VTE・Windows Terminal はset -ga terminal-features "*:hyperlinks"が必要。 URI は 1024 バイトで打ち切られる(core/url.tsのMAX_URL_CHARSがこれに合わせてある)。 - tmux ≤3.3a は OSC 8 を黙って捨てる(可視ゴミにはならない)。
screenは非対応。 - 仕様準拠の端末は未知の OSC を無視するだけなので、出しても表示は壊れない (可視ゴミが出るのは VTE ≤0.48 / Windows Terminal ≤0.9 など相当古い版)。
supports-hyperlinksパッケージは tmux 内で false を返す(TERM_PROGRAMを tmux が 上書きしTERMもscreen-*になる)。通知のdetectNotifyProtocolと同じ罠なので、 能力判定に使わない(codiva は判定せず常に出し、非対応端末の無視に任せている)。
報告された落ち方(node 22 / 既定のヒープ上限):
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
8: … v8::internal::MinorGCJob::Task::RunInternal()
zsh: abort codiva
-
Ineffective mark-compactsは「回収が確保に追いつかない」の意味で、Reached heap limitと 違って「巨大な 1 個」ではなく確保レートが高いときに出る(ネイティブスタックの最上位がMinorGCJob= 新世代 GC のジョブなのがその印)。だから犯人は「捨てているのに作り続けている」 コード = 毎更新でログ全体を作り直していたlogLinesだった。 -
実測(2,000 文字級の Markdown エントリ 500 件、幅 100 セル、「1 件追記 → 再描画」を 500 回 = 実際の追記ごと再描画と同じ形):
所要 生成した DisplayLineメモ化なし(従来) 23.3 秒 約 75 万個(+ spans配列)エントリ単位メモ化 0.10 秒 3,000 個(最終フレームぶんだけ) 226 倍。ログが伸びるほど差が開く(従来は追記 n 件目のコストが O(n))。
-
併せて保持側にも上限を掛けた(
core/log-buffer.ts)。SessionState.messagesは上限なしで、 かつ追記が[...messages, entry]= 全体コピーだったため、長いセッションでは保持量と 確保量が同時に増えていた。 -
メモ化は「一過性のゴミ」を「永続的な保持」に変換するので、キャッシュ側にも予算が必要 (実測。
--expose-gc+gc()後のheapUsed差分):保持量 行数上限なし・2000 件 × 1,500 文字を 1 セッション描画 91 MB(30,000 行) 同条件の最悪ケース(2000 件 × 20,000 文字) ~1.25 GB(40 万行) MAX_CACHED_ROWS= 8,000(LRU)+MAX_LOG_CHARS= 400,000。上限まで詰めた 6 セッションを描画22 MB 1 行は「素のテキスト」と「スパン(同じ文字を styled で持つ)」の両方を抱えるので、 展開後は元テキストの数倍になる。件数の上限だけでは足りない(1 件は 1 文字でも 20,000 文字でも よいので、件数 × 1 件上限 = 4000 万文字)。
-
LRU は「描画中の行」を追い出してはいけない。予算より大きいログを描くとき、素直に古い順で 捨てると自分がこのフレームで使う行を捨てて毎フレーム全再展開になる(= 元の症状に戻る)。
logLinesはパス番号で「今回使った行」を守り、予算は soft budget として扱う。実測: 上限いっぱい (2000 件・12,000 行 = 予算の 1.5 倍)で「1 件追記 → 再描画」を 300 回回して 0.40 ms/フレーム。 -
**1 セッション =
claudeサブプロセス 1 本(最大 ~1GiB)**という別枠のメモリもあるので、 「codiva 本体のヒープ」と「サブプロセスの合計」を混同しないこと。前者はクラッシュログのmemory行(rss / heapUsed / heapTotal)、後者はpsで見る。
上の対策を入れた 0.3.8 でまた OOM で 3 回落ちた(~/.codiva/logs/report.*.json)。前回と違い
今回は保持漏れで、しかも描画内容と無関係だった。
- 診断レポートの読み方:
old_space4.15〜4.22GB /large_object_spaceは 54〜57MB だけ ⇒ 「巨大な 1 個」ではなく小さいオブジェクトが数百万個生存。resourceUsage.userCpuSecondsが 1571 / 1628 / 1986 秒(= GC スレッドがほぼ回りっぱなし)。crash-*.logは無い(OOM はabort()即死なので JS が走らない = 設計どおり)。 - 既報だった(ink#869 /
facebook/react#35761。どちらもクローズ済みで、
結論・対策も同じ
NODE_ENV=production)。以下は診断レポートから独立に辿り直した記録。 - 犯人はヒープスナップショットで一発だった。上位が
PerformanceMeasure× 60,003 (= 20,000 描画 × 3)とComponents ⚛/Changed Props/Scheduler ⚛/primary-lightといった文字列 = React 19.2 の Performance Tracks。 - ゲートは
react-reconciler.development.jsのsupportsUserTiming(console.timeStamp&&performance.measure)で、モジュール評価時のvar初期化。 Node には両方あるので必ず有効になり、後からconsole.timeStampを消しても間に合わない。 - Node の user timing は自動で捨てられない(ブラウザの devtools が消費する前提の API)。 長時間動く TUI では単純なリークになる。
条件(空 Box を 8,000 回再描画・--expose-gc + 強制 GC 後の heapUsed 差分) |
永久保持 | perf エントリ | 所要 |
|---|---|---|---|
| dev ビルド(従来) | 2,230 B/フレーム | 24,003 件 | 414ms |
NODE_ENV=production |
117 B/フレーム | 0 件 | 166ms |
dev + 500 フレームごとに clearMeasures() |
174 B/フレーム | 1 件 | 406ms |
- 描画は約 10/秒(ストア購読の ~100ms スロットル。実測でも 9〜10/秒)⇒ 約 86MB/時。 既定のヒープ上限 ~4GB に半日〜1 日で到達する。production ビルドは描画自体も 2.5 倍速い (報告された 26〜33 分の CPU 時間の相当部分がこれ)。
NODE_ENVを react より先に立てるにはエントリを分けるしかない。ESM の static import は 巻き上げられて本文より先に評価されるので、tsupのbannerでは間に合わない。 シバンに#!/usr/bin/env -S NODE_ENV=production nodeを書く手は macOS では動くが、 mise 経由の起動はnode <path>直叩きでシバンを通らない(実際のクラッシュレポートのcommandLineがこれ)ので当てにならない。
process.env.NODE_ENV = 'production' は codiva 自身のプロセスに対する設定だが、そこから
spawn した子(claude / codex → エージェントのシェル)にもそのまま渡る。npm は
NODE_ENV=production を --omit=dev と解釈するので、セッション内の npm ci が
devDependencies を落とす。実測(codiva 0.4.3 / npm 11.x / Node 24):
$ npm ci # セッション内(NODE_ENV=production を継承)
added 103 packages # @types/* も vitest も eslint も入らない
$ NODE_ENV=development npm ci --include=dev
added 235 packages最小再現(devDependencies に is-odd だけを置いた package.json):
$ NODE_ENV=production npm install --silent; test -d node_modules/is-odd && echo YES || echo NO
NO
$ env -u NODE_ENV npm install --silent; test -d node_modules/is-odd && echo YES || echo NO
YES出るのは「依存が無い」ではなく error TS7016: Could not find a declaration file for module …
のような型エラーなので、原因が npm だと辿り着きにくい。対策は「codiva 自身は production の
まま、子へ渡す env からだけ落とす」(core/child-env.ts / utils/child-env.ts。設計は
docs/ARCHITECTURE.md「子プロセスへ渡す環境変数」)。
同じ調査で見つかった、もう 1 つの解放されない保持:
ink/build/measure-text.js…const cache = new Map()(キー = テキスト全文)ink/build/wrap-text.js…const cache = {}(キー =text + maxWidth + wrapType)
どちらも evict が無く、供給経路は yoga の measure func(dom.js の measureTextNode)なので
レイアウト計算のたびに最大 3 エントリ積まれる(renderThrottleMs は stdout 書き込みだけを
間引くので効かない)。実測(--expose-gc + 強制 GC 後の差分、unmount 後も残る):
| 描いたもの(毎フレーム内容が変わる) | 永久保持 |
|---|---|
| 約 100 文字の行 1 本 | 約 1.7KB |
4,000 文字の <Text wrap="truncate-end"> 1 本 |
約 17.8KB |
| 内容が固定の行(キャッシュに当たる) | 約 0.5KB |
codiva 側で効く対策は「毎フレーム変わる長い文字列を渡さない」だけ。当初はストリーミング
中の本文を 1 行に切って(streamTail(text, width))渡すことで対処した(実測: 4,000 文字の
最悪ケースで 6,786 → 3,129 B/フレーム)。
本文をログの行として下へ伸ばすようにしてからは、折り返してから 1 行ずつ渡す
(core/scroll.ts の streamLines)ことで同じ性質を保っている。末尾に足すだけなら
確定した行の文字列は変わらない = キャッシュに当たるので、毎デルタで新しく積まれるのは
書きかけの最終行 1 本だけで、1 行プレビューだった頃と同じコストになる。この性質は
2 つの前提に依存するので、どちらも崩さないこと:
- 途中テキストを Markdown 整形しない。未完の
**や ``` があると整形結果が デルタごとに変わり、全行の折り返しがズレて毎フレーム全行が別のキーになる。 - 頭を落とすときは行頭で落とす(
clipStreamText)。行の途中で切ると残った行の 折り返しが全部ズレる。上限(8k)と落とし先(4k)を別の値にしてあるのは、同じ値だと 上限到達後は 1 文字届くたびに切り直すことになるため。
上限そのものは ink 側の修正が必要(ink#986 で報告済み。 こちらは未報告のバグだった)。
-
macOS の
osascript display notificationは「Script Editor」名義になる(実測 / macOS 15)。通知センターは通知をアプリバンドル単位で管理し、osascriptは自前のバンドルを持たないため AppleScript の代表バンドルcom.apple.ScriptEditor2に紐づく。通知クリックは「送信元アプリのアクティベート」なので、codiva の完了通知を押すとスクリプトエディタが開く(同じ症状の報告: opencode#23446)。-sender相当の指定はosascriptには無く、tell application id "…" to display notificationで端末アプリ名義にする手は TCC(自動化)許可プロンプトが必要になる。 -
対策は端末自身に通知を出させる OSC シーケンス(
utils/notify.tsのbuildNotifySequence)。端末エミュレータが投函するので通知は端末アプリ名義になり、クリックでその端末が前面に来る=復帰動線として正しく機能する。OSC 52(クリップボード)と同じく SSH / コンテナ越しでも動く。方言が 3 つあるので端末ごとに使い分ける:方言 形 対応端末 OSC 777 ESC ] 777 ; notify ; <title> ; <body> BELGhostty / WezTerm / foot OSC 9 ESC ] 9 ; <body> BEL(本文 1 つだけ。タイトルは端末名)iTerm2 OSC 99 ESC ] 99 ; i=<id>:d=0:p=title:e=1 ; <base64> ST+d=1:p=bodykitty -
判定は環境変数(
detectNotifyProtocol)。OSC は投げっぱなしで解釈されたか分からない(無視されれば無音で消える=動いていた OS 通知まで失う)ため、対応が確実な端末だけを列挙し、それ以外は従来の OS コマンド(osascript/notify-send)へフォールバックする。非 TTY のときもエスケープは書かない(ゴミが残るだけ)。意図的に外したもの:- Windows Terminal: 通知用 OSC 777 は microsoft/terminal#20012 で実装されたが
allowOSC777設定が既定 false。OSC 9 の方は ConEmu 方言の数値サブコマンド(9;4プログレス等)専用で9;<text>の通知ではない。 - urxvt: OSC 777 は「第1フィールドの名前の perl 拡張へ丸投げ」する汎用口で、
notify拡張は同梱されていない(perl-ext-commonでの追加読み込みが必要)。
- Windows Terminal: 通知用 OSC 777 は microsoft/terminal#20012 で実装されたが
-
tmux は
TERM_PROGRAMをtmuxで上書きする(tmux 3.2 でTERM_PROGRAM/TERM_PROGRAM_VERSIONを export するようになった)。TERMも screen-* に化けるため、TERM_PROGRAM/TERMだけ見ると tmux 内では必ず判定漏れして Script Editor 名義に戻る。端末が自前で撒く変数(GHOSTTY_BIN_DIR/GHOSTTY_RESOURCES_DIR/WEZTERM_PANE/WEZTERM_EXECUTABLE/KITTY_WINDOW_ID/ITERM_SESSION_ID)は tmux サーバ起動時の環境として残るので、これらも判定に使う。iTerm2 のLC_TERMINAL=iTerm2は ssh が既定で転送する(SendEnv LC_*)ので、リモートの codiva からでも手元の iTerm2 に通知が出る。tmux 内では OSC を DCS パススルーで包む(utils/terminal-mode.tsのwrapForTmux。allow-passthrough onが必要)。 -
文字列は制御文字を空白へ潰し 120 文字で切る。ESC / BEL がシーケンスの終端子なので、セッションタイトル(LLM がリポジトリ内容から作る=非信頼入力)にそれらが混ざるとシーケンスが途中で切れて壊れる。C0 / DEL だけでなく C1(U+0080–U+009F)も落とす: UTF-8 のまま U+009C を ST、U+009B を CSI として解釈する端末があるため。OSC 777 の title 内
;はフィールド境界と誤読されるので,へ置換(body は最終フィールドなので不要)、OSC 9 は本文が9;4;70のような数値サブコマンドに化けないよう;を全部置換する。OSC 99 は payload を base64(e=1)にして;/ 非 ASCII をそのまま運び、id は<pid>-<連番>(同じ id は上書き・連結されるため、1 端末で codiva を 2 つ動かしても衝突しないように pid を混ぜる)。
# 作成(HEAD から新ブランチを切る)
git worktree add .codiva/worktrees/<slug> -b codiva/<slug>
# 一覧(porcelain がパースしやすい)
git worktree list --porcelain
# 削除(未コミット変更があると失敗する。UI で確認後 --force)
git worktree remove .codiva/worktrees/<slug> [--force]
git branch -D codiva/<slug>
# diff 概要(ベースブランチとの比較)
git -C .codiva/worktrees/<slug> diff <base>...HEAD --stat
git -C .codiva/worktrees/<slug> status --porcelain # 未コミット分
# マージ(メイン worktree 側で実行)
git merge --no-ff codiva/<slug>- コミットが1つもないリポジトリでは worktree を作れない → 起動時チェックで弾く(F-1)。
- 同名ブランチ/worktree の衝突: slug に連番を付与。
.codiva/を git から隠すのは.codiva/.gitignore(中身は*の 1 行)。*は同じディレクトリの.gitignore自身にも一致するので、この 1 ファイルだけでディレクトリ全体が ignore される(cargo のtarget/.gitignoreと同じ手)。無いときだけ書くので冪等。.git/info/excludeへの追記に戻さない。.gitはディレクトリとは限らない — linked worktree(git worktree addで作った作業ツリー)と submodule ではgitdir: <path>を書いたただのファイルなので、<repo>/.git/info/excludeへのappendFileが ENOTDIR で失敗する(実測)。codiva 自身のリポジトリを codiva の worktree の中から開いた場合がまさにこれで、握り潰していなかったため worktree 作成ごと失敗していた。- 副作用:
.gitignoreの出所がそのディレクトリの中にあると、git ls-files --others --ignored --exclude-standard --directoryは.codiva/を 1 件に畳まず中身まで列挙する(実測:.codiva//.codiva/.gitignore/.codiva/state.json/.codiva/worktrees/の 4 件)。ignore 済みファイルの引き継ぎフィルタが完全一致(entry === '.codiva')だと.codiva/worktrees/が引き継ぎ対象に化け、新しい worktree の中に worktree 群自身へのリンクが張られる → 以後のgit worktree removeがToo many levels of symbolic linksで失敗した。先頭セグメントで判定する(isInternalEntry())。
- git 実行は必ず
execFile(シェル経由禁止。slug はサニタイズ済みだが多層防御)。 - worktree がリポジトリ配下にあることの副作用(issue #81 の実測):
.codiva/worktrees/<slug>は プロジェクトルートの下なので、ルートから再帰的にファイル監視する開発サーバ(Next.js / Turbopack のnext dev --turbopack。macOS では FSEvents)の監視対象に入る。ここに<worktree>/.next -> <root>/.nextのようなリンクがあると、開発サーバが自分で書き込んでいる ディレクトリを worktree の数だけ別経路として再検知する。報告例は worktree 6 個で CPU / メモリ / FD を食い潰し OS ごとフリーズ(node_modulesも同様に多重経路になる)。 → 対策として、ビルド生成物・キャッシュは引き継ぎ対象から外した(DEFAULT_IGNORED_EXCLUDES。node_modulesは依存なので引き継ぎを維持 —— Next.js / Vite 等の監視はnode_modulesを 既定で除外する)。監視そのものを軽くしたい場合は、利用者側で.codivaを 開発サーバの監視除外に足すのが確実(codiva は対象リポジトリの.gitignoreを書き換えないので、 git 用の除外は.codiva/.gitignore= 自分の持ち物の中までに留める)。worktree の置き場所をリポジトリ外へ移す案は 取らない(.codiva/worktrees/<slug>前提のパス・復元・takenSlugs()を崩さないため)。
core/は SDK 非依存でテストする:SessionにqueryFnを DI し、テストでは「SDKMessage の配列を順に yield し、canUseTool を任意タイミングで発火させる」フェイクを注入。- テスト配置: 単体テストは実装の隣に co-located
*.spec.ts(src/core/slug.spec.ts等)。App 全体を通す機能/統合テストはtests/*.test.tsx。vitest の include はsrc/**/*.spec.{ts,tsx}とtests/**/*.test.{ts,tsx}の両方。coverage は**/*.spec.*と**/__fixtures__/**を除外。 - フィクスチャは Phase 1 スパイクで収集した実メッセージ JSONL を使う(
src/core/__fixtures__/)。手書きの想定メッセージでテストを書かない。__fixtures__/に昇格する前に必ずサニタイズする。system/initは環境情報の塊(cwdの絶対パス・memory_paths・接続中 MCP サーバ名・skills/slash_commands/agents等)を含むので、テストが使うsession_id等だけ残して環境フィンガープリントを削り、/Users/<name>等の個人パスも置換する(reducer が読むのは init のsession_idのみ)。生ログ置き場scripts/fixtures/は.gitignore済み。
status-reducerは純関数なのでテーブルドリブンでテスト。WorktreeManagerは一時ディレクトリに実リポジトリを作って統合テスト(git init→ コミット → add/remove/merge)。- UI は
ink-testing-library。カバレッジ 80% の対象は core/ と utils/(.specは対象外)。
- TodoWrite と TaskCreate/TaskUpdate のどちらが流れてくるか。tool_result から taskId をどう取るか
- AskUserQuestion の input スキーマと、回答を updatedInput でどう返すか
- streaming input mode での result メッセージの届き方(ターン毎に届くか、completed 判定に使えるか)
- ターン実行中に追加メッセージを push した場合の挙動(キューされるか、割り込むか)
- interrupt() の効果と、interrupt 後にセッションが継続可能か
- acceptEdits で実際に canUseTool に落ちてくるツールは何か(Bash が来ることの確認)
- 検証結果と実メッセージのサンプルを本ファイル末尾に「スパイク結果」節として追記する
scripts/spike.ts を basic / followup / interrupt の3シナリオで実行(のちに subagent シナリオを追加)。実メッセージは src/core/__fixtures__/session-{basic,followup,interrupt,subagent}.jsonl に保存済み(reducer / sdk-parse テストの正データ)。以下は実測結論。想定と違った点を太字にした。
system/init, assistant, user(tool_result を含む), result/success, result/error_during_execution, system/thinking_tokens, rate_limit_event。
observed した tool_use: TaskCreate, TaskUpdate, AskUserQuestion, Write, Bash, ToolSearch。
TaskCreateinput:{ subject, description?, activeForm? }。ID は input に無く、システムが連番の文字列("1","2", …)を採番して返す。- 文字列 tool_result:
"Task #1 created successfully: <subject>" - 構造化結果: user メッセージの
tool_use_resultに{ task: { id, subject } }(TaskCreateOutput)。ID はここから確実に取れる。
- 文字列 tool_result:
TaskUpdateinput:{ taskId, status, subject?, description?, activeForm?, ... }。status ∈ 'pending'|'in_progress'|'completed'|'deleted'。tool_result 構造化:{ success, taskId, updatedFields }。- reducer 実装方針:
assistantの tool_use を走査し、TaskCreateで{id: 連番, subject, status:'pending'}を push、TaskUpdateでtaskId一致タスクの status を更新。ID は「N番目の TaskCreate → id=String(N)」で採番すれば実測と一致(tool_use_result.task.idで照合すればより堅牢)。進捗 =completed数 / 全数。 system/task_*(task_started 等)メッセージはトップレベルでは出ない(あれは Task ツール=サブエージェント用)。無視してよい。
- input:
{ questions: [{ question, header, options: [{label, description, preview?}], multiSelect }] }(1〜4問、各2〜4択)。 - 回答方法(重要・実測):
canUseToolで{ behavior: 'allow', updatedInput: { ...input, answers } }を返す。answersは{ [questionText]: 選択ラベル }(multiSelect はカンマ区切り文字列)。自由入力はresponse?: string。- 回答した場合の tool_result:
"Your questions have been answered: \"...\"=\"...\"." - 回答せず allow だけ返すと tool_result は
"The user did not answer the questions."になり質問が無視される。必ずanswersを入れること。
- 回答した場合の tool_result:
- 出力型
AskUserQuestionOutput:{ questions, answers: {[k]:string}, response?, annotations?, afkTimeoutMs? }。 - codiva 実装方針:
toolName === 'AskUserQuestion'を検知 →awaiting_input状態で質問/選択肢を UI 表示 → ユーザー選択をanswersに載せて allow。回答が返るまで Promise を保留。
- streaming input mode で
result/successはユーザーターンごとに1回届く(followup で2回確認)。セッション終了ではない。 - followup では2ターン目に
system/initが再度届いたが、session_id は全メッセージで同一。→ init はターン毎に来ても、session_id で1セッションとして束ねる。 resultの主なフィールド:subtype,is_error,session_id,num_turns,total_cost_usd,usage,modelUsage,result(string, success時),stop_reason,permission_denials,duration_ms。- completed 判定:
result/success受信 = 「ターン完了・入力待ち」。codiva は暫定的にこれをcompleted(追加入力があれば running に戻る)として表示する。
- 前ターンの
result後にinput.push()すると次ターンが開始する(followup で確認)。キューは順次処理。 - ターン実行中の push はキューされ、現ターン完了後に処理される(実測上は割り込まない)。即時割り込みが必要なら
interrupt()を併用。
q.interrupt()を呼ぶと現ターンが打ち切られ、result/**error_during_execution**が届く(successではない)。- streaming input が開いていればセッション自体は生存し追加 push で継続可能。codiva の「中断して再指示」に使える。
- 打ち切られた result の中身(実測 =
__fixtures__/session-interrupt.jsonl):is_error: true/terminal_reason: 'aborted_streaming'/errors: ['[ede_diagnostic] result_type=user …']/resultフィールドは無し。直前に[Request interrupted by user]を本文に持つuserメッセージが 1 通入る。 is_errorなので素直に扱うとfailedになるが、ユーザーが自分で止めたのだから失敗ではない。 codiva はterminal_reasonの構造で判定してinterrupted(resumable)に落とす(errors[]は CLI の 内部診断なので表示しない)。詳細は ARCHITECTURE.md「ユーザーによる中断」。
permissionMode: 'acceptEdits'でも実測でWriteが canUseTool に落ちてきた(AskUserQuestionも当然来る)。「編集系は自動許可」を鵜呑みにできない。- codiva 実装方針: 自律実行のため、codiva の
canUseToolは Write/Edit/Bash 等のルーチンツールを自動 allow し、AskUserQuestion(=ユーザーへの質問)と、将来的に設定する「要確認ツール集合」のみ UI に上げる。allowedToolsで明示許可する手もあるが、canUseTool 集中管理の方が状態導出と一元化できる。
sdkSessionId: 最初のsystem/init.session_id(以降変わらない)。todos: TaskCreate/TaskUpdate から構築(上記1)。progress = {done, total}。status:system/init→running、AskUserQuestion(canUseTool)→awaiting_input、その他 canUseTool→awaiting_permission、result/success→completed、result/error_*→failed(ただしterminal_reasonがaborted_streaming=ユーザーの中断 / 一時的なapi_errorならinterrupted、認証切れ・レート制限はそれぞれの状態へ)。- ログ:
assistantの text ブロック、tool_use の1行要約、result.result。
Claude Code のステータスラインと同じ「プラン種別 + リミットまでの使用状況」を出すために、
accountInfo() と実験的な usage 要求を probe(何も送らない streaming-input セッション)から
読んだ実測結果。推論は走らないのでトークン消費はゼロ(実測 1〜2 秒)。
{ "email": "…", "organization": "THE PHAGE",
"subscriptionType": "Claude Team", "apiProvider": "firstParty" }subscriptionTypeは 表示用の文字列('Claude Team'。usage 側は'team'と小文字なのでcore/account.tsのnormalizePlanNameで綴りを揃える)。SDK 由来の表示文字列なので翻訳しない。apiProviderが'firstParty'のときだけ claude.ai のサブスク制限が効く(Bedrock / Vertex / API キーには無い)。- idle な probe でも即答する(init ハンドシェイクだけで完結。
supportedModels()と同じ)。
{ "session": { "total_cost_usd": 0, "model_usage": {} },
"subscription_type": "team",
"rate_limits_available": true,
"rate_limits": null, // ← available=true でも null
"behaviors": { "day": { … }, "week": { … } } }rate_limits_available: trueでもrate_limits: nullがあり得る(Team アカウントで3回連続再現)。 「available」を根拠に枠を描いてはいけない。core/usage.tsは null を「枠なし」として扱い、rate_limit_event側の情報だけで表示する。- 枠が来る場合の形は SDK 型宣言のとおり
{ utilization: number|null, resets_at: ISO文字列|null }。resets_atは ISO 8601 文字列で、rate_limit_eventのresetsAt(Unix 秒)とは単位が違う。 behaviorsはローカルの transcript スキャン由来の統計(/usageダイアログと同じ)。codiva は使わない。- ターンを回した直後のセッションでは
ProcessTransport is not ready for writingで失敗したので、 実セッションに相乗りせず専用 probe で読む(utils/usage-probe.ts)。
{ "type": "rate_limit_event",
"rate_limit_info": { "status": "allowed", "resetsAt": 1785414600, "rateLimitType": "five_hour",
"overageStatus": "allowed", "overageResetsAt": 1785542400, "isUsingOverage": false } }system/initの直後(assistant 出力より前)に届く = ターン開始ごとに最新化される。five_hourにはutilizationが付かないことがある(このアカウントでは付かなかった。overage枠にはutilization: 3.49が付いた)。つまり「% 使用」は常に取れるとは限らないので、 UI はutilizationが無い枠にゲージを描かず残り時間だけ出す(0% と誤読させない)。- idle なセッション(何も送っていない probe)には届かない。だからポーリングと併用する。
claude.ai の「Help improve our AI models」(モデル学習へのデータ提供。設定画面は https://claude.ai/settings/data-privacy-controls)の ON/OFF を codiva から判定するための実測。 Anthropic 内部の呼び名は grove。
| 出所 | 形 | 備考 |
|---|---|---|
~/.claude.json の groveConfigCache[<accountUuid>] |
{ grove_enabled: boolean | null, timestamp: number } |
Claude Code が取得時に書くキャッシュ。未取得のアカウントには存在しない(実測: 本開発機では未生成だった)。accountUuid は同ファイルの oauthAccount.accountUuid |
GET https://api.anthropic.com/api/claude_code_grove |
{ grove_enabled, domain_excluded, notice_is_grace_period, notice_reminder_frequency } |
Claude Code の /privacy-settings が使う非公開エンドポイント。更新は PATCH /api/oauth/account/settings(codiva は読み取りのみ) |
grove_enabledはtrue/falseのほかnullがありうる(ポリシーで選択肢が無いアカウント)。nullは「不明」として扱い、警告を出さない。
-
認証は Claude Code の OAuth アクセストークン(
Authorization: Bearer <token>)。置き場所は macOS が Keychain の generic passwordClaude Code-credentials(security find-generic-password -s 'Claude Code-credentials' -wで JSON が取れる。中身は{ claudeAiOauth: { accessToken, … } })、 それ以外は~/.claude/.credentials.json。 -
User-Agent が
claude-cliで始まらないと 403permission_error(実測)。User-Agent 結果 claude-cli/2.1.220 (external, cli)200 claude-cliのみ /claude-cli/0.0.0 (external, cli)200 codiva/0.2.9/curl/8.0/ 未指定403 anthropic-betaヘッダは不要(有無で結果は変わらない)。 -
非公開 API なのでいつ壊れてもよい前提で実装する: 失敗・403・タイムアウトはすべて
'unknown'に丸め、警告を出さない(誤警告を出さない側に倒す)。実装はutils/privacy.ts、 判定は純粋なcore/privacy.ts。
- 本開発機のアカウントでは
nullだった。Claude Code の設定ダイアログはdomainExcludedが真のとき トグル操作(tab)を無効化する実装になっており、「ドメイン側で対象外」を意味すると読める。 - 意味が確定するまで codiva は
domain_excluded === trueを'unknown'に倒す(対象外なのに 「学習に使われます」と警告するのを避ける)。非 null の実データが採れたらここに追記して判定を見直す。
- claude.ai の Web 側で設定を変えても
~/.claude.jsonのgroveConfigCacheは書き換わらない。 キャッシュの'on'を信用すると、ユーザーが警告に従って OFF にしたあとも最大 7 日間 警告が出続ける。'off'はキャッシュを採用、'on'は必ず API で確認(確認できなければ据え置き)。
一覧の #<n> バッジが「出るときと出ないときがある」原因の調査結果。
-
gh pr view <branch> --json number,url,state,mergeable,isDraftは REST ではなく GraphQL API を叩く。GraphQL のレート制限は 5000 ポイント/時で、REST(core, 5000/h)とは別枠。 -
枯れると exit code 1・stderr に次を出す(stdout は空):
GraphQL: API rate limit already exceeded for user ID <id>.残量は
gh api rate_limit --jq .resources.graphqlで確認できる({"limit":5000,"used":5001,"remaining":0,...})。 -
このクォータは同じアカウントの全ツールで共有で、codiva のポーリングだけでなく セッション内の Claude が実行する
gh(PR 作成・レビュー・CI 確認…)も同じ枠を消費する。 並列セッションを回していると現実に枯れる。
| 状況 | exit | 文言(stderr) |
|---|---|---|
| PR が無い | 1 | no pull requests found for branch "<branch>" |
| レート制限 | 1 | GraphQL: API rate limit already exceeded … |
| 未認証 | 4 | To get started with GitHub CLI, please run: gh auth login |
gh 未導入 |
— | spawn 時 ENOENT |
→ utils/pr.ts は例外をこの文言で分類し、found / absent / unavailable(reason) を返す。
unavailable を absent に丸めると、レート制限や一時的な通信断のたびに setPr(undefined) が走って
表示中の #<n> が消え、次のポーリングで復活する(=「GitHub ステータスが時々出ない」の正体)。
statusCheckRollupは PR メタ情報と同じ 1 回のpr viewで取れる。auto-ready 用に別途--json statusCheckRollupを投げていたのをやめ、1 セッション 1 サイクル =gh1 回にした (HEAD ブランチで見つからなかったときだけ記録ブランチへフォールバックの 2 回目)。rate_limit/auth/cliを検知したら 5 分ポーリングを止める(PR_LOOKUP_BACKOFF_MS)。 20 秒間隔で叩き続けても回復しないうえ、ユーザーの他ツールの枠まで削る。mergedになった PR とarchivedセッションは以後問い合わせない。サイクルの多重実行も禁止 (ghが 20 秒より遅いと重なる)。
素朴な実装(全セッション × 20 秒)だと 10 セッションで 1800 リクエスト/時。GraphQL の 5000/時を
Claude セッション自身の gh と分け合うので、これだけで枯れる。3 段で切り離した。
- PR の識別と状態を分けて、識別はキャッシュし切る(
PrRef/PrStatus)。番号・URL は ブランチに対して不変なのでstate.jsonに載せ、再起動直後から#<n>を出す(状態のグリフは 最初のポーリングで付く)。状態だけ取れないときも番号は消さない。 - セッションごとの陳腐化で叩くかを決める(
core/pr-refresh.ts)。20 秒 tick はスケジューラで、 実際のリクエストはチェック実行中(20 秒)/ マージ可否計算中(60 秒)/ 落ち着いた PR(180 秒)を 超えたものだけ。mergedPR とarchivedセッションは永久に対象外。落ち着いた一覧なら 9 tick に 1 回しか叩かない。 - 同時に 3 件以上あるときは
gh pr list1 回に畳む(utils/pr.tsのlookupPrs)。--json headRefName,…で全 PR を取り、各セッションの HEAD ブランチ(ローカルのgit rev-parse、API 不要)で突き合わせる。10 セッションでもリクエストは 1 回。 1〜2 件のときはpr viewの方が安い(list は全件のチェック rollup を運ぶ)ので閾値を置く。--limitは件数 × 3(30〜100)。list は新しい順なので、セッションの PR(=最近作ったもの)は 必ず先頭側に入る。ページが埋まっていた(=切り詰められた)ときだけ、既知の PR が 見つからなかったセッションをpr viewで確認する(「消えた」と誤判定しないため)。
pr list は JSON 配列で、各要素は pr view と同じフィールド + headRefName。
[{"headRefName":"fix/pr-status-visibility","number":78,"url":"https://github.com/o/r/pull/78",
"state":"OPEN","mergeable":"MERGEABLE","isDraft":false,
"statusCheckRollup":[{"__typename":"CheckRun","name":"check","status":"COMPLETED",
"conclusion":"SUCCESS","startedAt":"…","completedAt":"…",
"workflowName":"CI","detailsUrl":"…"}]}]- rollup の要素は
__typename: 'CheckRun'のときstateを持たない(status+conclusion)。 レガシーな commit status はStatusContextでstateを持つ。toChecksStateが両方を読むのは このため(片方しか見ないと「チェック無し」に見える)。 - 新しい順に返る(#78 → #77 の順で観測)。セッションの PR は最近作ったものなので、
--limitで切り詰められても取りこぼさない。 state: 'MERGED'のときmergeableはUNKNOWNに落ちる(#77 で観測)。stateを優先するtoMergeStatusの根拠。state: 'CLOSED'(マージせず閉じた)でもmergeableは閉じる直前の値(MERGEABLE/CONFLICTING)を返しうる。ここでもstateが勝つので、終わった PR に緑の✓や 「立て直せ」の✗が付かない(closedは専用グリフ⊘)。
実 API で lookupPrs を通した結果(1 回の pr list + セッションごとのローカル git rev-parse):
| ケース | 入力 | 結果 |
|---|---|---|
| HEAD にある PR | cwd=この worktree, branch=codiva/github |
found #78 mergeable / passing |
| PR 無し | cwd=main チェックアウト, branch=存在しないブランチ | absent(unavailable ではない) |
| マージ済み | branch=codiva/task-11 |
found #77 merged(以後ポーリングしない) |
codiva の 2 つ目の provider。@openai/codex-sdk を npm 依存に足さず、ユーザーがインストールした
codex を起動する(gh / git と同じ扱い。SDK を入れると Codex を使わないユーザーにも
プラットフォーム別バイナリが降る)。実装は core/codex-*.ts + utils/codex.ts、設計の理由は
ARCHITECTURE.md「エージェント抽象」6 節。
utils/codex.ts の codexArgs()。シェルは使わず引数配列(git / gh と同じ規約)。
codex exec --json --skip-git-repo-check
--sandbox <read-only|workspace-write|danger-full-access>
-c approval_policy="never"
[-c sandbox_workspace_write.network_access=<bool>] # workspace-write のときだけ
[--model <slug>] [-c model_reasoning_effort="<effort>"]
[resume <thread_id>]
-- <prompt>
--jsonと--experimental-jsonは同じフラグ(clap の alias。--helpに出るのは--json)。- 指示文の前に
--を必ず置く。ユーザーの入力は任意の文字列なので、-で始まると clap がオプションとして解釈して起動そのものが落ちる(実測:codex exec --json "--fix the thing"→error: unexpected argument '--fix the thing' found)。--以降は必ず値として扱われ、resume <id> -- <prompt>の形でも同じに効く(実測で確認済み)。 resumeはサブコマンド(codex exec [OPTIONS] resume <id> <prompt>)だが、オプションは global なので前に置ける。プロンプトは必ず最後の位置引数。resumeでもthread.startedは出る(実測 0.148.0。同じthread_idが返る)。 1 ターン目の頭にだけ何かを前置したいとき(systemPrompt / エージェント切替の引き継ぎ)は、 このイベントを「CLI がプロンプトを受け取った」合図として使ってよい。- プロンプトは argv で渡るので実サイズに上限がある。Linux の
execveは引数 1 本あたりMAX_ARG_STRLEN(32 ページ = 131,072 バイト)を超えるとE2BIGで失敗する (ulimitでは上げられないカーネル定数。macOS は per-arg 上限が無く合計 1 MiB なので 手元では再現しない)。日本語は 1 文字 3 バイトなので「文字数」で予算を組むと 3 倍 外す。systemPrompt とエージェント切替の引き継ぎを前置するときは、UTF-8 バイトで 予算を持つこと(MAX_HANDOFF_TRANSCRIPT_BYTES)。 - stdin は
'ignore'で開く。プロンプトを引数で渡していても、パイプされた stdin があると codex は追加入力として読もうとしてReading additional input from stdin...と出したままブロックする。 - stdout = JSONL、stderr = ログ(
2026-…Z ERROR codex_login::auth::manager: …のような tracing 行)。 失敗の診断は stderr の末尾(codiva は 4000 文字まで保持)。 - 終了コードは実質 0 か 1 だけ(正常終了 / 失敗)。細かい理由はコードから区別できないので、
分類は文言(
turn.failedの message か stderr)で行う。
出所は Codex の codex-rs/exec/src/exec_events.rs。codiva の型は core/codex-events.ts。
type |
運ぶもの | codiva での扱い |
|---|---|---|
thread.started |
thread_id |
resume の鍵。resume した回も同じ id が再度届く(session_started は no-op) |
turn.started |
なし | assistant_message(running へ戻す区切り) |
item.started |
item |
tool_use 相当(コマンド・パッチ・MCP・web 検索・TODO) |
item.updated |
item |
実測では todo_list にしか来ない |
item.completed |
item |
assistant テキスト / tool_result 相当 |
turn.completed |
usage? |
ターン完了の唯一の信号。トークン数のみで USD は無い |
turn.failed |
error.message |
ターン失敗の唯一の信号 |
error |
message |
終了ではない(下記)。system 行 1 行にとどめる |
item の type と状態:
item.type |
フィールド | status の enum |
|---|---|---|
agent_message |
text |
(なし) |
reasoning |
text(推論の要約) |
(なし) |
command_execution |
command / aggregated_output / exit_code(実行中は明示的に null) |
in_progress / completed / failed / declined |
file_change |
changes[] = {path, kind: add|delete|update} |
in_progress / completed / failed |
mcp_tool_call |
server / tool |
in_progress / completed / failed |
web_search |
query |
(なし) |
todo_list |
items[] = {text, completed}(真偽値だけ。in_progress が無い) |
(なし) |
error |
message |
(なし) |
item.idは CLI が振る通し番号(item_0,item_1…)で、モデル側の id ではない。 started ↔ completed の突き合わせに使う。- コマンド実行は started → completed の 2 段で、途中経過(
item.updated)は来ない。 進行中の出力は覗けず、aggregated_outputは completed でまとめて届く。 そのためitem.updatedを読む価値があるのはtodo_list(チェックが 1 つずつ埋まる)だけ。 todo_listはitem.completedでも直前のitem.updatedと同じリストを繰り返す (codiva はログ行を増やさない)。CodexUsage.cache_write_input_tokensは実測で省略されることがある(CLI 側にserde(default))。
接続が切れると、次のような行が stdout の JSONL として流れ続ける(__fixtures__/codex-failure.jsonl):
{"type":"error","message":"Reconnecting... 1/5 (stream disconnected before completion: …)"}
…
{"type":"error","message":"Reconnecting... 5/5 (stream disconnected before completion: …)"}
{"type":"error","message":"stream disconnected before completion: …"}
{"type":"turn.failed","error":{"message":"stream disconnected before completion: …"}}- 5 回まで自動で粘り、成功すればそのままターンが続く。
errorを素直に失敗扱いにすると 自力で回復するセッションが赤くなる。ターンの終わりを決めてよいのはturn.failed(と、 終端イベント無しの非ゼロ終了)だけ。 - 諦めたときは同じ文言が
errorとturn.failedの 2 回届く。codiva はerrorをcoalesceKey: 'Reconnecting'の system 行に畳んで 1 行にまとめ、分類はturn.failedで行う。 - 認証切れも同じ形(
error→turn.failedが同文言)で来る(__fixtures__/codex-auth-error.jsonl):Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.→classifyCodexErrorがauthに分類する。
codex exec の JSON モードは、コマンド実行 / パッチ適用 / MCP のいずれの承認要求も
CLI 内部で自動 reject し、JSONL には何も出さない(codex-rs/exec/src/lib.rs の
handle_server_request)。したがって codiva 側に許可要求を UI へ上げる経路は原理的に無く、
CODEX_CAPABILITIES.permissions = false。安全弁はサンドボックス(--sandbox = 設定
codexSandbox)だけになる。workspace-write の既定はネットワーク遮断なので、
-c sandbox_workspace_write.network_access=true(設定 codexNetworkAccess、既定 true)を
明示しないと npm install / gh が失敗して大半の作業が完了しない。
1 ターン = 1 プロセスなので、spawnCodex の後始末がそのままセッションの寿命に効く。
codexは Rust バイナリで、Rust は起動時にSIGPIPEを無視する。 stdout を読むのを やめてもパイプが壊れて死んでくれない(EPIPEの書き込みエラーになるだけ)ので、 捨てた run は明示的にkill()する。しないとcodex execが worktree を触ったまま 残る(セッションはfailed表示なのに裏で作業が続く)。SIGTERMだけに賭けない。 無視されると stdout が閉じずfor awaitが返らないので、 ターンが二度と進まない。KILL_ESCALATE_MS後にSIGKILLへ上げる(タイマーはunref()して TUI の終了を妨げない)。'close'は全 stdio が閉じてからなので、'exit'もフォールバックにする。 どちらか早い方で終了コードを確定させないと、stderr を掴んだままの子がいるだけで ターンが永久に止まる。child.stderrに'error'リスナを付ける。 listener の無い'error'は EventEmitter が throw し、TUI ではプロセス死になる(stdout は for-await 中の 非同期イテレータが面倒を見るので、素の emitter はここだけ)。- 1 行の長さに上限を置く。
command_executionはaggregated_outputを丸ごと 1 行で 運ぶので、長いビルドの出力が数十 MB の 1 行になる。溜め切ってからJSON.parseすると 同じものが 2 部ヒープに載るため、MAX_LINE_CHARSを超えた行は捨てて次の改行から復帰する (枠切りは純粋なcreateJsonlSplitter(core/codex-events.ts)に切り出してテストしてある)。 - stderr のバッファは必ず上限で切る。 1 チャンクは 64KB になりうるので
「短ければ足す」だけだとその 1 回で大きく超え、それが
turn_stopped.detail→state.errorに載る(ログ行と違ってstate.errorはクリップされない)。
- ローカルのモデルカタログを JSON で吐くだけで推論は走らない(トークンもコストもゼロ)。
Claude 側の
Query.supportedModels()と同じ「モデル名を直書きしない」ための出所。 - 出力は
base_instructions(モデルごとのシステムプロンプト全文)を含むため実測 ~280KB。execFileの既定maxBufferでは足りないので明示的に広げる(utils/codex.tsは 8MB)。 - 読むのは
models[].slug/display_name/description/visibilityだけ。visibility !== 'list'(hide等)は内部用なので選択肢に出さない。 - Codex の slug は
gpt-5.6-solのような実 IDで、Claude のsonnet/opusに相当する エイリアスが無い。したがって取得に失敗したときのフォールバックは**「デフォルト」1 行だけ** (DEFAULT_ONLY_MODEL_OPTIONS)。ここに推測でモデル名を並べると必ず陳腐化する。
Claude の system/init に当たるもの(実際に動いているモデル名)が Codex には無い。
実バイナリで確かめた結果は次のとおり:
| 調べた先 | 結果 |
|---|---|
codex exec --json の stdout |
モデル名を一切運ばない(thread.started は thread_id だけ、turn.started は空、turn.completed は usage だけ) |
codex debug models |
既定を示す印が無い(priority はあるが「CLI の既定」とは別物) |
codex doctor --json |
config.load.details.model は "<default>" としか答えない |
rollout の session_meta |
model_provider("openai")だけで slug は無い |
rollout の turn_context |
model: "gpt-5.6-sol" ← ここだけが解決済み slug を持つ |
そのため --model を明示していないセッション(= CLI の既定に任せている)のモデル欄が
一覧で空のままだった。--model を渡した場合は Session.consume() が渡した値をそのまま
表示する(#109)ので、埋まらないのは既定で動いているときだけ。
対策は rollout を読むこと(core/codex-rollout.ts = 純粋な抽出 / utils/codex.ts の
resolveCodexRolloutModel = 探索と読み出し):
-
置き場所は
$CODEX_HOME(既定~/.codex)のsessions/<年>/<月>/<日>/rollout-<時刻>-<thread_id>.jsonl。ファイル名の末尾がthread.startedのthread_idなので、時刻部分(ローカル時刻)は当てにせず id で突き合わせる。 -
1 ファイルは実測平均 1MB 超(
session_metaがbase_instructions全文を 1 行で運ぶ)。turn_contextはその数行あとに来るので先頭 512KB だけ読む。 -
turn_contextは「ターン開始時」ではなく数秒遅れて書かれる。実バイナリを走らせて 計測した順序(0.147.0、Reply with just the word OK.の 1 ターン):時刻( thread.started基準)起きること +0.0s stdout に thread.started~+0.2s rollout ファイルが作られる( session_meta=base_instructions全文が先に書かれる)~+3.0s turn_context(モデル slug)が書かれる(入力response_itemとworld_stateのあと)+7.2s turn.completed当初は「200ms × 6 回 = 最長 1.2 秒」(しかもファイル探索の上限で実質 0.6 秒)で諦めていたため、 既定モデルで動く Codex セッションのモデル欄はほぼ必ず空のままだった(#121 で入れた仕組みが 実際には一度も答えを拾えていなかった)。読み直しの猶予は実測の数倍取り(既定 20 秒・間隔は 指数バックオフ)、探索回数の上限は置かない(猶予そのものが上限。ファイルが作られる前に 張った問い合わせを、出現を待てずに諦めさせないため)。 一度見つけたパスは使い回す(追記されるだけで移動しないので、読み直しは同じ 1 ファイルの 読み直しで済み、走査を待ち時間のたびに繰り返さない)。
-
本命は「ターンが終わったあとの引き直し」。上の遅れがあるため、ターン中に張った問い合わせは 短いターンだと空振りしうる。一方ターンが終わった時点なら必ず書かれているので、終端イベントを 流したあとに短い猶予(1.5 秒)で聞き直す(
core/codex-adapter.ts)。ここが無いと 1 ターンで idle になったセッション(= 一度指示して結果を待つ、いちばん普通の使い方)のモデル欄が永久に 空のままになる。ターン中の問い合わせは誰も await しないので猶予を長く取れる代わりに、 終了後の引き直しだけは待つので短くする(取れない環境でターンの合間を引き止めない)。 -
探索を日数で打ち切らない。
codex exec resumeはスレッドを開始した日の rollout へ 追記し続ける一方、codiva のセッションはstate.jsonに無期限で残って何日も後に復元される。 「新しい日付から N 個まで」で切ると、その間に別セッションを作っただけで対象が範囲から外れ、 モデル欄が二度と埋まらない(暦日ではなく存在する日付ディレクトリ数なので、使うほど早く 当たる)。代わりに新しい順に見て最初に当たった時点で止めることで実費を抑え、全日付を 舐めるのは「本当に無い」ときだけにする(その繰り返しは猶予とバックオフ、および 「空振りしてよい回数」の予算で抑える)。 -
カタログ先頭(priority 1)を既定とみなす手は採らない。 実測では確かに priority 1 =
gpt-5.6-sol= 実際の既定だったが、~/.codex/config.tomlでmodelを 設定しているユーザーには嘘のモデル名を出すことになる。 -
読めなければ黙って諦める(モデル欄が空のままになるだけ)。
--ephemeral実行や レイアウト変更で取れなくてもセッションは壊さない。
報告は専用の中立イベント model_resolved(core/agent-events.ts)で行う。
session_started / assistant_message にも model は載るが、あちらは「ターンが動いている」
区切りでもあり status を running へ戻す。問い合わせの答えはターンが終わったあとに
届くことがあるので、あれに相乗りさせると完了したセッションが running に巻き戻る。
Codex の JSONL には上流に採取済みのフィクスチャが無く、実アカウントで走らせると
(a)課金・レート制限を食う(b)モデルの気分で出るイベントが変わり command_execution の
失敗や turn.failed を狙って出せない。そこで codex バイナリはそのまま、モデル側だけを
差し替える方法で採取した。src/core/__fixtures__/codex-*.jsonl はこれで採った実出力。
-
ローカルに Responses API 互換のモックサーバを立てる(
POST /v1/responsesに SSE で 決め打ちの応答を返すだけの小さな HTTP サーバ)。モデルの出力を固定できるので、 シェル実行・パッチ適用・TODO 更新・コマンド失敗・ストリーム切断を狙って再現できる (切断は応答の途中でソケットを閉じるだけでよい)。 -
codexをそのプロバイダに向ける。-cは~/.codex/config.tomlの値を上書きするので、 設定ファイルを汚さずに 1 回の実行だけ差し替えられる:codex exec --json --skip-git-repo-check --sandbox workspace-write \ -c model_provider="mockprov" \ -c 'model_providers.mockprov={name="mock",base_url="http://127.0.0.1:<port>/v1",wire_api="responses",requires_openai_auth=false}' \ -c approval_policy='"never"' \ "<prompt>"
-c <key>=<value>の value は TOML として解釈される(パースできなければ生文字列)。 だから文字列は-c approval_policy='"never"'のようにクォートを 2 重にする。 -
stdout をそのまま
.jsonlとして保存し、絶対パス等をサニタイズしてからsrc/core/__fixtures__/へ昇格させる(Claude 側の spike と同じ運用。 testing.md)。
この手法は「CLI の出力形式に依存したパーサを、CLI 本体を差し替えずにテストしたい」場面で そのまま再利用できる。上流にフィクスチャが無い provider を足すときの既定手段にする。
codiva の 3 つ目の provider(issue #107)。Claude / Codex と同じく CLI は同梱せず、
ユーザーがインストールした grok を起動する(導入は curl -fsSL https://x.ai/cli/install.sh | bash、
認証は grok login)。実装は core/grok-*.ts + utils/grok.ts。
grok には 2 つの非対話モードがある。
| モード | 形 | 許可・質問 |
|---|---|---|
grok -p <prompt> --output-format streaming-json |
1 ターン = 1 プロセス、NDJSON を一方向に吐く | 上げられない(読み取り専用) |
grok agent stdio |
JSON-RPC 2.0(ACP = Agent Client Protocol)の双方向ストリーム | session/request_permission / _x.ai/ask_user_question が飛んでくる |
Codex に倣えば前者だが、それでは Codex と同じく permissions: false に落ちる。後者は
エージェント側からクライアントへ要求が飛ぶので、codiva の許可ダイアログ・質問ダイアログを
そのまま繋げる。よって codiva は ACP を選んでいる(GROK_CAPABILITIES.permissions = true)。
utils/grok.ts の grokAgentArgs()。シェルは使わず引数配列。
grok agent [--reasoning-effort <effort>] --no-leader stdio
- フラグは
agentとstdioの間に置く。grok agent stdio --reasoning-effort highはerror: unexpected argumentで落ちる(実測)。 --no-leaderで共有 leader プロセス(~/.grok/leader.sock)に相乗りしない。worktree ごとに 独立したセッションを持つ codiva では、相乗りは他クライアントの状態変化を呼び込むだけ。- stdin へ JSON-RPC を 1 行 1 通で書き、stdout から同じ形で読む(stdio は双方向なので
Codex のように
stdin: 'ignore'にはできない)。stderr は tracing ログ(診断用に 4000 文字保持)。
| メソッド | 形 | 備考 |
|---|---|---|
initialize |
要求 | 最初に必ず 1 回。応答の _meta.modelState がモデルカタログの出所 |
session/new |
要求 | {cwd, mcpServers:[], _meta:{rules, modelId}} → {sessionId, models} |
session/resume |
要求 | {sessionId, cwd, mcpServers:[]}。文脈だけ戻す |
session/load |
要求 | resume と違い会話を通知として全部流し直す。codiva は使わない |
session/prompt |
要求 | 1 往復 = 1 ターン。応答の stopReason が終わりを告げる |
session/set_model |
要求 | {sessionId, modelId}。走っているセッションに効く |
session/cancel |
通知 | id を付けると -32601 Method not found(実測)。付けずに送ると走っているターンが stopReason: "cancelled" で返る |
stopReason は end_turn / cancelled / max_tokens / max_turn_requests / refusal。
codiva は end_turn を完了、cancelled を(自分で止めたなら静かに)中断、残りは
「理由を 1 行残して完了」にしている(失敗ではないので追加指示をそのまま送れる)。
// 許可(ACP 標準)
{"method":"session/request_permission","params":{
"toolCall":{"toolCallId":"call_1","rawInput":{"command":"rm -rf build"},
"_meta":{"x.ai/tool":{"name":"run_terminal_command","kind":"execute"}}},
"options":[{"optionId":"allow-once","kind":"allow_once"},
{"optionId":"reject-once","kind":"reject_once"}]}}
// 応答: {"outcome":{"outcome":"selected","optionId":"allow-once"}} / {"outcome":{"outcome":"cancelled"}}
// 質問(xAI 拡張。ACP の ext メソッドは wire 上で `_` が前置される)
{"method":"_x.ai/ask_user_question","params":{
"toolCallId":"call_q","mode":"default",
"questions":[{"question":"Which approach?","multiSelect":false,
"options":[{"label":"Rewrite","description":"Start over"}]}]}}
// 応答: {"outcome":"accepted","answers":{"Which approach?":["Rewrite"]}} / {"outcome":"cancelled"}optionIdは固定文字列ではない。編集用・コマンド用・MCP 用で別の id が来るので、kind(allow_once/allow_always/reject_once/reject_always)で選ぶ。answersは「質問文」をキーにしたマップで、値は選択ラベルの配列(後方互換で単一文字列も可)。outcomeを落とすとツールがClient returned an invalid response to user question: missing field \outcome`で失敗する(実測)。 拒否は **cancelled**(declinedという値は無い。実測でエラーメッセージが受理値を列挙する:expected one of `accepted`, `chat_about_this`, …`)。- 知らない要求にも 必ず応答を返す(codiva は
-32601を返す)。放置するとエージェントは 待ち続け、ターンが終わらない。 - なお、許可を求めるかどうかは Grok 自身のポリシーが決める。
echo hiや通常のファイル編集は 自分で自動承認し、rm -rfのような不可逆な操作でだけ要求が上がってくる(実測)。
| method | 形 | 例 |
|---|---|---|
session/update |
ACP 標準(camelCase) | agent_message_chunk / agent_thought_chunk / tool_call / tool_call_update / plan / available_commands_update / session_info_update |
_x.ai/session_notification |
xAI 拡張(同じ update 封筒だが snake_case) |
turn_completed / response_completed / retry_state / pending_interaction / interaction_resolved / last_turn_summary / tool_call_delta_chunk / session_summary_generated |
core/grok-events.ts の grokUpdateOf() が両方から update を取り出し、core/grok-parse.ts が
AgentEvent へ写す。codiva が解釈するのは
agent_message_chunk / agent_thought_chunk / tool_call / tool_call_update / plan / retry_state の
6 種だけで、残りは捨てる(CLI が種別を足しても落ちない)。
- 本文の区切りが無い。
agent_message_chunkの細切れしか来ず「1 通終わった」という通知が 無いので、パーサ側で溜めてツール呼び出し・ターン終了で確定させる(createGrokParserが 状態を持つ唯一の理由。Claude / Codex の parse は状態を持たない)。 - TODO は
plan通知から作る。todo_writeのツール呼び出しも来るが同じ内容なので、 ログが 2 行に増えないよう握り潰す。 retry_stateは再試行の実況であって終了ではない(Codex の{"type":"error"}と同じ罠)。 ログはcoalesceKeyで 1 行に畳む。
retry_state は codiva の AgentStopCause に近い分類を自分で持っている:
{"sessionUpdate":"retry_state","type":"failed","error_type":"auth",
"message":"Unauthorized (401) from https://api.x.ai/v1/responses: unauthenticated: Incorrect API key provided."}core/grok-errors.ts は error_type を先に見て、auth / rate_limit / network はそのまま採用する。
ただし api は文言判定へ落とす — あれは 4xx も 5xx も一緒くたなので、そこで failed に丸めると
401 が「よく分からない失敗」に格下げされ、再ログインを促せなくなる。
ターン自体が落ちたときは session/prompt の応答が JSON-RPC エラー(-32603)になり、
error.data に同じ本文が入る。
Codex は rollout ファイルを読みに行くしかなかったが、Grok は 3 か所で報告する:
session/new/session/resumeの応答 →models.currentModelIdsession/promptの応答 →_meta.modelId_x.ai/models/update通知 →currentModelId
したがって grok-rollout.ts に相当するものは無いし、探索の予算・リトライも要らない。
一覧の「実際に動いているモデル」は最初のターンの前(セッション確立時)に埋まる。
grok models は人間向けのテキストしか出さない(--json は無い):
You are not authenticated.
Default model: grok-4.5
Available models:
* grok-4.5 (default)
しかも未認証でも終了コードは 0 なので、ログイン判定にも使えない。utils/grok.ts の
fetchGrokModelCatalog() は grok agent stdio を起こして initialize だけ送り、
_meta.modelState.availableModels(modelId / name / description / _meta.totalContextTokens /
reasoningEfforts)を読んだらプロセスを畳む。セッションを作らないので推論は走らない。
- 資格情報は
~/.grok/auth.json(GROK_HOMEで移せる)。XAI_API_KEYでも動く。 導入判定はgrok --version、ログイン判定はこの 2 つの有無で行う(トークンの有効性は見ない)。 - 未認証だと
session/newが{"code":-32000,"message":"Authentication required","data":"no auth method id provided"}を返す。 codiva はこれをauth(=needs_login)に写す。 authenticateを勝手に呼んではいけない。{"methodId":"grok.com","_meta":{"headless":true}}で 呼んでも応答が返らないままブラウザ OAuth の完了を待ち続ける(実測。60 秒で打ち切り)。 ログインは必ずユーザー操作(codiva の/login=grok login --device-auth)から始める。grok login --device-authは認証 URL とデバイスコードを stderr に、ANSI 付きで出す。 既存のcore/agent-login.ts(ANSI 除去 + URL/コード抽出)がそのまま使える。
worktree の共有 symlink 注意書き + .codiva/prompt.md(core/system-prompt.ts)は
_meta.rules で渡す。CLI 側で Grok 自身の system prompt の末尾に足されることを、
モックバックエンドに届いた実リクエストで確認した。_meta.systemPromptOverride も存在するが、
あれは Grok の system prompt を丸ごと置き換えるので使わない。
src/core/__fixtures__/grok-*.jsonl は 実 grok 1.0.0 の stdout をそのまま保存したもの
(ACP のフレーミング・ツール実行・許可要求・質問要求はすべて本物で、モデルの生成だけが差し替え)。
-
POST /v1/responses(OpenAI Responses API 互換)に SSE を返すモックサーバを立てる。 実測で必要だったフィールドはsequence_number(全イベント必須) と、response.completedのusage.input_tokens_details/output_tokens_details。欠けると CLI 側がFailed to deserialize ResponseStreamEvent from streamで落ちる。 -
API キー認証にして、xAI の API をモックへ向ける:
XAI_API_KEY=xai-mock GROK_CLI_CHAT_PROXY_BASE_URL=http://127.0.0.1:<port>/v1 \ grok agent --xai-api-base-url http://127.0.0.1:<port>/v1 stdio
モックは
GET /v1/modelsにも答える必要がある(起動時に必ず取りに来る)。 なおgrok-4.5は/v1/responses、grok-code-fast-2は/v1/chat_completionsを叩く (モデルによって wire API が違う。実測)。 -
クライアント役の小さなスクリプトで
initialize→session/new→session/promptを回し、 届いた許可要求・質問要求に応答しながら stdout を 1 行ずつ保存する。 -
絶対パス・ホスト名をサニタイズしてから
src/core/__fixtures__/へ昇格させる。
[ { "value": "default", "resolvedModel": "claude-opus-4-8[1m]", "displayName": "Default (recommended)", "description": "Opus 4.8 with 1M context · Best for everyday, complex tasks", "supportsEffort": true, "supportedEffortLevels": ["low","medium","high","xhigh","max"], "supportsAdaptiveThinking": true, "supportsFastMode": true, "supportsAutoMode": true }, { "value": "opus[1m]", "resolvedModel": "claude-opus-4-8[1m]", "displayName": "Opus" }, { "value": "claude-fable-5[1m]", "resolvedModel": "claude-fable-5", "displayName": "Fable" }, { "value": "sonnet", "resolvedModel": "claude-sonnet-5", "displayName": "Sonnet" }, { "value": "haiku", "resolvedModel": "claude-haiku-4-5-20251001", "displayName": "Haiku" } ]