Skip to content

v0.30.0

Choose a tag to compare

@Kewton Kewton released this 02 Sep 14:38
· 33 commits to main since this release
be84da7

[0.30.0] - 2026-09-02

Highlight: セッション画面にターミナルと切り替えられるチャット出力面を新設した(Epic #2192、子 Issue 12 件)。当初は履歴ペインを流用する設計だったが、実機で「History と同じでチャットに見えない」ことが判明したため決定を撤回し、専用のフラットな吹き出し列(ChatTranscript)へ差し替えている。従来は 100 文字クランプにより assistant 本文(中央値 2,478 文字)の約 4% しか読めていなかったものが全文表示になり、生成中の返答も末尾の吹き出しの中でその場に伸びるようになった。あわせて WebSocket push が届かない・履歴が二重化するといった realtime 層の不具合を複数修正している。

Added

  • feat(ui): スマホのチャット面にも送信直後の pending 行を出す (#2213): PC の split では #1121 の optimistic bubble が出るのにスマホでは出なかったのは、usePendingMessages が「送信関数と履歴配列を同じ所有者が持つこと」を前提にするのに対し、スマホでは履歴が terminal タブの中(MobileTerminalTab → MobileChatSurface)、composer はタブ内容の外(WorktreeDetailRefactored に docked)という兄弟配置だったため。画面スコープの context を 1 本足して、送信のほうを上へ流すことで解決した(src/contexts/WorktreeChatSendContext.tsx)。usePendingMessages は履歴のある MobileChatSurface に置いたまま sendOptimistic を登録し、docked composer(MobileComposer)がそれを MessageInput の既存 onOptimisticSend seam として受け取る。履歴を上へ持ち上げないので useSplitMessages を chat 表示中だけマウントする #2193 の性質は保たれ、terminal 面に戻すと登録が解除されて composer は従来の await-then-clear 経路に戻る。2 本目の送信経路もグローバルな送信バスも作っておらず、送信は POST /api/worktrees/:id/send 一本のまま・PC 側(TerminalSplitPaneContent)は無変更。失敗した送信の discard は本文を composer に戻す(PC と同じ復帰動作)。あわせて api-client.sendMessage の戻り値を { success: boolean } から作成済み ChatMessage に広げた(サーバは /send の 201 で以前から本体を返しており、クライアントの型が捨てていただけ。timestamp は useSplitMessages と同じ規則で Date に復元する)。MessageInput.onMessageSent も第 2 引数で作成済み行を渡せるようにしたが、既存の呼び出し元はすべて第 1 引数のみを宣言しており無変更で動く。#2106 のモバイル縦予算は不変(E2E 実測で 360x640・ストリップ閉のターミナルは 284px、下限 250px)。

  • docs(en): webapp-guide の英語版に出力面(ターミナル/チャット)の 2 節を追従し、ja-en parity ガードへ登録 (#2211): Epic #2192 が JA 版へ足した ## 出力面の切り替え / ## 出力面の既定 の 2 節が EN 版に無く(## 見出しが JA 18 / EN 16)、tests/unit/docs/ja-en-heading-parity.test.ts の PAIRS に webapp-guide が入っていなかったため誰も赤くならなかった。再発防止の本体は PAIRS への登録で、これを先に足すと expected 16 to be 18 で赤くなることを確認してから EN 側を書いた(逆順だとガードが空振りでも気づけない)。EN は PC の split ヘッダのトグル・モバイルの浮遊ピル・Cmd/Ctrl + Shift + M・?view=terminal|chat の deep-link・チャット面に出るもの・「Open terminal」バナーの 4 条件(isPagerActive / isSelectionListActive / isUnclassifiedActive / 読めない応答待ち)・既定の優先順位(その面の保存値 > サーバ設定 > 組み込み既定 terminal)を収録。用語は shipped UI の EN 文字列(Output surface for {split} / Default output surface)に合わせ、衝突していた検証ペイン表の列見出し Surface を JA 側の「画面」と同じ Screen へ改名した。

  • feat(history): antigravity の会話ストアから Markdown 本文と user 行を記録する (#2198): 本 Issue は「読める転写が存在するか」の go/no-go から始まり、go と判定した。agy 1.1.18 を実機で 3 ターン動かして採取したところ、会話本文は ~/.gemini/antigravity-cli/brain/<conversationId>/.system_generated/logs/transcript_full.jsonl に平文 JSONL(41 本 1,024 レコードを全数走査して malformed 0)で永続化されており、hook payload の transcriptPath が指すのもこのファイルだった。同じ会話は conversations/<conversationId>.db にも SQLite + 非公開 protobuf で二重に書かれているが、そちらは開くだけで -wal が生えるので採らない。hook の conversationId が転写ディレクトリ名そのものなので、codex のようなファイル走査も claude のような cwd スラッグも要らず、パスはポインタから計算できる(agy は hook を ~/.gemini/config を cwd にして起動するため、cwd からの推測はそもそも不可能 — 10/10 件で実測)。turn 境界は USER_EXPLICIT/USER_INPUT レコードで、agent の発話は MODEL/PLANNER_RESPONSE にしか無く(他の MODEL type はツール出力、SYSTEM は注入された文脈)、tool_calls は PLANNER_RESPONSE にだけ付いて args.toolAction に agy 自身の 1 行要約が入っている(439/439)。assistant 行は request_id = antigravity-turn:<conversationId>#<stepIndex>(step_index は会話内で一意だが連番でも昇順でもないので会話 id と対にする)、user 行は antigravity-prompt:<conversationId>#<stepIndex> を #2196 の recordUserTurn() で記録する(agy には UserPromptSubmit hook が無いため、転写が prompt の唯一の記録)。AgentSourceCapabilities.transcriptHistory に 'pull' を宣言し #2197 の PULL_TRANSCRIPT_READERS に 1 行足しただけで、gate 側にツール名分岐は増えていない。pointer 無し・ファイル無し・窓に prompt が無い・本文が空はすべて false を返して従来のスクレイプ経路へ fail-open し、何も throw しない。実測記録は docs/design/antigravity-transcript-reader.md、fixture は tests/fixtures/transcripts/antigravity/。

  • feat(history): 生成中のアシスタント本文をチャット面に逐次表示する(claude 転写 tail / opencode SSE) (#2199): チャット面は turn が終わるまで「応答中…」しか出せず、長い turn では数分間ターミナル面だけが埋まっていく状態だった。永続化しない WS イベント chat_turn_progress({worktreeId, cliToolId, instanceId, turnKey, body, partial, version, done:false})を追加し、確定は従来どおり message イベント(chat_messages の行)で行い、クライアントは同じ turnKey(claudeTurnRequestId / opencodeTurnRequestId =確定行の requestId そのもの)を持つ行が届いた時点で live バブルを捨てる。生成器は buildCurrentOutput と同じ共有ビルダー buildChatTurnProgress() / emitChatTurnProgress()(src/lib/session/current-output-builder.ts)1 本で、(1) 1 s 未満の間隔では source 自体を呼ばない(claude 側は 4 MiB の転写読み+パースなので「送信を絞る」だけでは費用が残る)、(2) turnKey と本文が同一なら送らない(opencode の SSE は境界フレームをバイト同一で再送する)、(3) version は (worktreeId, cliToolId, instanceId) ごとに単調増加で turn では戻さない(戻すと次 turn の 1 フレーム目が stale として捨てられる)、(4) 本文は 64 KiB を上限に末尾を残して切り、切ったことを partial で申告する。claude は poller の生成中 tick(sessionStatus === 'running')で readClaudeTurnProgress() が転写の末尾窓を読み、書き込み経路(#2121 / #2196)とは別経路で chat_messages には一切触れない。転写の窓(4 MiB)にプロンプト行が入らない巨大 turn は、読める範囲だけを partial: true で出す(黙って切らない)。opencode は message.part.updated 受信時に renderOpencodeTurn の結果を同じ throttle で push する。ChatSurface は live 領域に Markdown(remarkGfm + rehypeSanitize + rehypeHighlight、確定行と同じ経路)の in-progress バブルを高さ上限つきで描き、古い version を捨て、WS 切断中は従来の「応答中…」へフォールバックする(再接続時の replay はしない)。codex / antigravity はインジケータのまま(#2197 / #2198)。

  • feat(history): codex の応答を rollout JSONL から Markdown で履歴に書き、ターミナル入力も user 行として載せる (#2197): codex 自身の転写($CODEX_HOME/sessions/**/rollout-*-<session_id>.jsonl)を読む pull 型リーダー src/lib/hooks/sources/codex/{transcript,history}.ts を追加。assistant 行は TUI の罫線・折返し・スピナー残骸を含まない Markdown 本文を request_id='codex-turn:<turn_id>' で、user 行は #2196 の recordUserTurn() を再利用して codex-prompt:<UserMessage item id> で書くので、tmux attach から直接打った入力もチャット面に載る。実測(codex-cli 0.151.0 実機+保存済み 400 セッション、docs/design/codex-transcript-reader.md)で決めた 4 点: ①rollout は同じ会話を response_item(モデル向け)と event_msg/item_completed(TUI 向け)の 2 系統で持つが、注入された role:"user" レコードと本人入力を版に依らず見分けられるのは後者だけ(前者を判別できる content_item_kinds は 0.151.0 にしか無い)なので item 側だけを読む ②turn 境界は turn_id で、task_complete を見ていないターンは書かずに scraper へ返す(切れた本文が完成して見える行を残さないため) ③1 ターンに operator の入力が複数載ることがある(326 ターン中 23)ため user 行のキーは turn ではなく item id ④hook の session_id は rollout ファイル名の uuid と一致するので session pointer だけでファイルを特定でき、cwd 推測が要らない(同一 worktree の codex と codex-2 は cwd を共有し session を共有しないため、推測すると取り違える)。session pointer が無い・ファイルが無い・読めない・ターンが閉じていない場合は false を返して従来のスクレイプ経路へ fail-open する。あわせて lib/polling/structured-history-gate.ts のツール名分岐を AgentSourceCapabilities.transcriptHistory('pull'=claude/codex、'push'=opencode、null=その他、JSON 直列化可能な宣言値のみ)による分岐へ置換し、Markdown 本文に含めないレコード種別は unknownBlockTypes として数えてログに出す。対応下限は codex-cli 0.147.0(それ未満は item_completed が無く、turn が組み立たないので従来どおり scraper)。

  • feat(ui): チャット面に生成中インジケータ・「ターミナルで確認」導線・自動追従を追加 (#2194): #2193 が TerminalDisplay の位置に置いた HistoryPane を ChatSurface(新規 src/components/worktree/ChatSurface.tsx)で包み、チャット面を「履歴を眺めるだけ」から「作業できる面」にした。追加したのは 4 つ。(1) 生成中インジケータ: isRunning のとき「応答中…」(thinking なら「考え中…」)を出すが、最後のペアが pending のときは出さない — その場合は ConversationPairCard 自身の pending-indicator が既に同じことを言っているため。(2) 「ターミナルで確認」バナー: isPagerActive / isSelectionListActive / isUnclassifiedActive / 「応答待ちだが選択肢を読み取れない」のときだけ表示し、理由文言をフラグごとに変え、ボタン 1 つで onSurfaceModeChange('terminal') を呼ぶ。答えられるプロンプトでは出さない — composer 側の PromptPanel / MobilePromptSheet が #2193 以来チャットモードでもそのまま出ているので、バナーを出すと 1 つのダイアログに 2 つの導線が並ぶ。(3) 自動追従: 末尾にいる間は新着で末尾へ追従し、上へスクロール中は視点を動かさず「新着へ」チップを出す。追従の判定はペア数ではなくメッセージ数で行う — 既存ペアに合流するアシスタント応答(この面で最も多い新着)はペア数を増やさないため HistoryPane 自身の #1123 追従が発火しない。(4) 空状態: 履歴 0 件かつ未起動のとき「送信するとセッションが起動します」の 1 行(表示のみ。起動は従来どおり /send に任せ、新しい起動経路は作らない)。live 領域は仮想リストの外(shrink-0 の兄弟)に置いてある — リスト内の行は末尾から離れた瞬間に unmount されるため、生成中表示が最も必要な場面で消える。送信直後の pending 行は PC では既存 #1121 の usePendingMessages をそのまま経由し、ChatSurface は id 一致で重複させない保証(dedupeById)だけを足す。i18n は worktree.chatSurface.*(en / ja)。

  • feat(settings): 新しく開くセッションの出力面(ターミナル/チャット)の既定を設定できるようにする (#2201): #2193 が永続化したのは split ごと・スマホのタブごとの「最後にどうしていたか」であって「最初に何で開くか」ではなく、いつもチャットで作業する人は新しく開く出力面を毎回手で切り替える必要があった。app_settings.default_surface_mode にサーバ全体の既定値を持たせ、GET / PUT /api/settings/default-surface-mode で読み書きできるようにした(PUT は ?view= と localStorage に使うのと同じ isSurfaceMode() を境界に置き、不正値は 400。DB 側も読み出し時に同じガードを通すので、手編集や巻き戻したビルドが書いた xterm は「未設定」として読まれる)。More 画面の「設定」セクションにラジオ 2 択のカードを追加し、locales/{en,ja}/common.json に settings.defaultSurfaceMode.* を追加。ブラウザ側は surface-mode-config に localStorage ミラー(commandmate.settings.defaultSurfaceMode)つきのクライアントストアを持ち、readSurfaceMode() の優先順位は その面の保存値 > 設定値 > 組み込み既定 'terminal' = 手で切り替えた split は設定を保存しても戻らない。ミラーは同期読み出し(useState 初期化子から読まれるため)で、readSurfaceMode() がページごとに 1 回だけバックグラウンドで設定を取りに行って次に開く面に効かせる。既存の保存済みモードの移行と worktree 単位の既定はスコープ外。

  • feat(history): ターミナルで直接打った入力を claude の転写から user 行として履歴に載せる (#2196): これまで chat_messages の role='user' 行を書くのは sendUserMessage(/send・Timer)だけで、tmux attach での直接入力・UnsentComposerBar の Run・外部スクリプトの tmux send-keys は履歴にプロンプトが残らず、応答だけが groupMessagesIntoPairs の orphan ペアになっていた。新設の共通ヘルパ recordUserTurn(target, key, content, timestampMs)(src/lib/history/user-turn-recorder.ts、#2197 codex / #2198 antigravity が再利用するためツール非依存)が「①同じ key の行が既にあれば何もしない ②同 (worktree, tool, instance) の request_id IS NULL な user 行と正規化後の本文が一致し ±120 秒に収まっていればその行に key を付けて採用(/send 由来の行を正本にし、挿入しない)③どちらでもなければ挿入して broadcast」の 3 規則を担い、captureClaudeTranscriptTurn が assistant 行を書く前にこれを呼ぶ。どの type: "user" レコードが人間の入力かは 744 本の実転写を全数調査して決めた — 候補 4,943 件のうち 4,909 件が origin/promptSource を持ち、持たない 34 件は全て /compact と [Request interrupted by user…] でプロンプトは 1 件も無かったため、判定は deny list ではなく origin.kind === 'human' または promptSource ∈ {typed, queued} という積極的証拠にした(tool_result / isMeta の slash コマンド展開 / <task-notification> / compact 要約 / 中断マーカー / sdk 実行は全て除外)。冪等キーは claude-prompt:<promptUuid>(claude-turn:<promptUuid> と同じ uuid・別 prefix)で、AGENT_MARKDOWN_REQUEST_ID_PREFIXES には入れないため user 行は従来どおり verbatim 描画のままであり ConversationPairCard は無改造。user 行と assistant 行が同じミリ秒に並ぶと groupMessagesIntoPairs(timestamp のみで整列)の順序が DB の返却順次第になるため、user 行が書かれたターンに限り assistant 行を max(promptTimestamp, userTimestamp + 1) にずらす。

  • feat(realtime): チャット履歴を WebSocket で即時反映し、5 s ポーリングをフォールバックへ降格 (#2195): useSplitMessages が message / message_updated を購読して行を id で upsertするようにした(message_updated は置換、message は追加または同 id 置換。順序は timestamp の安定ソート、表示上限 historyDisplayLimit はサーバと同じターン単位の切り捨てで揃える)。採用するのは (worktreeId, cliToolId, instanceId) が自分の split と一致するフレームだけなので、同じツールの別インスタンス(#869/#1000)が同じ worktree ルームに流すターンは混ざらない。新しいイベント型もサーバ側の新しい共有状態も増やしていない(next dev はモジュールスコープの Map が bundle ごとに分かれる。#1736)— 足したのはbroadcast していなかった 2 つの producerで、実測で洗い出した: (1) sendUserMessage が user 行作成直後に broadcastMessage('message', …) を送るようになり(createMessage は呼び出し側のオブジェクトをそのまま返すので instanceId はプライマリ(=== cliToolId)へ解決してから載せる)、別デバイスで開いている画面が「自分が送っていないメッセージ」を次のポーリングまで見ない状態が解消した。(2) markPendingPromptsAsAnswered(エージェントが先へ進んだ時に pending プロンプト行を answered に掃く sweep)が onUpdated コールバックで刻印した行を返すようになり、response-checker の 2 つの呼び出し地点が message_updated を送る(DB 層は socket を import しない)。ポーリングは削除せず、WS 接続中は 15 s(useTerminalPanePolling の #1120 と同方針)、切断中は従来の 5 s のままで、切断・再接続のたびに 1 回の即時 refetch で取りこぼしを回収する。in-flight のポーリングが「まだ書かれていなかった行」を含まない応答で state を上書きして push 済みの行を消す競合も塞いだ。

  • feat(ui): セッションの出力面を ターミナル ⇄ チャット で切り替えられるようにした (#2193): 出力面のモードを表す SurfaceMode('terminal' | 'chat'、既定 'terminal')と境界検証 isSurfaceMode() を src/types/ui-state.ts に追加し、永続化と deep-link の唯一の境界として src/config/surface-mode-config.ts を新設。PC は split ごと(commandmate.worktree.surfaceMode-<worktreeId>-split-<splitIndex>)、スマホは worktree ごと(…-<worktreeId>-mobile)に localStorage へ保存し、read/write は private mode・quota・site data ブロックのすべてを try/catch で受けて 'terminal' に落とす。?view=terminal|chat の deep-link は isSurfaceMode() を通してから使い(不正値は無視)、URL 指定は localStorage より優先したうえでその値を書き戻すので共有リンクが次回訪問で戻らない。既存の ?pane= とは独立で併用でき(?pane=terminal&view=chat)、useWorktreeTabState の setPane / setSurfaceMode はどちらも既存クエリを引き継ぐ。UI は PC が TerminalSplitPane ヘッダのインスタンスセレクタ直後に置いたアイコン 2 個の segmented control(aria-pressed / aria-label / title、split ごとに独立)、スマホが MobileTerminalTab 先頭の 44px 以上・touch-manipulation の segmented control(terminal タブ内トグルで、タブは増やさない)。chat では TerminalDisplay の代わりに既存 HistoryPane を出力面に描き、split 内の折りたたみ History 列は隠して同じ会話を二重に出さない。チャット面はテーマ追従(HistoryPane の既存挙動のまま、light-on-dark の直書きなし)で、ターミナル面の常時ダーク島はそのまま。NavigationButtons 以降の入力系(MessageInput / PromptPanel / AutoYesToggle / MobilePromptSheet)は無変更なので、チャット表示中も送信・プロンプト応答・Auto-Yes・中断がそのまま動く。キーボードショートカット Mod+Shift+M を src/config/keyboard-shortcuts.ts(scope terminal)に登録して ? 一覧に載せ、ハンドラはフォーカスを持つ split([data-split-index])が応答し、split 外のテキスト入力中は MarkdownEditor の素の Ctrl+M(#1518)と二重発火しないよう見送る。

  • feat(ui): エージェントインスタンス行から、そのセッション宛の CLI コマンド 4 点を提示・コピーできるようにした (#2120): ロスター各行の CLI アイコンから send / wait / capture / respond をコピーできるパネルを追加。--instance の値は GET /api/worktrees/:id/resolve-target の解決結果をそのまま使い、GUI 側で優先順位を再実装しない(#1925 の「2 つの権威が 2 つの答えを出して 1 つの tmux セッション名を作る」事故を再発させないため。解決に失敗したときはコマンドを 1 行も出さずエラーを出す=推測で組み立てた宛先を clipboard に載せない)。バイナリ名は CM_LAUNCHED_BY 由来の単一の出典(src/lib/assistant/context-builder.ts の private 関数を src/lib/cli/command-reference.ts へ抽出し、assistant コンテキストと GUI が共有)で、ブラウザからは読めないため新設の GET /api/worktrees/:id/cli-reference がサーバ側の答え(binary と CM_PORT= 前置の要否)を返す。並列サーバ(start --issue N --auto-port)向けには --port ではなく CM_PORT=<port> を前置する —— send / wait / capture / respond に --port オプションは存在せず、貼り付けると commander が unknown option で落とすため。メッセージ本文はプレースホルダのまま(改行・引用符・日本語を含む本文のシェルクォートを GUI で誤ると静かに切り詰まる)。同じパネルに 3 点の注意書きを常設: (1) 送り先指定は --instance(--agent は roster 外のアドホック起動用で wait には存在しない)、(2) wait は --on-prompt human つきで提示(既定の agent はプロンプト検出で即 exit 10)、(3) respond は回答を意味解決しないので選択肢は番号で送る(yes は入力後に無視され、続く Enter が既定選択を選ぶ)。

  • fix(scripts): カタログ reconcile に opencode のループバックポートを渡す --opencode-port を追加 (#2036): RunReconcileOptions.opencode と FetchOpencodeOptions は opencode provider 着地時から存在していたのにこれを組み立てる呼び出し元がリポジトリ内に 1 つも無く(develop f590316 実測)、index.ts の fetchOpencodeCommands(options.opencode ?? false) は常に false 側だけを通っていた。結果として週次 catalog-drift を含む全ての run が opencode provider skipped: no loopback port given を出し、check-report.ts がこれを blocking warning として source-warning: に積むため opencode は照合不能のまま固定されていた(型はあるがプラグの刺さっていないソケット)。scripts/refresh-slash-command-catalog.ts に --opencode-port <port> と --help / -h を追加し、パースを純関数 src/lib/slash-command-reconcile/runner-args.ts へ切り出して opencode = { port } を runReconcile へ配線した。ポートは ^[0-9]{1,5}$ + isUsableOpencodePort の二段で検証し(Number(' 80 ')=80 / Number('8e3')=8000 / Number('')=0 が全て通ってしまうため素の Number() キャストは使わない)、不正値は fail-soft にせず usage を出して exit 2 で落とす。フラグ未指定時の挙動は従来どおり skip 警告で、ポートを渡さない週次ワークフローの run は --check の出力形も含めて不変(GET /command は markdown command と Skill しか載せず、TUI 組み込み 16 個は palette attestation のまま。attestation JSON は #2026 の決定どおり人手専用で本変更でも触っていない)。

Changed

  • feat(ui): 生成中の返答を末尾の吹き出しでその場に流す(フッタ帯の廃止) (#2233): 進行中の本文を ChatSurface のフッタ帯から ChatTranscript 末尾の吹き出しへ移し、確定行と同じ提示(.chat-md / text-sm / max-w-[92%] / rounded-2xl、max-h-[7.5rem] クランプ廃止)を ChatMessageBubble の定数と ChatMarkdownBody の共有で保証したので、turn が確定しても本文の位置も見た目も変わらなくなった(従来は .assistant-md / text-xs / 帯いっぱいの箱から上のリストへ飛んでいた)。吹き出しはスクロール領域の内側・仮想リストの外に置き、可視範囲外の行を unmount する仮想化の影響を受けない(#2194 がフッタを選んだ理由を維持)。progress 本文を持たないツール(codex / antigravity / vibe-local)では同じ位置に「応答中…」のみを出し、上へスクロール中は jump-to-latest チップがスピナーと専用の accessible name で生成中を告げる

  • feat(ui): チャット面に専用のフラットな吹き出し列(ChatTranscript)を実装する (#2232): チャット出力面の本体を HistoryPane から新規 ChatTranscript へ差し替え、会話をペアではなくメッセージ単位の吹き出し列として描く(user は右寄せ ml-auto + max-w-[85%] sm:max-w-[75%]、assistant は左寄せ mr-auto + max-w-[92%]、両者とも text-sm、連続する同一 role はヘッダを省略)。最大の変更は assistant 返答を常時全文表示にしたことで、従来は COLLAPSED_MAX_CHARS = 100 により本リポジトリ直近 26 件の assistant 行のうち 25 件(96%、中央値 2,478 文字)が約 4% しか見えていなかった。コピー・composer 挿入・#1121 の再送/破棄・ファイルパスリンク・#168 のアーカイブ減光・#716 の検索はすべて移植し、ホバー前提(opacity-0 group-hover:opacity-100)をやめて操作を常時表示にした。検索は「メッセージ履歴」タイトル・表示件数セレクト・アーカイブ表示・user-only トグルを出さない代わりにアイコン 1 つの控えめな導線で提供する。仮想化は @tanstack/react-virtual + measureElement で、#1123 のゼロ計測フォールバック(CHAT_FALLBACK_RENDER_COUNT)を踏襲する。Epic #2192 決定 1(「チャット面の本体は HistoryPane をそのまま使う」)と #2194 §1(「トランスクリプト実装を二重に持たない」)は本 Issue で撤回したが、その代償として HistoryPane / ConversationPairCard / lib/history-virtualization は 1 バイトも変更していない(Markdown は共有の .assistant-md ではなく新設 .chat-md、検索ハイライトも CSS.highlights の衝突を避けるため chat-search* 名前空間を新設)。あわせて二重に出ていた空状態を ChatTranscript の 1 つに畳み(worktree.chatSurface.emptyHint → worktree.chatTranscript.emptyHint)、ConversationPairCard の pending 表示が無くなったぶん ChatSurface の生成中行から isAwaitingReply ゲートを外し、スマホのチャット面に composer 挿入を配線した

  • fix(history): 会話履歴の assistant 本文で prose とツール実行を分離し、吹き出しがツールログで始まらないようにする (#2234): 転写リーダー(#2041 / #2121 / #2197 / #2198)が書く本文は厳密な転写順だったため、ツール呼び出しから始まったターンは本文の冒頭が - `Bash` — … の列になっていた。実測(2026-09-02、測定機の Claude Code 転写 714 本のうち新しい 400 本、docs/design/2234-turn-prose-tool-separation.md §1)で 本文が空でない 586 turn のうち 141 件(24 %)が 1 行目に tool 行を持っており、本文を全文表示する #2232 のチャット面では吹き出しの書き出しが毎回ツールログになる。新設した src/lib/hooks/sources/turn-body.ts の separateTurnBody() に 4 リーダーのレイアウトを一本化し、prose と Thinking の引用を先頭に、ツール呼び出しを末尾の > **Tool calls (N)** ラベル付き blockquote 1 セクションに畳む(種別の中の順序は従来どおり保持、捨てたのは種別をまたぐ interleave のみ)。表現が <details> ではなく blockquote なのは実測によるもので、カードは remark-gfm + rehype-sanitize + rehype-highlight を rehypeRaw 無しで通すため <details> / <summary> はラベルごと消える(§3、同じ測定を unit テストに回帰として固定)。保存済み行は書き換えない: 4 つの writer はいずれも findMessageByRequestId で既存行を見つけたら降りるので content は不変で、テキストからの再分類は実測で prose 18,914 行中 2 件(緩いパターンなら 94 件)を tool 行と誤判定するため移行そのものを行わない(§2 / §6)。scraper 由来の Ran N shell commands は isAgentAuthoredMarkdown() が false で逐語表示される行であり、かつ TUI が自分で畳んだ要約 1 行で分離すべき構造を持たないため対象外(§5)。src/components/** と src/app/globals.css は無変更で、分離は Markdown 本文の中だけで完結する。

  • refactor(api): 呼び出し元 0 件の POST /api/worktrees/:id/start-polling route を削除 (#2229): 2025-12-05 の 6768ef14「chore: add WIP files」で入ったまま、リポジトリ内に呼び出し元が 1 件も無いことを実測で確認して削除した(grep -rn 'start-polling' src tests docs README.md CHANGELOG.md のヒットは route 自身の 2 行を除くと #2223 が docblock に書いた例示 2 件と CHANGELOG の #2223 エントリ 1 件だけで、Web client の fetch / API client / CLI / endpoint テスト / README / docs/module-reference.md の行はいずれも 0 件。同じ grep が sibling の /prompt-response には 19 件のヒットを返すので、0 件は grep の空振りではない)。ポーラの起動は /send(send-user-message.ts:339)/ /respond / /prompt-response / auto-yes-poller.ts:559 がそれぞれ正しい instanceId を渡して行っているため機能の穴は空かない —— むしろこの route だけが startPolling(id, body.cliToolId) の 2 引数形で instanceId を落としており、生きていれば multi-instance worktree で別 instance の pane を既定 instance の poller に見張らせる欠陥経路だった(Issue 本文が当初「instanceId を受け取れるように直す」としていたのはこの欠陥のこと。呼び出し元が 0 件と分かったため修正ではなく削除を選んだ)。あわせて #2223 が「route bundle 側から poller を起こす経路」として列挙していた例示(src/lib/polling/response-poller-core.ts の docblock と tests/unit/lib/polling/response-poller-singleton-2223.test.ts の suite docblock)を残る 3 経路 /send / /respond / /prompt-response に直した —— 2 graph topology(custom server の graph と Next route bundle でモジュールが別々に評価される)という #2223 の技術的主張そのものは不変で、消えたのは列挙の 4 つ目だけ。削除後 npm run build の .next/routes-manifest.json / .next/app-path-routes-manifest.json / .next/server/app-paths-manifest.json のいずれからもこの path が消え、.next 配下に残存チャンクが 0 件であることを確認済み。

  • docs(design): #2200 が削除した MessageList 系の名前に「削除済み」の注記を入れ、識別子ガードの history 行を実態に合わせる (#2212): docs/design/multi-agent-state-architecture.md は §9「統合判定(非影響)」/ §10.13 / §15.3 DR3-022 / §15.4 で MessageList(と props の generatingContent / realtimeOutput)を「本設計の消費者ではない」という 2026-08-21 時点の否定的実測として名指ししているが、#2200 が旧チャット UI ごと削除したあとも設計書側は「実在する」と読める書き方のままだった(§15.4 の逐語確認 10 は「9 件はすべて実在する」と明記していた)。判定内容は 2026-08-21 時点の記録として一切書き換えず、§15.4 に「後日の更新」段落を 1 つ足して削除の事実・後継(ChatSurface.tsx / HistoryPane.tsx / ConversationPairCard.tsx)・「この記録は #1921 がこの面を非影響と決めた根拠そのものなので消さない」を書き、他の 4 箇所からはそこを指す 1 節ずつの注記に留めた。あわせて tests/unit/docs/design-doc-identifier-audit.test.ts の DECLARED 3 行の why を「設計書が削除を書いている」実態に更新し(#2200 が書いた「§14.3 DR3-022」は誤りで、DR3-022 は §15.3 にある。§14.3 は DR3-001〜017 のサマリー)、history 区分の定義に「設計書が削除・改名・撤回を書いていること」という条件を明記した。カテゴリ内訳の receipt は 5 のまま変化なし。

  • refactor(ui): 旧チャット資産(MessageList / PromptMessage / Review 画面のインライン入力とその送信フック)を撤去し、docs を実装に合わせた (#2200): チャット面が ChatSurface + HistoryPane + ConversationPairCard に一本化された(#2193/#2194)ことで、これら 4 モジュールはどのアプリコードからもマウントされていなかった — barrel components/worktree/index.ts からの再 export と自身のテストだけが参照元で、送信フックが叩いていた POST /api/worktrees/:id/messages に至ってはその route が GET しか export していない死んだ配線だった。機能パリティ(Markdown / コードブロックコピー / ファイルパスリンク / プロンプト応答 / アーカイブ表示)は現行の 3 コンポーネント+コンポーザの PromptPanel で満たされていることを確認済みで、ANSI 変換だけは吸収先を持たないが、保存経路が stripAnsi() を通す(lib/response-extractor)ため到達不能な分岐だった。他で未使用になった i18n キー 15 件(worktree.messages.* / worktree.output.* / prompt.awaitingSelection ほか)を en/ja 両方から削除し、それらを pin していた i18n ガードも縮めた。docs/architecture.md は §1.1(ゴール)・§3.1(コンポーネント図)・§4.2(ChatMessage)・§5.2(送信フロー)・§5.4(WS イベント)を実装に合わせ、WS イベント一覧は src/lib/realtime/types.ts を正として chat_turn_progress(#2199)を含む 7 種を表にした。あわせて tmux セッション名の旧表記 cw_{worktreeId} を §6.1 の現行 mcbd-{cliToolId}-{worktreeId} に統一。docs/user-guide/webapp-guide.md に出力面の切り替え方(トグル・Cmd/Ctrl + Shift + M・「ターミナルで確認」が出る 4 条件)を追加した。

Fixed

  • fix(realtime): terminal_snapshot に sessionStatus を載せ、push だけで生成中判定が立つようにする (#2240): WebSocket push は isRunning(健全な tmux セッションが在るか)を運ぶのに、#2238 でチャット面のゲートになった sessionStatus(idle / ready / running / waiting の統合判定)を運んでおらず、#2239 はクライアント側で前回 poll 値を保持する回避策でこれを埋めていた。保持は「直前に poll が landed していること」に依存するため、最初のフレームが push で届くペインでは初期値 '' のまま最大 15 秒(push 健全時の fallback poll 間隔)生成中バブルが出なかった。TerminalSnapshotEvent に必須メンバとして sessionStatus を追加し、emitter が buildCurrentOutput の値をそのまま載せることで push と poll が同一の判定を報告するようにし、あわせて interaction 後の再描画抑止フィンガープリントにも sessionStatus を含めた(画面が同じで判定だけが waiting から running に動く応答直後のフレームが握り潰されていた)。クライアント側の保持(data.sessionStatus ?? prev.sessionStatus)は意図的に残す — ワイヤは型検査されず版ズレバナーは再読込を促すだけなので、ダウングレードを跨いで開いたままのタブには #2240 より古いサーバの push が届き続けうる

  • docs(architecture): 出力面の chat 構成を #2232 撤回後の実装に合わせる (#2241): docs/architecture.md の出力面の記述が「ChatSurface → HistoryPane → ConversationPairCard」という Issue #2232 が撤回した Epic #2192 の決定 1 のまま残っており、次にこの面を設計する人を撤回済みの案へ差し戻す状態だったため、現在の実装(ChatSurface → ChatTranscript → ChatMessageBubble、生成中の本文は列末尾の ChatLiveTurnBubble、#2233)へ訂正した。あわせて、単なる置換では失われる「なぜ二重実装なのか」(履歴ブラウザと会話面は必要な情報密度が正反対で 1 実装では両立しない)と、その代償として課される規律(HistoryPane / ConversationPairCard / lib/history-virtualization を変更しない、Markdown は .assistant-md と分けて .chat-md を使う)、および生成中判定が sessionStatus === 'running' であること(isRunning は「健全な tmux セッションが在るか」、#2238)を追記した

  • fix(ui): チャット面の生成中バブルを sessionStatus === 'running' で判定する (#2238): 生成中バブルと useChatTurnProgress の購読が live.isRunning でゲートされていたが、isRunning の実体は hasSession() + isSessionHealthy()=「健全な tmux セッションが在るか」であって「生成中か」ではないため、セッションが生きている worktree ではターンが終わった後も「Responding…」が消えず、リロードしても残り続けていた(単発の残留ではなく定常的な誤表示)。useTerminalPanePolling に /current-output が以前から返していた sessionStatus(idle / ready / running / waiting)の読み取りを足し、チャット面の判定をサーバ側 publishChatTurnProgress のゲート(current-output-builder.ts の payload.isRunning && payload.sessionStatus === 'running')と同じ verdict に揃えた。sessionStatus を運ばない WebSocket push(terminal_snapshot)では直前の poll 値を保持し、セッション停止イベントでは idle に落とす。isRunning 自体の意味は変えていないためターミナル面の消費者(isActive / disabled / isSessionRunning)は無変更で、取り違えの入口だった ChatSurfaceLiveState.isRunning の doc コメント「The session is generating.」も実体に合わせて訂正した

  • fix(polling): response poller が二重に走り同じ返答が History に 2 行入る問題を修正 (#2223): activePollers / pollingStartTimes と、同じ lifecycle で生き死にする 3 つの補助キャッシュ(tuiResponseAccumulator / promptHashCache / responseHashCache)を globalThis の process-wide coordinator へ移し、さらに各 poller key に generation(ownership token)を持たせた。module scope は「Node の module cache が singleton を保証する」という前提だったが、next start は custom server の graph(復元 timer → sendUserMessage、Auto-Yes poller)と Next route bundle(/send /respond /prompt-response /start-polling)でこのモジュールを別々に評価するため、registry も dedup キャッシュも graph ごとに 1 つになっていた。共有だけでは足りず、checkForResponse() を await 中の tick は発火済みなので clearTimeout() で止まらず、再開時に activePollers.has() を「自分がまだ owner」と誤読して自分の timer を生きている poller の上に上書きし(新 poller は追跡不能のまま走り続ける)、逆に古い tick の stopPolling() が後続世代を殺していた。tick は ownership token を握って再予約・broadcast を判断し、in-flight 中の restart は key 単位で直列化(pendingRestart)、checkForResponse() 内から上がる stopPolling() は AsyncLocalStorage で「superseded な tick が発した stop」だけを無視する。export const activePollers / pollingStartTimes と getPollerKey() の #868 互換キー形は不変。

  • fix(realtime): 再送時の orphan 削除が別 instance の行を消す問題と、削除が別端末へ伝わらない問題を修正 (#2219): #379 の重複防止が使う orphan 検索 getMessages(db, worktreeId, {limit:1, cliToolId, instanceId}) は、instance_id または cli_tool_id の排他フィルタ(#868)で絞る。通常送信は UI も send API も instanceId を省略するため tool フィルタへ落ち、同じ tool の全 instance を横断した最新 user 行が返っていた。primary からの再送が claude-2 の同文行を物理削除しうる実データ消失で、削除は best-effort なのでログ 1 行しか残らない。sendUserMessage 側で primary(instanceId ?? cliToolId)へ解決したうえで、getMessages に opt-in の matchResolvedInstance を追加して COALESCE(instance_id, cli_tool_id, 'claude') = ? で比較する(素の instance_id = ? にすると #868 以前の instance_id IS NULL 行が見えなくなり、UI に出ている重複を消せなくなる — #2196 の findUnkeyedUserMessages と同じ理由・同じ式)。あわせて削除成功後に messages_invalidated ({worktreeId, cliToolId, instanceId, reason}) を broadcast し、受信した pane が history を再取得するようにした。行の削除を表せるイベントが無く(message / message_updated は「その行が今どう見えるか」しか言えない)、送信していない端末は新旧 2 行を自分の次のポーリング(#2195 で WS 接続中 15 秒フォールバックへ降格)まで表示し続けていた。id ではなく scope を送るので受信側は DB の確定状態を読み直し、落ちた message フレームも同じ往復で回復し、削除前に発行済みの fetch は再取得の request id で無効化される。broadcast は try/catch で、送信の成否も #379 の best-effort な性質も変えない。

  • fix(realtime): route 起点の WebSocket push が production でも無言で no-op になっていた問題を修正 (#2220): ws-server の wss / clients / rooms は素のモジュールスコープで、実 socket を持つのは setupWebSocket() を呼んだ 1 インスタンスだけ。production build は dist/server/server.js(require("./src/lib/ws-server"))と .next/server/chunks/<n>.js(route handler が引く別コピー。setupWebSocket は export されるだけで一度も呼ばれない)の 2 グラフに分かれるため、/send・/respond・/prompt-response・/hooks/*・/worktrees など route 起点の broadcast は production でも空の rooms に落ちて silent return していた(#2214 が「production は単一 bundle なので影響しない」と記録したのは誤り)。globalThis 上に process-local な publisher capability registry(src/lib/realtime/publisher-registry.ts)を置き、setupWebSocket() だけが publish / hasSubscribers / cleanupRooms / migrateRooms を owner token つきで登録する方式に変更。broadcast / broadcastMessage / hasRoomSubscribers / cleanupRooms / migrateWorktreeRooms の内部を registry 経由に差し替えたので producer 側の import は無変更で、wss と socket 集合は owner モジュール内に閉じたまま。hasRoomSubscribers も橋渡し対象(terminal-broadcast と emitChatTurnProgress はこれを最初に見て早期 return するため、broadcast だけでは terminal snapshot と chat_turn_progress が届かない)。terminal-broadcast の versionCounters は複数インスタンスが version: 1 から採番してクライアントの stale guard に捨てられるのを防ぐため globalThis 化。解除は同じ token の owner だけができ、古いインスタンスの closeWebSocket() は新しい publisher も #1788 の waiting 購読も解除しない。publisher 未登録時は従来どおり no-op だが、抑制つき warning(realtime:no-room-publisher、60 秒に 1 回)とカウンタを追加した。

  • docs(user-guide): 出力面の既定値をブラウザが受け取る時点の記述を実装に合わせて修正 (#2221): 日本語版 docs/user-guide/webapp-guide.md が「この既定値をブラウザが知るのは More 画面を一度開いたあと」「別の端末や別のブラウザから初めてアクセスしたときは、一度 More 画面を開くまで ターミナル で始まる」と書いていたが、実装はそうではない。src/config/surface-mode-config.ts の readSurfaceMode()(:148)は先頭で scheduleClientDefaultSurfaceModeSeed()(:322)を呼び、ensureClientDefaultSurfaceMode()(:341)で /api/settings/default-surface-mode をページセッションごとに 1 回だけ背景取得する。この経路は PC の TerminalSplitPaneContent.tsx:221 とスマホの MobileTerminalTab.tsx:254 がどちらも resolveSurfaceMode() 経由で通るため、どの出力面を開いても既定値は取得され、More 画面は「その端末に即座に反映させる」だけの経路にすぎない。#2211 で入った英語版 docs/en/user-guide/webapp-guide.md の記述が正しく、JA だけが食い違っていたため JA を EN に合わせた(EN は無変更)。## 見出し数は JA/EN とも 18 のまま据え置き、tests/unit/docs/ja-en-heading-parity.test.ts の等価性を維持している。

  • fix(realtime): 履歴行を書くのに broadcast していない producer 4 箇所を塞ぐ (#2214): chat_messages に行を書きながら realtime フレームを出していなかった残り 4 producer に broadcastMessage を足した。新規 INSERT の 3 箇所 — Auto-Yes 承認の監査行(permission-decision-service)、#1708 の「分類できなかったフレーム」行と #1725 の「scraper に見えないダイアログ」行(current-output-builder)— は 'message'、既存行を更新する stale プロンプト掃除(worktree-status-helper が markPendingPromptsAsAnswered に #2195 の onUpdated を渡す)は 'message_updated' で publish する。#2195 がクライアントの履歴ポーリングを 15 s のフォールバックへ降格したため、これらの行は最大 15 s 遅れて現れていた。push はいずれも DB の成功から分離して ws-server を動的 import する detached promise で行い、socket 失敗が書き込み・裁定・ステータス検出のどれも壊さないようにしてある(特に sweep のコールバックは detectInstanceSessionStatus の try の中で呼ばれ、そこで throw すると catch が「capture 失敗=processing」と読んでサイドバー・Home・Sessions・Review・コマンドパレットに誤ったステータスを publish する)。DB 層に ws-server は持ち込んでいない。既知の制約: broadcastMessage が触るのは自分の module instance の rooms だけで、実ソケットを持つのは custom server が setupWebSocket した bundle のみ。production は単一 bundle なので影響しないが、route ごとに bundle が分かれる next dev では route 経由の push が空の rooms に落ちて no-op になりうる(画面は次の履歴ポーリングで追いつく)。bundle 横断 bridge / rooms の globalThis 化は本 Issue より大きいので別 Issue とし、実際の WebSocket サーバと実 socket を使う tests/integration/ws-broadcast-module-instance-2214.test.ts が production 側の到達と next dev 側の no-op の両方を pin している。

  • fix(push): 待機通知の本文が最初の分類で固定され「端末の確認が必要です」を誤表示する問題を修正 (#2156): waiting-push-notifier が、分類のついていない待機(unclassified / kind なし)だけを CLASSIFICATION_GRACE_MS(8 秒)保留し、猶予明けに getWaitingEpisode() が持つその時点の分類で本文を組み立てるようにした。AskUserQuestion のようにダイアログが 2 回の probe の間に描画される待機は、エッジが立つ時点ではまだ pane に読めるものが無いため初報が unclassified になり、waiting-episode-state は kind の更新を意図的にエッジとして emit しない(性質が変わっても 1 つの待機)ので、通知は暫定判定のまま「端末の確認が必要です」と言い切っていた(Issue 実測: 00:08:38.837 にエッジ、00:08:44.863 に prompt へ確定=6.03 秒)。prompt / menu は pane を読めた肯定的判定なのでエッジ即送のまま(menu の「端末の確認が必要です」に退行なし)。通知総数は増えない — 訂正 push は送らず、猶予中に解消した待機はそもそも送らない。

  • fix(history): claude の TUI 会話履歴を転写ファイルから読み、利用者のプロンプトが assistant 行に混入するのを止める (#2121): claude の返答を chat_messages に書く経路を、ペインのスクレイプから エージェント自身の転写 JSONL(~/.claude/projects/ 配下、ディレクトリ名は cwd の非英数字をすべてハイフンにした slug、ファイル名は session-id + .jsonl)へ差し替え。保存されていた assistant 行の冒頭が利用者自身のプロンプトだった事象(Issue 実測: chat_messages 13,253 文字 対 転写 3,669 文字)は、プロンプトと返答が別 type のレコードで届き type='assistant' しか読まないことで構造的に起きなくなる。1 ターンは「利用者のプロンプトレコードの uuid」で束ね(実測: 未完了の 1 ターンで既に assistant レコード 110 件・requestId 23 種、requestId や レコード uuid で鍵にすると 1 返答が数十行に割れる)、行は request_id に claude-turn: を持つので ConversationPairCard は無改造で markdown 描画に乗る。ポーラーとの排他は structured-history-gate に pull 型の captureStructuredHistoryTurn() を追加して実現し、転写ファイルが無い / 読めない / まだ返答が書かれていない場合は false を返して従来のスクレイプ経路にそのままフォールバックする(fail-open)。ライブ追記中のファイルは行単位でパースし壊れた行だけを捨てる。セッション ID は鍵ではなく「三つ組がいまどの転写を指すか」の可変ポインタとして hooks イベントから latch するので、/clear でファイルが変わっても、1 worktree に claude が 2 インスタンス居ても取り違えない。codex は範囲外(理由と残作業は commit メッセージ)。実転写 10 本での読み取り検証 2026-08-31: malformed 0 / orphan 0 / 未知ブロック 0 / プロンプト混入 0。

  • fix(db,worktree): ディレクトリが消えた repositories 行が回収されず毎起動で ERROR を出し続ける問題を修正 (#2165): サーバ起動時(initializeWorktrees())に、enabled=1 かつ worktree 行 0 件かつディレクトリの不在が確定している repository 行を enabled=0 へ降格して走査対象から外す reclaimGhostRepositories() を追加。行は削除せず降格に留める(id・表示名・visible・clone URL・clone_jobs.repository_id の参照はそのまま残り、Repositories 画面から 1 クリックで戻せる。「除外は停止であって削除ではない」とした #1666 と整合)。線引きの要は 3 つで、worktree 行を 1 件でも持つ行は対象外(チャット履歴・メモ・todo・タイマー・タスク・検証実行はすべて worktrees にぶら下がるため、0 件は「失うものが無い」ことの証明そのもの)、WORKTREE_REPOS が今も宣言しているパスは対象外(migration v43 の guard 2 と同じ理由。#1339)、不在は lstat の ENOENT/ENOTDIR で確定した場合のみ(fs.existsSync() は「無い」と「確かめられなかった」を同じ false に潰すため、到達不能なネットワークボリューム上のリポジトリ=EIO/ETIMEDOUT/ESTALE/EACCES を「消えた」と誤読する。これが「一時的に見えないだけ」を消さない線)。起動経路での呼び出しは直前の 3 つの reconciler と同じく個別の try/catch で fail-open にしてあり、回収が throw しても同じサイクルのリポジトリ走査と worktree 同期は走る。あわせて scanWorktrees() が走査前に不在を確認して repository:scan-skipped-missing-dir を WARN で出すようにし、存在しない cwd へのシェル spawn とその結果の [ERROR] repository:scan-failed {"error":"spawn /bin/sh ENOENT"}(/bin/sh の不在ではなく cwd の不在。実際に「シェルが壊れている」と誤診しかけた文面)を止めた。ディレクトリが存在するリポジトリの git 失敗は従来どおり ERROR のまま。

  • fix(scripts): ビルド無しの正規な再起動手段を用意し、load-env.sh が非 bash で無言成功するのをやめる (#2132): scripts/load-env.sh に bash ガードを入れ、${BASH_SOURCE+x} が無いシェル(zsh / dash)から source されたら理由と代替手段を stderr に出して非 0 で失敗するようにした(develop f590316 実測: zsh -c '. scripts/load-env.sh' は rc=0 で 1 変数も export しない。Epic #2002 実機 UAT 2026-08-29 はこれを踏んで CM_VAPID_* / CM_DB_PATH / CM_ROOT_DIR / CM_PORT / CM_BIND を全部落としたサーバを起動し、Web Push が全便無音で死んで UAT を 2 ラウンド失った)。あわせて scripts/start.sh に --daemon / -d / --help を追加し(build-and-start.sh:145-200 の daemon ブロックからビルド段だけを抜いた移植。PID ファイルの chmod 600 [S4-003]・ログの chmod 640 [S4-005]・symlink 拒否 [S4-006]・PID/ポート二重起動チェック [D1-004] は保持)、停止+起動を 1 コマンドにする scripts/restart-nobuild.sh を新設した。どちらも npm run build を呼ばないので .next/BUILD_ID が変わらず、開いているタブが壊れない(コメント除外行の静的検査で固定。陽性対照=npm start が在ること、陰性対照=build-and-start.sh は依然ビルドすること)。さらに server.ts が VAPID セルフチェックの直前に runDotenvSelfCheck()(src/lib/env.ts)を呼び、「.env は在るのに宣言された変数が 1 つも process.env に無い」を起動ログの [env] ... 行として報告する(原因を症状より先に読ませるための順序。fail-open・正常時は完全に無言)。

  • fix(push,docs): push-fanout-complete に宛先が無く、複数ワークツリーの通知を取り違える問題を修正 (#2133): push-fanout-complete のログを {kind, delivered, failed} から {kind, worktreeId, instanceId, delivered, failed} に拡張。docs/qa/2001-cross-device-dismissal-uat.md は T-4 / T-6 / T-7 の 3 項目が「通知が来ないこと」を合格条件にしており、それを実機で観測する手段はこのログを数えることしか無い。宛先を持たないため他ワークツリーの通知と区別できず、Epic #2002 の実機 UAT(2026-08-29)では T-4 の実施中に別ワークツリー mycodebranchdesk / claude-3 の通知が同じ秒に 2 通着弾し、同時刻の resolution-push-sent(こちらは worktreeId を持つ)と突き合わせて辛うじて切り分けた —— 解決 push を伴わない素の待機通知なら突き合わせる相手が無く、そのまま誤判定になっていた。キー名は同型の先例である resolution-push-notifier の context に揃えたので、1 つの待機の両半分(発火と解決)が同じ worktreeId で grep できる。instanceId は episode dedup キーと同じ解決順(event.instanceId ?? event.agentName)で、名乗る生産者が居なければ "" ではなく省略する(JSON.stringify が undefined を落とす。空文字は「名前の無いインスタンス」と読めてしまう)。あわせて resolution-push-skipped の reason: no-card は logger.debug のまま据え置いた(Issue の対策案 2 を採用): no-card / still-waiting は「構造上の既定」—— no-card は Auto-Yes が答えた待機すべてで出る —— であり、info へ昇格させると読み手が見ているログを埋める。代わりに手順書側の期待値を、既定設定で実際に観測できる内容に書き換えた: T-4 / T-6 は reason: no-card を探すのをやめ、worktreeId で絞った push-fanout-complete が 0 件であることで判定する(grep -a push-fanout-complete logs/server.log | grep -c '"worktreeId":"<id>"')。T-8 の「no-card なら不合格」も resolution-push-sent の有無での判定に、§1 の [DEBUG] サンプル行も「既定設定では出ない」注記つきに、§7 の合否表も同じ基準に直した。docs/user-guide/webapp-guide.md(ja / en)のログ例も新しい形に更新し、worktreeId で絞る grep を追記。検算: 構造保存変異 5 件(ログを #2124 の形へ差し戻す / instanceId を '' にフォールバック / agentName フォールバックを削除 / 手順書 T-4 を「サーバログに reason: no-card」へ差し戻す / ja ガイドのサンプル行から worktreeId を削除)を 1 件ずつ注入し、5 件すべてが赤になることを確認した。

  • fix(pwa): iOS で待機通知が置き換わらずカードが積み上がる問題を修正 (#2155): public/sw.js の push リスナが replaceStaleNotifications()(getNotifications({tag}) → close() → そのあと showNotification)へ分岐する条件を resolved から options.tag に広げ、タグを持つ push はすべて close→show を通すようにした。Safari/iOS は素の showNotification による同一 tag の置き換えを行わないため、close 経路を通らない通常の待機通知(とりわけ src/lib/push/waiting-push-notifier.ts のエスカレーション再通知と #2057 経路 A のサーバ再起動による再観測)が iPad で 1 枚に畳まれず積み上がっていた(実測 2026-08-30 / iPad iOS 18.7・Safari 26.5.2 と Android 10・Chrome 151 を同一 push で並置: prompt→prompt 77 秒間隔・0 枚起点で iPad 2 枚 / Android 1 枚、prompt→resolution 43 秒間隔では iPad 1 枚。経過時間では説明が付かず経路が説明する)。close→show の順序は保たれるので userVisibleOnly:true 違反の罰(Chrome の汎用カード / Firefox の silent push クォータ / WebKit の購読剥奪)は踏まない。silent は従来どおり解決 push だけに立てる(待機通知は鳴ってよい。無音化は #1999 / #2000 の別件)。設計書 §7 は「close() が効かなくても tag 置換が保険になる」と書いていたが実測は正反対だったため、§7.1 に実測を追記して想定が逆だったことを記録した。getNotifications({tag}) が iOS で全件返すか最新 1 件かは未確定のままなので、コード側はどちらでも正しく動く(返ってきた分だけ閉じる)実装にしてある。

  • test(i18n): opencode セッション操作のラベルが訳文で出ることを実辞書で検証するガードを追加 (#2083): OpencodeSessionControls のラベルは Issue #2051 / #2109 で LABELS = { en, ja } の直書きマップから worktree.opencodeSession.*(next-intl)へ移設済みだったが、移設先が正しいことを検証するテストが 1 本も無かった。当該コンポーネントの unit test はすべて vi.mock('next-intl') でキー文字列をそのまま返すスタブ越しに解決するため、辞書に訳文があっても・キーパスのコピーがあっても・エントリが存在しなくても同じように緑になる(受入条件 3 が警告していた罠そのもの)。tests/unit/i18n/opencode-session-keys-2083.test.ts を追加し、next-intl を一切 import / mock せずに locales/{en,ja}/worktree.json をディスクから読み、①コンポーネントが解決する 16 キーが両ロケールに揃うこと ②en / ja のキー集合が完全一致し、呼び出し元の無い余剰キーが無いこと ③値が new / opencodeSession.new / worktree.opencodeSession.new のいずれのキーパスとも一致しないこと ④画面に文字として出る new / list / fork / share の ja が英語のコピーでなく仮名または漢字を含むこと、を assert する。空振りでないことは変異注入で確認済み(ja の値をキーパスに置換 → 2 件赤 / 英語のコピーに置換 → 1 件赤 / ja からキー削除 → 3 件赤)。あわせて、コンポーネントが useLocale() を呼ばなくなったのに OpencodeSessionControls-2038 / -share-2051 / -errors-2109 の 3 本に残っていた死んだ useLocale モック(と -2038 の locale hoisted 変数)を削除した。