@cocorograph/hub-agent 0.8.76 → 0.8.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,7 @@ import { randomUUID } from "node:crypto"
22
22
  import { readFileSync } from "node:fs"
23
23
  import os from "node:os"
24
24
  import path from "node:path"
25
+ import { isUnderRemoval } from "./chat-removal-guard.mjs"
25
26
 
26
27
  import { jsonlPath } from "./claude-history.mjs"
27
28
  import { watchSessionFile } from "./claude-history-watch.mjs"
@@ -2710,10 +2711,14 @@ export class ClaudeStreamBridge extends EventEmitter {
2710
2711
  * SDK セッションを生かしたままだと同一 cwd の jsonl を二重ホストして競合する
2711
2712
  * (DESIGN.md C7)。soft-detach (7 日 resident) では止まらないので、ここで明示 close する。
2712
2713
  *
2713
- * @param {{cwd?: string, session_id?: string}} sel
2714
+ * ⚠️ `reason` は exit イベントにそのまま載る。既定の `tui-bind-stop` は上記 TUI bind 用の
2715
+ * 名前なので、**TUI 以外の理由で止めるときは必ず渡す**こと (既定のままだと、存在しない
2716
+ * tmux 連携のせいで会話が消えたように見え、原因追跡が別経路へ逸れる)。
2717
+ *
2718
+ * @param {{cwd?: string, session_id?: string, reason?: string}} sel
2714
2719
  * @returns {number} 停止したセッション数
2715
2720
  */
2716
- stopSessionsFor({ cwd, session_id } = {}) {
2721
+ stopSessionsFor({ cwd, session_id, reason = "tui-bind-stop" } = {}) {
2717
2722
  const victims = new Set()
2718
2723
  if (session_id) {
2719
2724
  // 複合キー化 (T06271) で session_id 単独では引けないため values を走査して拾う。
@@ -2757,7 +2762,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2757
2762
  this.emit("exit", {
2758
2763
  stream_id: s.stream_id,
2759
2764
  code: 0,
2760
- reason: "tui-bind-stop",
2765
+ reason,
2761
2766
  session_id: s.sessionId,
2762
2767
  })
2763
2768
  stopped++
@@ -2767,7 +2772,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2767
2772
 
2768
2773
  /** cwd と session_id が完全一致する live セッションだけを停止し、CLI 終了まで待つ。
2769
2774
  * 戻り値は今回新たに停止した数 (tombstone の待ち直しは数えない)。timeout は throw。 */
2770
- async stopSessionForDeletion({ cwd, session_id }) {
2775
+ async stopSessionForDeletion({ cwd, session_id, reason = "session-deleted" }) {
2771
2776
  const key = this._liveKeyFor(cwd, session_id)
2772
2777
  const newVictims = new Set()
2773
2778
  for (const s of [...this._liveBySession.values(), ...this.sessions.values()]) {
@@ -2802,7 +2807,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2802
2807
  this.emit("exit", {
2803
2808
  stream_id: s.stream_id,
2804
2809
  code: 0,
2805
- reason: "session-deleted",
2810
+ reason,
2806
2811
  session_id: s.sessionId,
2807
2812
  })
2808
2813
  }
@@ -2860,6 +2865,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2860
2865
  // cwd を 1 回だけ解決し、reattach lookup / session 生成 / 索引登録で同じ値を使う
2861
2866
  // (複合キーの整合性を保つため)。
2862
2867
  const scwd = cwd || process.env.HOME || process.cwd()
2868
+ if (isUnderRemoval(scwd)) throw new Error(`worktree removal in progress: ${scwd}`)
2863
2869
  // 常駐化: resume_session_id が示すセッションが (走行中 / idle 問わず) まだ生存していれば、
2864
2870
  // 新規 query を起動せずそのセッションへ再アタッチする。これにより端末スリープ/再接続でも
2865
2871
  // ターンは落ちず、以降のイベントは新しい stream_id 経由でライブに流れる
@@ -43,6 +43,7 @@
43
43
  import { EventEmitter } from "node:events"
44
44
  import { randomUUID } from "node:crypto"
45
45
  import path from "node:path"
46
+ import { isUnderRemoval } from "./chat-removal-guard.mjs"
46
47
 
47
48
  import { lookupInjectSource, recordInjectSourcePersistent } from "./inject-source-store.mjs"
48
49
 
@@ -800,6 +801,7 @@ export class CodexStreamBridge extends EventEmitter {
800
801
  if (this.sessions.has(stream_id)) {
801
802
  throw new Error(`stream_id already attached: ${stream_id}`)
802
803
  }
804
+ if (isUnderRemoval(cwd)) throw new Error(`worktree removal in progress: ${cwd}`)
803
805
  // 計測 (2026-07-31): attach 1 回 = 1 行の内訳ログ (`codex attach timing`)。
804
806
  // frontend の attach_sent→ready_received は 1 つの数字でしか見えず、その中身が
805
807
  // 「app-server の cold spawn + initialize」なのか「thread/resume の rollout 読み込み」なのか、
@@ -923,6 +925,11 @@ export class CodexStreamBridge extends EventEmitter {
923
925
  }
924
926
  }
925
927
 
928
+ // ⚠️ attach の入口でも同じ検査をしているが、そこから **_tryReattach の await** を挟んで
929
+ // ここへ来る(既存 session の thread 確定待ち)。その間に削除が始まっていることがあるので、
930
+ // プロセスを起こす直前にもう一度見る。ここを外すと「停止対象を列挙し終えたあとに新しい
931
+ // プロセスが生まれる」経路が残り、削除された cwd を指す会話ができる。
932
+ if (isUnderRemoval(cwd)) throw new Error(`worktree removal in progress: ${cwd}`)
926
933
  const client = this._clientFactory({
927
934
  cwd,
928
935
  codexCmd: this.codexCmd,
@@ -1974,6 +1981,14 @@ export class CodexStreamBridge extends EventEmitter {
1974
1981
  return addCodexSessionTitles(sessions, sessionsDir)
1975
1982
  }
1976
1983
 
1984
+ /** 指定 stream の live 会話を停止する。履歴ファイルは削除しない。 */
1985
+ stopSessionForStream({ stream_id, reason }) {
1986
+ const session = this.sessions.get(stream_id)
1987
+ if (!session || session.dead) return 0
1988
+ this._reapSession(session, Date.now(), reason || "explicit-stop")
1989
+ return 1
1990
+ }
1991
+
1977
1992
  async operateSession(options) {
1978
1993
  const invalid = validateCodexSessionOperation(options)
1979
1994
  if (invalid) return { ok: false, error: invalid }
@@ -4730,7 +4745,12 @@ export class CodexStreamBridge extends EventEmitter {
4730
4745
  producer_seq: session.seq,
4731
4746
  thread_id: session.threadId,
4732
4747
  code: 0,
4733
- reason: reason === "session-deleted" ? "session-deleted" : "idle-reaped",
4748
+ // ⚠️ ここは「明示的に止めた理由」の許可リスト。載っていない理由はすべて "idle-reaped"
4749
+ // へ丸められ、利用者には「放置されて片付いた」と通知される。明示停止を足したら必ず
4750
+ // ここへも足すこと(足し忘れても停止自体は動くので、通知だけが静かに嘘になる)。
4751
+ reason: ["session-deleted", "chat-stop", "chat-stop-all", "worktree-remove"].includes(reason)
4752
+ ? reason
4753
+ : "idle-reaped",
4734
4754
  })
4735
4755
  }
4736
4756
 
@@ -83,8 +83,14 @@ function globalMutationMode(type) {
83
83
  return null
84
84
  }
85
85
 
86
- function attachLaneKey(mode, msg) {
87
- const cwd = typeof msg?.cwd === "string" ? msg.cwd.trim() : ""
86
+ // ⚠️ cwd を省略した attach は、ハンドラ側で config.claude_cwd に補われ、さらに claude では
87
+ // bridge が HOME へ補完して「その cwd の会話」になる (main.mjs の claude.attach / codex.attach
88
+ // と claude-stream-bridge の attach)。レーン分類がその補完を知らないと、同じ会話への attach が
89
+ // stream レーンへ、個別停止 (cwd を持つ) が cwd レーンへ散り、直列化されないまま同時に走る。
90
+ // scheduler へ設定も HOME も読ませず、補完後の既定 cwd を呼び出し側から mode 別に渡してもらう
91
+ // (補完の規則は CLI 種別で違うため、mode を知らない scheduler 側では決められない)。
92
+ function attachLaneKey(mode, msg, defaultCwd = "") {
93
+ const cwd = (typeof msg?.cwd === "string" ? msg.cwd.trim() : "") || defaultCwd
88
94
  if (cwd) return `${mode}:cwd:${cwd}`
89
95
  const resumeId =
90
96
  mode === "claude"
@@ -100,14 +106,18 @@ function attachLaneKey(mode, msg) {
100
106
  * run: (msg: object) => Promise<void>|void,
101
107
  * logger?: {debug?: Function, warn?: Function, error?: Function},
102
108
  * shouldBypass?: (type: string|undefined) => boolean,
109
+ * defaultCwd?: (mode: "claude"|"codex") => string,
103
110
  * }} options
104
111
  */
105
112
  export class DispatchScheduler {
106
- constructor({ run, logger, shouldBypass }) {
113
+ constructor({ run, logger, shouldBypass, defaultCwd }) {
107
114
  if (typeof run !== "function") throw new TypeError("DispatchScheduler run is required")
108
115
  this.run = run
109
116
  this.logger = logger
110
117
  this.shouldBypass = typeof shouldBypass === "function" ? shouldBypass : () => false
118
+ // cwd 省略時にハンドラ/bridge が使う既定 cwd を CLI 種別ごとに返す関数。展開済みの値を
119
+ // 返すこと (scheduler は `~` 展開も HOME 参照もしない)。
120
+ this.defaultCwd = typeof defaultCwd === "function" ? defaultCwd : () => ""
111
121
  this.generation = 0
112
122
  this.sequence = 0
113
123
  this.laneTails = new Map()
@@ -154,6 +164,21 @@ export class DispatchScheduler {
154
164
  }
155
165
  }
156
166
 
167
+ /**
168
+ * cwd 省略時にハンドラ/bridge が使う既定 cwd を、CLI 種別ごとに引く。
169
+ * ⚠️ レーン分類は全メッセージが通る経路なので、設定の読み取りが失敗しても dispatch を
170
+ * 止めない。取れなければ省略時の従来分類 (stream / control) へ戻す。
171
+ * @param {"claude"|"codex"} mode
172
+ */
173
+ _resolveDefaultCwd(mode) {
174
+ try {
175
+ const cwd = this.defaultCwd(mode)
176
+ return typeof cwd === "string" ? cwd.trim() : ""
177
+ } catch {
178
+ return ""
179
+ }
180
+ }
181
+
157
182
  _laneFor(msg) {
158
183
  const type = msg?.type
159
184
  const mutationMode = globalMutationMode(type)
@@ -163,20 +188,35 @@ export class DispatchScheduler {
163
188
  // frontend の 10 秒打ち切りで捨てられていたため分離する (2026-09-04 本番計測)。
164
189
  if (type === "usage.request") return "usage"
165
190
 
191
+ if (type === "chat.session.stop") {
192
+ if (msg.cli_kind === "claude") {
193
+ const cwd = typeof msg.cwd === "string" ? msg.cwd.trim() : ""
194
+ return cwd ? `claude:cwd:${cwd}` : "control"
195
+ }
196
+ if (msg.cli_kind === "codex" && msg.stream_id) {
197
+ return this.streamLaneKeys.get("codex:" + msg.stream_id) || `codex:stream:${msg.stream_id}`
198
+ }
199
+ return "control"
200
+ }
201
+ // chat.stop_all は全レーンに跨るため control に置く。その時点で live な会話だけを
202
+ // 止める契約であり、接続処理中のまだ live でない会話は対象外。sessions.request も control。
166
203
  const mode = chatMode(type)
167
204
  if (!mode) return "control"
168
205
 
169
206
  const streamId = typeof msg?.stream_id === "string" ? msg.stream_id : ""
170
207
  const streamKey = streamId ? `${mode}:${streamId}` : ""
208
+ const fallbackCwd = this._resolveDefaultCwd(mode)
171
209
  if (type === `${mode}.attach`) {
172
- const laneKey = attachLaneKey(mode, msg)
210
+ const laneKey = attachLaneKey(mode, msg, fallbackCwd)
173
211
  this._rememberStreamLane(streamId, laneKey)
174
212
  return laneKey
175
213
  }
176
214
  if (streamKey && this.streamLaneKeys.has(streamKey)) {
177
215
  return this.streamLaneKeys.get(streamKey)
178
216
  }
179
- const cwd = typeof msg?.cwd === "string" ? msg.cwd.trim() : ""
217
+ // attach と同じ補完を使う。片方だけ既定 cwd を見ると、同じ会話宛の upload / history 等が
218
+ // attach とは別レーンへ散り、attach で直したはずの取り違えが型ごと再発する。
219
+ const cwd = (typeof msg?.cwd === "string" ? msg.cwd.trim() : "") || fallbackCwd
180
220
  if (cwd) return `${mode}:cwd:${cwd}`
181
221
  if (streamId) return `${mode}:stream:${streamId}`
182
222
  // codex.profile.list 等の stream 非依存 read は main 側の read-only bypass
package/src/main.mjs CHANGED
@@ -115,8 +115,13 @@ import {
115
115
  shouldLaunchPaneCli,
116
116
  validateInitialPrompt,
117
117
  } from "./tmux.mjs"
118
+ import { expandTilde, prepareChatWorkspace, isCwdUnavailableError } from "./chat-workspace.mjs"
119
+ import { buildChatSessionsSnapshot } from "./chat-sessions-snapshot.mjs"
120
+ import { chatTargetsCache } from "./chat-targets.mjs"
121
+ import { ensureChatSession } from "./chat-session-starter.mjs"
122
+ import { beginRemoval } from "./chat-removal-guard.mjs"
118
123
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
119
- import { isSessionWarm } from "./live-sessions.mjs"
124
+ import { describeLiveSessionsUnion, isSessionWarm } from "./live-sessions.mjs"
120
125
  import { applyPointerContexts, resolvePointerContexts } from "./pointer-context.mjs"
121
126
  import {
122
127
  boundSessionId,
@@ -178,6 +183,26 @@ const BUNDLE_MANIFEST_PATH =
178
183
  process.env.HUB_BUNDLE_MANIFEST ||
179
184
  path.join(os.homedir(), ".claude", "scripts", "manifest.json")
180
185
 
186
+ /** 通常接続と自動起動で、現在の Claude 設定を共通に解決する。 */
187
+ export function resolveClaudeAttachOptions(config, msg = {}) {
188
+ return {
189
+ model: msg.model || config?.claude_model || null,
190
+ permissionMode:
191
+ msg.permission_mode ||
192
+ config?.claude_permission_mode ||
193
+ null,
194
+ // Phase B: チャット SDK に効くオプション (session 単位 override)。
195
+ maxTurns: typeof msg.max_turns === "number" ? msg.max_turns : null,
196
+ maxThinkingTokens:
197
+ typeof msg.max_thinking_tokens === "number"
198
+ ? msg.max_thinking_tokens
199
+ : null,
200
+ // effort (Opus 4.6+): browser がチャット既定 (agent 設定由来) を送る。
201
+ // 空文字/未指定なら null (SDK / Claude Code 既定 = Opus は high)。
202
+ effort: msg.effort || config?.claude_effort || null,
203
+ }
204
+ }
205
+
181
206
  /**
182
207
  * `~/.hub/agent.json` の Claude CLI 設定から claude コマンド文字列を組み立てる。
183
208
  *
@@ -414,6 +439,25 @@ export function isFastPathMessage(type) {
414
439
  * codex.sessions.request は rollout の disk 走査のみで app-server を spawn しない。
415
440
  * CodexStreamBridge.listThreads 側の single-flight が同一走査の重複 I/O を畳む。
416
441
  */
442
+ /**
443
+ * cwd を省略したメッセージがレーン分類で使う既定 cwd を、CLI 種別ごとに返す。
444
+ *
445
+ * ⚠️ ここが返す値は、ハンドラと bridge が実際に行う補完と **同じ結果**でなければならない。
446
+ * ずれると、同じ会話への attach が stream レーン・その会話の個別停止が cwd レーンへ散り、
447
+ * 直列化されないまま同時に走る(停止が 0 件で先に終わった後に会話が起き上がる)。
448
+ * claude … handler が `msg.cwd || config.claude_cwd`、bridge がさらに
449
+ * `|| process.env.HOME || process.cwd()` まで補う(claude-stream-bridge の attach)
450
+ * codex … handler の `msg.cwd || config.claude_cwd` まで。bridge は補完せず cwd 未設定の
451
+ * まま session を持つので、ここで HOME へ寄せない(寄せると誰も居ない cwd の
452
+ * レーンへ codex の会話を集めてしまう。codex の個別停止は stream_id の記録済み
453
+ * レーンを使うので、そもそも cwd に依存しない)
454
+ */
455
+ export function defaultLaneCwd(config, mode) {
456
+ const configured = expandTilde(config?.claude_cwd || "") || ""
457
+ if (configured || mode !== "claude") return configured
458
+ return process.env.HOME || process.cwd()
459
+ }
460
+
417
461
  export function isReadOnlyRpc(type) {
418
462
  return (
419
463
  type === "claude.history.request" ||
@@ -423,6 +467,10 @@ export function isReadOnlyRpc(type) {
423
467
  type === "profile.list" ||
424
468
  type === "codex.profile.list" ||
425
469
  type === "chat.sidebar_snapshot.request" ||
470
+ // 登録先の走査とライブ集合の読み取りだけで pane・入力・bridge の状態を変えず、
471
+ // request_id で相関するため WS 受信順に依存しない。直列レーンでは前方の重い
472
+ // attach spawn 等によりサイドバー更新が数十秒遅れるため、即時実行する。
473
+ type === "chat.sessions.request" ||
426
474
  // T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
427
475
  // かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
428
476
  // (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
@@ -1312,6 +1360,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1312
1360
  // fast-path (interrupt) / read-only RPC は従来どおり即時実行。接続 lifecycle は
1313
1361
  // scheduler 自身が常に priority 扱いする。
1314
1362
  shouldBypass: (type) => isFastPathMessage(type) || isReadOnlyRpc(type),
1363
+ // cwd 省略時にハンドラ/bridge が補う cwd を、レーン分類にも同じ結果で見せる
1364
+ // (規則と根拠は defaultLaneCwd 側にまとめてある)。scheduler には設定も path 解決も
1365
+ // 持たせず、解決済みの値だけを渡す。
1366
+ defaultCwd: (mode) => defaultLaneCwd(ctx.config, mode),
1315
1367
  })
1316
1368
  client.on("close", () => dispatchScheduler.advanceGeneration("ws_close"))
1317
1369
  client.on("open", () => dispatchScheduler.advanceGeneration("ws_open"))
@@ -1390,6 +1442,10 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1390
1442
  claudeBridge,
1391
1443
  codexBridge,
1392
1444
  logger,
1445
+ starter: ({ cwd, cliKind }) => ensureChatSession({
1446
+ cwd, cliKind, bridges: { claudeBridge, codexBridge }, logger,
1447
+ resolveClaudeOptions: () => resolveClaudeAttachOptions(ctx.config),
1448
+ }),
1393
1449
  onRestartRequested: () => restartCoordinator.request(),
1394
1450
  isInputBlocked: () => lifecycle.restartPending || lifecycle.shutdownStarted,
1395
1451
  })
@@ -4041,6 +4097,10 @@ export async function handleWorktreeCreate(msg, ctx) {
4041
4097
  tmuxOptions.codexCmd = tmuxOptions.codexCmd ?? buildCodexCmd(tmuxOptions)
4042
4098
  }
4043
4099
  const { wtName, wtPath, createdBranch } = await worktreeCreate(dir, branch)
4100
+ // ⚠️ 宛先一覧の無効化を tmux 作成の成否に依存させない。この一覧の正本はローカルの
4101
+ // ディレクトリ実体なので、worktree ができた時点で既に古い。後続の tmux 作成が失敗しても
4102
+ // worktree dir は残るため、ここで捨てないと残り TTL のあいだ実在する宛先が一覧から欠ける。
4103
+ chatTargetsCache.clear()
4044
4104
  // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
4045
4105
  // 解釈し、codex なら codex を pane 起動する(既定 claude)。
4046
4106
  // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
@@ -4072,6 +4132,73 @@ export async function handleWorktreeCreate(msg, ctx) {
4072
4132
  }
4073
4133
  }
4074
4134
 
4135
+ /**
4136
+ * 指定ディレクトリ配下を cwd とする会話 (claude / codex) すべてに対して停止 API を呼び、
4137
+ * **呼んだ本数**を返す。
4138
+ *
4139
+ * ⚠️ worktree 削除の前段専用。会話の実体が tmux から離れたので、削除前に止めるのは tmux の
4140
+ * セッションではなく会話そのものになる (ユーザー決定「会話を止めてから削除」)。
4141
+ *
4142
+ * ⚠️ **保証するのは「対象の停止 API を呼んだこと」だけ**で、下の 2 つは保証しない
4143
+ * (2026-09-16 判断)。どちらも現状の bridge では確かめる手段が無く、作るには client 管理の
4144
+ * 作り替えが要る:
4145
+ * 1. **停止が成功したこと。** bridge は内部の停止失敗を握り潰すので、呼び出し側からは失敗が
4146
+ * 見えない (claude-stream-bridge.mjs の `s.close()` を囲む `catch {}` /
4147
+ * codex-stream-bridge.mjs の `codex reap: stop threw` warn)。
4148
+ * 2. **プロセスが終了したこと。** 終了は待たない (根拠は tmux.mjs の removeWorktree 参照)。
4149
+ * したがって削除を中止するのは **呼び出しが外へ例外を投げたとき**だけで、実際にそうなるのは
4150
+ * bridge の不在・API 呼び出し不能といった構成上の失敗に限られる。
4151
+ *
4152
+ * ⚠️ 戻り値は「停止 API を呼んだ本数」であって「止まった本数」ではない。**成功件数として
4153
+ * 利用者へ見せないこと。**
4154
+ * ⚠️ 生存判定は `describeLiveSessionsUnion` に一本化する。ここで別実装を書くと、表示・配送と
4155
+ * 停止対象が無言でずれる (live-sessions.mjs の不変条件)。
4156
+ * ⚠️ 比較はパス境界を見る。`startsWith(base)` だけだと `<wt>` と `<wt>-backup` を取り違えて、
4157
+ * 消していない worktree の会話まで止めてしまう。
4158
+ *
4159
+ * @param {string} dir 削除対象の worktree パス
4160
+ * @param {{claudeBridge?: object, codexBridge?: object}} ctx
4161
+ * @param {string} [reason] exit に載る停止理由 (既定の "explicit-stop" 等と区別するため明示する)
4162
+ * @returns {Promise<number>} 停止 API を呼んだ本数 (止まった本数ではない)
4163
+ */
4164
+ export async function stopChatSessionsUnder(dir, ctx, reason = "worktree-remove") {
4165
+ const base = path.resolve(dir)
4166
+ const isUnder = (cwd) => {
4167
+ if (typeof cwd !== "string" || !cwd) return false
4168
+ const resolved = path.resolve(cwd)
4169
+ return resolved === base || resolved.startsWith(base + path.sep)
4170
+ }
4171
+ const live = describeLiveSessionsUnion({
4172
+ claudeBridge: ctx.claudeBridge,
4173
+ codexBridge: ctx.codexBridge,
4174
+ })
4175
+ const claudeCwds = new Set()
4176
+ const codexStreams = new Set()
4177
+ let stopped = 0
4178
+ // ⚠️ 例外を捕まえない。投げれば removeWorktree がその場で中断し、dir は残る (= 削除しない)。
4179
+ // 「失敗した分を集めて最後にまとめて投げる」形も書けるが、削除しないのであれば残りを止める
4180
+ // 必要はなく、「削除していないのに会話だけ止まっている」という意外な結果を作るだけだった。
4181
+ // ⚠️ ここで捕まるのは **呼び出しそのものが失敗したとき**だけ (bridge の不在・API 呼び出し
4182
+ // 不能といった構成上の失敗)。停止 API の中で起きた失敗は握り潰されて届かないので、
4183
+ // 「止まったこと」はここでは確かめられない。保証の範囲は JSDoc に書いたとおり。
4184
+ for (const session of live) {
4185
+ if (!isUnder(session?.cwd)) continue
4186
+ if (session.kind === "claude") {
4187
+ if (claudeCwds.has(session.cwd)) continue
4188
+ claudeCwds.add(session.cwd)
4189
+ stopped += await ctx.claudeBridge.stopSessionsFor({ cwd: session.cwd, reason })
4190
+ } else if (session.kind === "codex") {
4191
+ if (codexStreams.has(session.stream_id)) continue
4192
+ codexStreams.add(session.stream_id)
4193
+ stopped += await ctx.codexBridge.stopSessionForStream({
4194
+ stream_id: session.stream_id,
4195
+ reason,
4196
+ })
4197
+ }
4198
+ }
4199
+ return stopped
4200
+ }
4201
+
4075
4202
  /**
4076
4203
  * DispatchScheduler から呼ばれる本番メッセージ入口。
4077
4204
  * export はハンドラ契約を subprocess なしで検証するテスト専用の公開面でもある。
@@ -4146,6 +4273,25 @@ export async function dispatch(msg, ctx) {
4146
4273
  // (将来 cockpit から worktree path を指定する用)。
4147
4274
  // model / permissionMode は agent.json の値も使えるよう fallback。
4148
4275
  const cwdArg = msg.cwd || ctx.config?.claude_cwd || undefined
4276
+ try {
4277
+ await prepareChatWorkspace({
4278
+ waitForSync: false,
4279
+ cwd: cwdArg || process.env.HOME || process.cwd(),
4280
+ cliKind: "claude",
4281
+ logger: ctx.logger,
4282
+ })
4283
+ } catch (err) {
4284
+ // Claude は attach で会話を登録するだけで、プロセス起動前に ready を返す。
4285
+ // cwd が無くても live・配送対象に載るため、起動時の失敗に頼らず登録前に止める。
4286
+ if (isCwdUnavailableError(err)) {
4287
+ ctx.client.send({ type: "claude.error", stream_id, error: `workspace_missing: ${err.message}` })
4288
+ return
4289
+ }
4290
+ ctx.logger?.warn?.(
4291
+ { cwd: cwdArg, err: err?.message || String(err) },
4292
+ "claude.attach workspace preparation failed (attach continues)",
4293
+ )
4294
+ }
4149
4295
  const resumeSessionId = msg.resume_session_id || null
4150
4296
  // attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
4151
4297
  // 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
@@ -4162,20 +4308,7 @@ export async function dispatch(msg, ctx) {
4162
4308
  const info = ctx.claudeBridge.attach({
4163
4309
  stream_id,
4164
4310
  cwd: cwdArg,
4165
- model: msg.model || ctx.config?.claude_model || null,
4166
- permissionMode:
4167
- msg.permission_mode ||
4168
- ctx.config?.claude_permission_mode ||
4169
- null,
4170
- // Phase B: チャット SDK に効くオプション (session 単位 override)。
4171
- maxTurns: typeof msg.max_turns === "number" ? msg.max_turns : null,
4172
- maxThinkingTokens:
4173
- typeof msg.max_thinking_tokens === "number"
4174
- ? msg.max_thinking_tokens
4175
- : null,
4176
- // effort (Opus 4.6+): browser がチャット既定 (agent 設定由来) を送る。
4177
- // 空文字/未指定なら null (SDK / Claude Code 既定 = Opus は high)。
4178
- effort: msg.effort || ctx.config?.claude_effort || null,
4311
+ ...resolveClaudeAttachOptions(ctx.config, msg),
4179
4312
  resumeSessionId,
4180
4313
  })
4181
4314
  ctx.client.send(buildClaudeReadyPayload(stream_id, info))
@@ -4605,6 +4738,24 @@ export async function dispatch(msg, ctx) {
4605
4738
  }
4606
4739
  try {
4607
4740
  const resolvedCwd = msg.cwd || ctx.config?.claude_cwd || undefined
4741
+ try {
4742
+ await prepareChatWorkspace({
4743
+ waitForSync: false,
4744
+ cwd: resolvedCwd || process.env.HOME || process.cwd(),
4745
+ cliKind: "codex",
4746
+ logger: ctx.logger,
4747
+ })
4748
+ } catch (err) {
4749
+ // Claude と同じく、cwd の利用不能が確定したら会話の登録前に止める。
4750
+ if (isCwdUnavailableError(err)) {
4751
+ ctx.client.send({ type: "codex.error", stream_id, error: `workspace_missing: ${err.message}` })
4752
+ return
4753
+ }
4754
+ ctx.logger?.warn?.(
4755
+ { cwd: resolvedCwd, err: err?.message || String(err) },
4756
+ "codex.attach workspace preparation failed (attach continues)",
4757
+ )
4758
+ }
4608
4759
  const resumeThreadId = msg.resume_thread_id || null
4609
4760
  const info = await ctx.codexBridge.attach({
4610
4761
  stream_id,
@@ -5430,6 +5581,82 @@ export async function dispatch(msg, ctx) {
5430
5581
  }
5431
5582
  return
5432
5583
  }
5584
+ case "chat.session.stop": {
5585
+ const result = { type: "chat.session.stop.result", request_id: msg.request_id, stopped: 0 }
5586
+ try {
5587
+ if (msg.cli_kind === "claude") {
5588
+ const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
5589
+ const session_id = typeof msg.session_id === "string" ? msg.session_id : ""
5590
+ if (!cwd || !path.isAbsolute(cwd)) result.error = "cwd required"
5591
+ else if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(session_id)) {
5592
+ result.error = "invalid session_id"
5593
+ } else {
5594
+ result.stopped = await ctx.claudeBridge.stopSessionForDeletion({ cwd, session_id, reason: "chat-stop" })
5595
+ }
5596
+ } else if (msg.cli_kind === "codex") {
5597
+ if (typeof msg.stream_id !== "string" || !msg.stream_id) result.error = "stream_id required"
5598
+ else result.stopped = await ctx.codexBridge.stopSessionForStream({ stream_id: msg.stream_id, reason: "chat-stop" })
5599
+ } else {
5600
+ result.error = "unsupported cli_kind"
5601
+ }
5602
+ } catch (err) {
5603
+ result.error = err?.message || String(err)
5604
+ }
5605
+ ctx.client.send(result)
5606
+ return
5607
+ }
5608
+ case "chat.stop_all": {
5609
+ // exit に載る停止理由。bridge 側の既定 ("tui-bind-stop" / "explicit-stop") は
5610
+ // それぞれ TUI 切替・個別停止の名前なので、全停止はここで明示して区別する。
5611
+ const STOP_ALL_REASON = "chat-stop-all"
5612
+ const result = { type: "chat.stop_all.result", request_id: msg.request_id, stopped: 0, failed: [] }
5613
+ try {
5614
+ const live = describeLiveSessionsUnion({ claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge })
5615
+ const claudeCwds = new Set()
5616
+ const codexStreams = new Set()
5617
+ for (const session of live) {
5618
+ let target
5619
+ try {
5620
+ if (session.kind === "claude") {
5621
+ const cwd = session.cwd
5622
+ if (claudeCwds.has(cwd)) continue
5623
+ claudeCwds.add(cwd)
5624
+ // ⚠️ reason を省くと stopSessionsFor の既定 "tui-bind-stop" が exit に載り、
5625
+ // 画面から止めた会話が「tmux へ切り替えたせい」で消えたように見える。
5626
+ target = { cwd, reason: STOP_ALL_REASON }
5627
+ result.stopped += await ctx.claudeBridge.stopSessionsFor(target)
5628
+ } else if (session.kind === "codex") {
5629
+ const stream_id = session.stream_id
5630
+ if (codexStreams.has(stream_id)) continue
5631
+ codexStreams.add(stream_id)
5632
+ target = { stream_id, reason: STOP_ALL_REASON }
5633
+ result.stopped += await ctx.codexBridge.stopSessionForStream(target)
5634
+ }
5635
+ } catch (err) {
5636
+ result.failed.push({ ...target, reason: err?.message || String(err) })
5637
+ }
5638
+ }
5639
+ } catch (err) {
5640
+ result.error = err?.message || String(err)
5641
+ }
5642
+ ctx.client.send(result)
5643
+ return
5644
+ }
5645
+ case "chat.sessions.request": {
5646
+ try {
5647
+ const snapshot = await buildChatSessionsSnapshot({
5648
+ bridges: { claudeBridge: ctx.claudeBridge, codexBridge: ctx.codexBridge },
5649
+ logger: ctx.logger,
5650
+ })
5651
+ ctx.client.send({ type: "chat.sessions", request_id: msg.request_id, ...snapshot })
5652
+ } catch (err) {
5653
+ ctx.client.send({
5654
+ type: "chat.sessions", request_id: msg.request_id, targets: [], live: [],
5655
+ error: err?.message || String(err),
5656
+ })
5657
+ }
5658
+ return
5659
+ }
5433
5660
  case "tmux.list_sessions": {
5434
5661
  try {
5435
5662
  const snapshot = await tmuxSessionsSnapshotCache.get(async () => {
@@ -5589,8 +5816,30 @@ export async function dispatch(msg, ctx) {
5589
5816
  }
5590
5817
  const force = msg.force === true
5591
5818
  try {
5592
- const result = await removeWorktreeDir(name, { force })
5593
- if (result.dirty !== true) tmuxSessionsSnapshotCache.clear()
5819
+ const removeWorktree = ctx._removeWorktreeDir || removeWorktreeDir
5820
+ let result
5821
+ let releaseRemoval
5822
+ try {
5823
+ result = await removeWorktree(name, {
5824
+ force,
5825
+ // ⚠️ 会話の停止は dir 削除の **前**に置く(removeWorktree 側の契約)。ここで渡さないと
5826
+ // 会話が生きたまま cwd が消える。dirty で中止した場合は呼ばれない(削除しないのに
5827
+ // 会話だけ止めない)。
5828
+ stopSessions: (wtPath) => {
5829
+ // ⚠️ 万一 2 回呼ばれても前の登録を漏らさない。beginRemoval は参照カウントなので、
5830
+ // 解除し損ねるとそのパスへ以後 attach できなくなる(プロセスを再起動するまで)。
5831
+ releaseRemoval?.()
5832
+ releaseRemoval = beginRemoval(wtPath)
5833
+ return stopChatSessionsUnder(wtPath, ctx)
5834
+ },
5835
+ })
5836
+ } finally {
5837
+ releaseRemoval?.()
5838
+ }
5839
+ if (result.dirty !== true) {
5840
+ tmuxSessionsSnapshotCache.clear()
5841
+ chatTargetsCache.clear()
5842
+ }
5594
5843
  ctx.client.send({
5595
5844
  type: "worktree.remove.result",
5596
5845
  request_id: msg.request_id,
@@ -5600,6 +5849,11 @@ export async function dispatch(msg, ctx) {
5600
5849
  dirty: result.dirty === true,
5601
5850
  changes: result.changes || [],
5602
5851
  unpushed_commits: result.unpushed_commits || 0,
5852
+ // ⚠️ 削除の前に **停止 API を呼んだ**会話の本数。「止まった本数」ではないので、
5853
+ // 画面で成功件数として見せないこと(bridge は内部の停止失敗を握り潰すため、
5854
+ // 止まったかどうかはこの経路では分からない。詳細は stopChatSessionsUnder の JSDoc)。
5855
+ // 呼び出しそのものが失敗した場合は削除せず ok:false になるので、この応答には来ない。
5856
+ stop_requested_sessions: result.stop_requested_sessions || 0,
5603
5857
  })
5604
5858
  } catch (err) {
5605
5859
  ctx.client.send({