v0.30.0
[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の既存onOptimisticSendseam として受け取る。履歴を上へ持ち上げないので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にしか無く(他のMODELtype はツール出力、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 にはUserPromptSubmithook が無いため、転写が 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(scopeterminal)に登録して?一覧に載せ、ハンドラはフォーカスを持つ 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-pollingroute を削除 (#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のDECLARED3 行の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 モジュールはどのアプリコードからもマウントされていなかった — barrelcomponents/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_messages13,253 文字 対 転写 3,669 文字)は、プロンプトと返答が別typeのレコードで届きtype='assistant'しか読まないことで構造的に起きなくなる。1 ターンは「利用者のプロンプトレコードのuuid」で束ね(実測: 未完了の 1 ターンで既に assistant レコード 110 件・requestId23 種、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のlocalehoisted 変数)を削除した。