@cocorograph/hub-agent 0.8.75 → 0.8.77

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.
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
  })
@@ -2277,6 +2333,7 @@ export function buildClaudeReadyPayload(streamId, info) {
2277
2333
  turn_active: info?.turn_active === true,
2278
2334
  history_pushed: info?.resuming === true,
2279
2335
  supports_queue_input_id: true,
2336
+ supports_review_events: true,
2280
2337
  supports_queue_snapshot: true,
2281
2338
  supports_session_options_cas: true,
2282
2339
  }
@@ -2328,6 +2385,7 @@ export function buildCodexReadyPayload(streamId, info, queueState) {
2328
2385
  // Browser は旧 hub-agent との混在期間、この明示 capability が true のときだけ
2329
2386
  // 子 thread の個別停止 UI を有効化する。省略は未対応を意味する。
2330
2387
  supports_subagent_interrupt: true,
2388
+ supports_review_events: true,
2331
2389
  // 旧 agent との混在時、取消結果を待てることを明示する。
2332
2390
  supports_queue_cancel_response: true,
2333
2391
  }
@@ -4039,6 +4097,10 @@ export async function handleWorktreeCreate(msg, ctx) {
4039
4097
  tmuxOptions.codexCmd = tmuxOptions.codexCmd ?? buildCodexCmd(tmuxOptions)
4040
4098
  }
4041
4099
  const { wtName, wtPath, createdBranch } = await worktreeCreate(dir, branch)
4100
+ // ⚠️ 宛先一覧の無効化を tmux 作成の成否に依存させない。この一覧の正本はローカルの
4101
+ // ディレクトリ実体なので、worktree ができた時点で既に古い。後続の tmux 作成が失敗しても
4102
+ // worktree dir は残るため、ここで捨てないと残り TTL のあいだ実在する宛先が一覧から欠ける。
4103
+ chatTargetsCache.clear()
4042
4104
  // worktree dir で tmux session を作成。tmux.create_session と同じく cli_kind を
4043
4105
  // 解釈し、codex なら codex を pane 起動する(既定 claude)。
4044
4106
  // T05945: セッション名は cli_kind に応じて `<wt>` / `<wt>__codex` へ分ける。これで
@@ -4070,6 +4132,73 @@ export async function handleWorktreeCreate(msg, ctx) {
4070
4132
  }
4071
4133
  }
4072
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
+
4073
4202
  /**
4074
4203
  * DispatchScheduler から呼ばれる本番メッセージ入口。
4075
4204
  * export はハンドラ契約を subprocess なしで検証するテスト専用の公開面でもある。
@@ -4144,6 +4273,25 @@ export async function dispatch(msg, ctx) {
4144
4273
  // (将来 cockpit から worktree path を指定する用)。
4145
4274
  // model / permissionMode は agent.json の値も使えるよう fallback。
4146
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
+ }
4147
4295
  const resumeSessionId = msg.resume_session_id || null
4148
4296
  // attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
4149
4297
  // 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
@@ -4160,23 +4308,19 @@ export async function dispatch(msg, ctx) {
4160
4308
  const info = ctx.claudeBridge.attach({
4161
4309
  stream_id,
4162
4310
  cwd: cwdArg,
4163
- model: msg.model || ctx.config?.claude_model || null,
4164
- permissionMode:
4165
- msg.permission_mode ||
4166
- ctx.config?.claude_permission_mode ||
4167
- null,
4168
- // Phase B: チャット SDK に効くオプション (session 単位 override)。
4169
- maxTurns: typeof msg.max_turns === "number" ? msg.max_turns : null,
4170
- maxThinkingTokens:
4171
- typeof msg.max_thinking_tokens === "number"
4172
- ? msg.max_thinking_tokens
4173
- : null,
4174
- // effort (Opus 4.6+): browser がチャット既定 (agent 設定由来) を送る。
4175
- // 空文字/未指定なら null (SDK / Claude Code 既定 = Opus は high)。
4176
- effort: msg.effort || ctx.config?.claude_effort || null,
4311
+ ...resolveClaudeAttachOptions(ctx.config, msg),
4177
4312
  resumeSessionId,
4178
4313
  })
4179
4314
  ctx.client.send(buildClaudeReadyPayload(stream_id, info))
4315
+ try {
4316
+ if (resumeSessionId) {
4317
+ void ctx.siblingInjectBridge
4318
+ ?.replayReviews?.("claude", cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
4319
+ ?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events"))
4320
+ }
4321
+ } catch (err) {
4322
+ ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
4323
+ }
4180
4324
  if (resumeSessionId) {
4181
4325
  void ctx.siblingInjectBridge
4182
4326
  ?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
@@ -4594,6 +4738,24 @@ export async function dispatch(msg, ctx) {
4594
4738
  }
4595
4739
  try {
4596
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
+ }
4597
4759
  const resumeThreadId = msg.resume_thread_id || null
4598
4760
  const info = await ctx.codexBridge.attach({
4599
4761
  stream_id,
@@ -4610,6 +4772,15 @@ export async function dispatch(msg, ctx) {
4610
4772
  ctx.client.send(
4611
4773
  buildCodexReadyPayload(stream_id, info, ctx.codexBridge.queueState(stream_id)),
4612
4774
  )
4775
+ try {
4776
+ if (info.threadId) {
4777
+ void ctx.siblingInjectBridge
4778
+ ?.replayReviews?.("codex", resolvedCwd || process.env.HOME || process.cwd(), info.threadId)
4779
+ ?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events"))
4780
+ }
4781
+ } catch (err) {
4782
+ ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
4783
+ }
4613
4784
  // S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
4614
4785
  // 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
4615
4786
  // 既に分かっている threadId を sessionId として記録する (直後の turn/started が
@@ -5410,6 +5581,82 @@ export async function dispatch(msg, ctx) {
5410
5581
  }
5411
5582
  return
5412
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
+ }
5413
5660
  case "tmux.list_sessions": {
5414
5661
  try {
5415
5662
  const snapshot = await tmuxSessionsSnapshotCache.get(async () => {
@@ -5569,8 +5816,30 @@ export async function dispatch(msg, ctx) {
5569
5816
  }
5570
5817
  const force = msg.force === true
5571
5818
  try {
5572
- const result = await removeWorktreeDir(name, { force })
5573
- 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
+ }
5574
5843
  ctx.client.send({
5575
5844
  type: "worktree.remove.result",
5576
5845
  request_id: msg.request_id,
@@ -5580,6 +5849,11 @@ export async function dispatch(msg, ctx) {
5580
5849
  dirty: result.dirty === true,
5581
5850
  changes: result.changes || [],
5582
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,
5583
5857
  })
5584
5858
  } catch (err) {
5585
5859
  ctx.client.send({
@@ -0,0 +1,141 @@
1
+ import fs from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { createHash, randomUUID } from "node:crypto"
4
+
5
+ // Maximum retention; the IPC temp directory can be removed earlier by the OS.
6
+ const RETENTION = 30 * 24 * 60 * 60 * 1000
7
+ export const REVIEW_SESSION_MAX_BYTES = 4 * 1024 * 1024
8
+ const FIELDS = new Set([
9
+ "type", "subtype", "schema_version", "review_id", "phase", "reviewer", "round",
10
+ "workdir", "timestamp", "report", "verdict", "summary", "pid", "pid_started_at",
11
+ ])
12
+
13
+ export function validReviewEvent(event) {
14
+ if (!event || typeof event !== "object" || Array.isArray(event)) return false
15
+ if (Object.keys(event).some((key) => !FIELDS.has(key))) return false
16
+ if (Buffer.byteLength(JSON.stringify(event)) > 512 * 1024) return false
17
+ if (event.type !== "system" || event.subtype !== "cross_review" || event.schema_version !== 1) return false
18
+ if (typeof event.review_id !== "string" || !/^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(event.review_id)) return false
19
+ if (!["started", "completed", "failed", "stopped"].includes(event.phase)) return false
20
+ if (!["claude", "codex"].includes(event.reviewer)) return false
21
+ if (!Number.isSafeInteger(event.round) || event.round <= 0) return false
22
+ if (typeof event.workdir !== "string" || !path.isAbsolute(event.workdir)) return false
23
+ if (typeof event.timestamp !== "string" || !/^\d{4}-\d\d-\d\dT/.test(event.timestamp) || !Number.isFinite(Date.parse(event.timestamp))) return false
24
+ if (["report", "summary"].some((key) => event[key] !== undefined && typeof event[key] !== "string")) return false
25
+ if (event.pid !== undefined && (!Number.isSafeInteger(event.pid) || event.pid <= 0)) return false
26
+ if (event.pid_started_at !== undefined && typeof event.pid_started_at !== "string") return false
27
+ if (event.verdict !== undefined && !["pass", "changes_requested"].includes(event.verdict)) return false
28
+ return event.phase !== "completed" || (typeof event.report === "string" && event.verdict !== undefined)
29
+ }
30
+
31
+ /** Single daemon writer; atomic snapshots preserve start and first terminal independently.
32
+ * Synthetic stopped is also final. Replay is best-effort across daemon restarts, not OS temp cleanup.
33
+ */
34
+ export class ReviewEventStore {
35
+ constructor(dir, now = Date.now) {
36
+ this.dir = dir
37
+ this.now = now
38
+ this.queue = Promise.resolve()
39
+ this.lastSweep = 0
40
+ }
41
+ _serial(operation) {
42
+ const pending = this.queue.then(operation)
43
+ this.queue = pending.catch(() => {})
44
+ return pending
45
+ }
46
+ _file(kind, cwd, sessionId) {
47
+ const hash = createHash("sha256").update(JSON.stringify([kind, cwd, sessionId])).digest("hex")
48
+ return path.join(this.dir, hash + ".json")
49
+ }
50
+ async _checkDirectory() {
51
+ const info = await fs.lstat(this.dir)
52
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o700) {
53
+ throw new Error("unsafe review directory")
54
+ }
55
+ }
56
+ async _read(file, target) {
57
+ try {
58
+ await this._checkDirectory()
59
+ const info = await fs.lstat(file)
60
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
61
+ throw new Error("unsafe review store")
62
+ }
63
+ if (info.size > REVIEW_SESSION_MAX_BYTES) throw new Error("review store exceeds size limit")
64
+ const snapshot = JSON.parse(await fs.readFile(file, "utf8"))
65
+ if (JSON.stringify(snapshot.target) !== JSON.stringify(target) || !Array.isArray(snapshot.rows) || snapshot.rows.length > 100) {
66
+ throw new Error("invalid review store target or rows")
67
+ }
68
+ const ids = new Set()
69
+ for (const row of snapshot.rows) {
70
+ if (!row || !Number.isFinite(row.at) || ids.has(row.id) || (!row.started && !row.terminal)) {
71
+ throw new Error("invalid review store row")
72
+ }
73
+ ids.add(row.id)
74
+ for (const key of ["started", "terminal"]) {
75
+ const event = row[key]
76
+ if (event && (!validReviewEvent(event) || event.review_id !== row.id || (key === "started") !== (event.phase === "started"))) {
77
+ throw new Error("invalid review store event")
78
+ }
79
+ }
80
+ }
81
+ return snapshot.rows.filter((row) => row.at >= this.now() - RETENTION)
82
+ } catch (err) {
83
+ if (err.code === "ENOENT") return []
84
+ throw err
85
+ }
86
+ }
87
+ events(kind, cwd, sessionId) {
88
+ return this._serial(async () => {
89
+ const rows = await this._read(this._file(kind, cwd, sessionId), [kind, cwd, sessionId])
90
+ return rows.flatMap((row) => [row.started, row.terminal].filter(Boolean))
91
+ })
92
+ }
93
+ record(kind, cwd, sessionId, event) {
94
+ return this._serial(() => this._record(kind, cwd, sessionId, event))
95
+ }
96
+ async _record(kind, cwd, sessionId, event) {
97
+ if (!validReviewEvent(event)) throw new Error("invalid review event")
98
+ await fs.mkdir(this.dir, { recursive: true, mode: 0o700 })
99
+ await this._checkDirectory()
100
+ if (this.now() - this.lastSweep >= 86400 * 1000) {
101
+ for (const name of await fs.readdir(this.dir)) {
102
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) continue
103
+ const file = path.join(this.dir, name)
104
+ if ((await fs.lstat(file)).mtimeMs < this.now() - RETENTION) await fs.unlink(file)
105
+ }
106
+ this.lastSweep = this.now()
107
+ }
108
+ const file = this._file(kind, cwd, sessionId)
109
+ const target = [kind, cwd, sessionId]
110
+ const rows = await this._read(file, target)
111
+ let row = rows.find((r) => r.id === event.review_id)
112
+ const key = event.phase === "started" ? "started" : "terminal"
113
+ if (row?.[key]) return { duplicate: true, events: [] }
114
+ if (!row) {
115
+ row = { id: event.review_id, at: this.now() }
116
+ rows.push(row)
117
+ }
118
+ row[key] = event
119
+ row.at = this.now()
120
+ let serialized = JSON.stringify({ target, rows })
121
+ while (rows.length > 100 || Buffer.byteLength(serialized) > REVIEW_SESSION_MAX_BYTES) {
122
+ // Prefer evicting old completed reviews, preserving the incoming review and active work.
123
+ let index = rows.findIndex((candidate) => candidate !== row && candidate.terminal)
124
+ if (index < 0) index = rows.findIndex((candidate) => candidate !== row)
125
+ rows.splice(index, 1)
126
+ serialized = JSON.stringify({ target, rows })
127
+ }
128
+ const tmp = file + "." + randomUUID() + ".tmp"
129
+ try {
130
+ await fs.writeFile(tmp, serialized, { mode: 0o600, flag: "wx" })
131
+ await fs.rename(tmp, file)
132
+ } finally {
133
+ try {
134
+ await fs.unlink(tmp)
135
+ } catch (err) {
136
+ if (err.code !== "ENOENT") throw err
137
+ }
138
+ }
139
+ return { duplicate: false, events: key === "started" && row.terminal ? [event, row.terminal] : [event] }
140
+ }
141
+ }