@cocorograph/hub-agent 0.7.104 → 0.8.0

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
@@ -37,7 +37,6 @@ import {
37
37
  resolveSyncTargets,
38
38
  syncBundleTargets,
39
39
  } from "./hub-bundle.mjs"
40
- import { extractLastAssistantText, pickContextText } from "./extract-paragraph.mjs"
41
40
  import { loadPlugins, runHookBroadcast, runHookChain } from "./plugin-loader.mjs"
42
41
  import { WsClient } from "./ws-client.mjs"
43
42
  import { PtyBridge } from "./pty-bridge.mjs"
@@ -47,11 +46,9 @@ import { CodexStreamBridge, classifyCodexInputError } from "./codex-stream-bridg
47
46
  import { UploadManager } from "./claude-upload.mjs"
48
47
  import { requestSelfUninstall } from "./service-install.mjs"
49
48
  import {
50
- decideSessionRotation,
51
49
  fetchSessionHistory,
52
50
  jsonlPath,
53
51
  listSessions,
54
- validateRotationCandidate,
55
52
  } from "./claude-history.mjs"
56
53
  import { listAgents } from "./agents.mjs"
57
54
  import { listSkills } from "./skills.mjs"
@@ -61,7 +58,6 @@ import {
61
58
  detectSessionState,
62
59
  invalidateSessionCache,
63
60
  listSessionStates,
64
- resolveBindSnapshotStatus,
65
61
  StallTracker,
66
62
  } from "./state.mjs"
67
63
  import {
@@ -72,6 +68,7 @@ import {
72
68
  getActiveProfile,
73
69
  getActiveProjectsRoot,
74
70
  readProfileAccount,
71
+ resolveProjectsRoot,
75
72
  setActiveProfile,
76
73
  addProfile,
77
74
  renameProfile,
@@ -84,36 +81,22 @@ import {
84
81
  profileHasKeychainCredentials,
85
82
  } from "./claude-credentials.mjs"
86
83
  import {
87
- answerAskUserQuestion,
88
84
  buildClaudeCmd,
89
85
  createSession as createTmuxSession,
90
86
  createWorktreeDir,
91
- cyclePermissionMode,
92
87
  execTmux,
93
88
  killManySessions,
94
89
  killSession as killTmuxSession,
95
90
  listSessions as listTmuxSessions,
96
91
  listWorktreeNameHistory,
97
92
  listWorktreeStubs,
98
- isPaneRunningClaude,
99
93
  paneClaudeAliveOrUnknown,
100
- rebindClaudeSession,
101
- shouldSkipRebindRespawn,
102
- recoverTuiInput,
103
94
  removeWorktree as removeWorktreeDir,
104
- resumeWithMessage,
105
- pasteToSessionByName,
106
- sendInterruptKey,
107
95
  setSessionMouse,
108
96
  setTmuxGlobalEnv,
109
- setTuiEffort,
110
- setTuiModel,
111
97
  startTerminalMirror,
112
98
  stopTerminalMirror,
113
99
  } from "./tmux.mjs"
114
- import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
115
- import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
116
- import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
117
100
  import { SiblingInjectBridge } from "./sibling-inject.mjs"
118
101
  import {
119
102
  boundSessionId,
@@ -153,27 +136,6 @@ import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
153
136
 
154
137
  const logger = pino({ name: "hub-agent" })
155
138
 
156
- // 遅延回答 resume: 書き込んだ .decision がフックに消費されない (= フック死亡) かを
157
- // 判定する猶予 (ms)。生存フックは ~0.15s で消費するので 2s 残れば死亡とみなす。
158
- const RESUME_DECISION_GRACE_MS = 2000
159
- // resume の二重実行防止 (request_id -> 実施時刻ms)。低頻度なので Map で十分。
160
- const _tuiResumed = new Map()
161
- function _markResumed(requestId) {
162
- const now = Date.now()
163
- // 古い項目を掃除 (10 分超)。
164
- for (const [id, at] of _tuiResumed) {
165
- if (now - at > 600000) _tuiResumed.delete(id)
166
- }
167
- if (_tuiResumed.has(requestId)) return false
168
- _tuiResumed.set(requestId, now)
169
- return true
170
- }
171
- /** 遅延回答を新メッセージとして tmux セッションへ届けて会話を再開する。 */
172
- async function _resumeTuiAnswer(sessionName, resumeText, requestId) {
173
- if (!sessionName || !resumeText) return
174
- if (!_markResumed(requestId)) return // 二重実行防止
175
- await resumeWithMessage(sessionName, resumeText, { logger })
176
- }
177
139
 
178
140
  const BUNDLE_MANIFEST_PATH =
179
141
  process.env.HUB_BUNDLE_MANIFEST ||
@@ -342,6 +304,33 @@ export function isFastPathMessage(type) {
342
304
  )
343
305
  }
344
306
 
307
+ /**
308
+ * Phase 3 (Read-plane 2026-07-24): タイムライン/セッション一覧の「読み取り専用 RPC」を判定する。
309
+ * これらは disk (jsonl) / codex rollout を読んで自前の response を送るだけで pane・入力・bridge の
310
+ * 状態を一切変更しない (ハンドラ本体で確認済み: claude.history.request=fetchSessionHistory /
311
+ * claude.sessions.request=listSessions / codex.sessions.request=listThreads、いずれも純粋読み取り)。
312
+ * メッセージ自身が cwd/session_id/stream_id を持ち、frontend も stream_id/cwd 照合で stale 応答を
313
+ * 捨てるため、WS 受信順に対する順序依存が無い。
314
+ *
315
+ * これを直列 dispatchChain から外す理由 (view/spawn 分離の核): 直列キューは「WS 受信順 = pane 反映順」
316
+ * を守るためのものだが、その代償として前方の重い処理 (codex.sessions.request/codex.attach の
317
+ * app-server spawn = 数秒、claude.sessions.request の全ファイル読み) が後続の claude.attach を
318
+ * head-of-line blocking し、attach→ready を数十秒遅延させていた (本番計測 cold p95 40〜54s)。
319
+ * claude/codex が同一 dispatchChain を共有するため codex の spawn が claude の切替まで巻き込む。
320
+ * 読み取り RPC を並行レーンへ逃がすことで、タイムライン閲覧が live 接続 (spawn) の完了を待たなくなる。
321
+ *
322
+ * ⚠️ codex.sessions.request は listThreads が app-server を spawn するため、off-queue 化の副作用で
323
+ * 同一 cwd への同時リクエストが spawn storm を起こしうる。CodexStreamBridge.listThreads 側に
324
+ * single-flight ガードを併設して同時 spawn を 1 本に畳む (B で rollout disk 走査へ置換すれば spawn 自体が消える)。
325
+ */
326
+ export function isReadOnlyRpc(type) {
327
+ return (
328
+ type === "claude.history.request" ||
329
+ type === "claude.sessions.request" ||
330
+ type === "codex.sessions.request"
331
+ )
332
+ }
333
+
345
334
  /**
346
335
  * active プロファイルの configDir を process.env.CLAUDE_CONFIG_DIR へ反映する。
347
336
  * 既定 (`~/.claude`) のときは変数を消して従来挙動 (Claude Code の既定パス) に戻す。
@@ -1122,6 +1111,20 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1122
1111
  })
1123
1112
  return
1124
1113
  }
1114
+ // Phase 3 (Read-plane): 読み取り専用 RPC (history/sessions) も直列キューから外して並行処理する。
1115
+ // 直列キュー前方の重い処理 (codex app-server spawn 等) が claude.attach→ready を HoL blocking
1116
+ // するのを防ぐ (view/spawn 分離)。純粋読み取りで順序依存が無いことは isReadOnlyRpc の説明参照。
1117
+ if (isReadOnlyRpc(msg?.type)) {
1118
+ Promise.resolve(
1119
+ dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }),
1120
+ ).catch((err) => {
1121
+ logger.error(
1122
+ { err: err.message, type: msg?.type },
1123
+ "dispatch threw (bypassed read-only RPC lane)",
1124
+ )
1125
+ })
1126
+ return
1127
+ }
1125
1128
  dispatchChain = dispatchChain
1126
1129
  .then(() => dispatch(msg, { ...ctx, client, ptyBridge, claudeBridge, codexBridge, codexMcpControl, uploadManager }))
1127
1130
  .catch((err) => {
@@ -1149,50 +1152,17 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1149
1152
  // ready-for-input 判定: claude プロセスの子プロセスを内省し「Stop フック/ツール実行が
1150
1153
  // 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
1151
1154
  // Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
1152
- // TUI の tmux session_name → Claude session_id。state loop 起動より**前**に作る。
1153
- // 後で作ると state loop には undefined が渡り、バックグラウンド時だけ cwd 最新 JSONL
1154
- // へフォールバックして別会話の context% を表示する。
1155
+ // state loop に渡す boundSessions (tmux session_name → session_id) の器。
1156
+ // ⚠️ 純 SDK チャット化 (2026-07) で唯一の writer だった claude.tui.bind が消えたため、
1157
+ // この Map は **常に空**。それでも state loop 起動より前に「空 Map として」用意する:
1158
+ // undefined を渡すと state loop がバックグラウンド時に cwd 最新 JSONL へフォールバックして
1159
+ // 別会話の context% を出す。空 Map なら boundSessionId が常に null を返し、
1160
+ // resolveStateLoopBoundSessionId の「SDK チャットは bind 台帳を持たないので claudeBridge の
1161
+ // ライブセッションへフォールバック」経路 (下記) が正しく効く。
1155
1162
  ctx.tuiReboundSessions = new Map()
1156
1163
 
1157
1164
  const readinessTracker = new ReadinessTracker()
1158
- // claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
1159
- // respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
1160
1165
  ctx.readinessTracker = readinessTracker
1161
- // 【+新規 defer — 構造版 root 2026-06-22】busy (ターン進行中) のペインへ来た fresh (+新規) は
1162
- // respawn-pane -k で殺さず保留し、ready (ターン終了) で 1 回だけ実行する。
1163
- // session_name → {cwd, request_id, at, model, permissionMode}。
1164
- const pendingFreshRespawn = new Map()
1165
- ctx.pendingFreshRespawn = pendingFreshRespawn
1166
- // fresh respawn のスロットル: idle ペインへの remount storm (再マウント毎に新 request_id) で
1167
- // fresh が連打され new セッションを乱造するのを防ぐ。session_name → epoch ms。
1168
- const lastFreshRespawnAt = new Map()
1169
- ctx.lastFreshRespawnAt = lastFreshRespawnAt
1170
- // ready エッジで呼ばれ、保留中の +新規 を実行する (busy が解けたので非破壊)。
1171
- const flushDeferredFreshRespawn = async (name) => {
1172
- const pending = pendingFreshRespawn.get(name)
1173
- if (!pending) return
1174
- pendingFreshRespawn.delete(name)
1175
- if (Date.now() - pending.at > PENDING_FRESH_TTL_MS) {
1176
- logger.info({ session: name }, "tui rebind: deferred +new expired before ready, dropped")
1177
- return
1178
- }
1179
- try {
1180
- const rebind = await rebindClaudeSession(name, null, {
1181
- cwd: pending.cwd,
1182
- model: pending.model || "",
1183
- permissionMode: pending.permissionMode || "",
1184
- fresh: true,
1185
- busy: false,
1186
- logger,
1187
- })
1188
- if (rebind.ok) {
1189
- lastFreshRespawnAt.set(name, Date.now())
1190
- logger.info({ session: name }, "tui rebind: executed deferred +new after ready")
1191
- }
1192
- } catch (err) {
1193
- logger.warn({ session: name, err: err?.message }, "deferred +new respawn failed")
1194
- }
1195
- }
1196
1166
  const stateLoop = startStateLoop({
1197
1167
  client,
1198
1168
  plugins,
@@ -1228,79 +1198,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1228
1198
  logger,
1229
1199
  tracker: readinessTracker,
1230
1200
  intervalMs: 700,
1231
- onReady: flushDeferredFreshRespawn,
1232
1201
  })
1233
1202
 
1234
- // TUI 権限ブリッジ: 対話 TUI の PreToolUse フックが書く権限要求を fs.watch で
1235
- // 拾い、既存 SDK 経路と同じ `claude.permission.request` として push する。
1236
- // 決定 (claude.permission.reply) は dispatch 側で request_id を見て
1237
- // tuiPermissionBridge.resolve() に優先ルーティングする。
1238
- const tuiPermissionBridge = new TuiPermissionBridge({ logger })
1239
- tuiPermissionBridge.on(
1240
- "permission",
1241
- async ({ request_id, session_id, cwd, tool_name, input, context_text }) => {
1242
- if (cwd) {
1243
- try {
1244
- // TUI 権限ブリッジ (PreToolUse フック) は Claude Code TUI 専用の経路なので
1245
- // 常に claude バケットへ記録する (T05945)。
1246
- recordChatActivity(cwd, "claude", { inputPending: true })
1247
- } catch {
1248
- /* ignore */
1249
- }
1250
- }
1251
- // ── 直前アシスタント説明の抽出 (症状4 根治) ─────────────────────────────
1252
- // PreToolUse フック発火時点で jsonl はまだ前ターン止まり (フックがブロック
1253
- // している間 claude は現ターンを jsonl に commit しない=デッドロック構造) の
1254
- // ため、フック側の transcript 抽出は前ターンの古い説明を掴む「取り違え」を
1255
- // 起こす。代わりに hub-agent がここで tmux capture-pane を打ち、TUI のペイン上
1256
- // に既に描画されている「現ターンの assistant 本文段落 (⏺ プレフィックス)」を
1257
- // 抜き出す。これが回答前に説明を届ける唯一の信頼ソースなので、フック由来の
1258
- // context_text の有無に関わらず **常に** capture-pane を試み、pickContextText で
1259
- // liveContext を最優先する (旧 bundle が古い context_text を送ってきても汚染
1260
- // しない)。抽出失敗時は null フォールバック (= 説明なし) で安全縮退。
1261
- let liveContext = null
1262
- if (cwd) {
1263
- try {
1264
- const sessions = await listTmuxSessions({ logger })
1265
- const match = Array.isArray(sessions)
1266
- ? sessions.find((s) => s && s.cwd === cwd)
1267
- : null
1268
- if (match && match.name) {
1269
- const pane = await capturePane(match.name, { noCache: true })
1270
- liveContext = extractLastAssistantText(pane)
1271
- }
1272
- } catch (err) {
1273
- logger?.debug?.(
1274
- { err: err?.message, cwd },
1275
- "capture-pane based context_text extraction failed; falling back to null",
1276
- )
1277
- }
1278
- }
1279
- client.send({
1280
- type: "claude.permission.request",
1281
- stream_id: null,
1282
- session_id,
1283
- cwd,
1284
- request_id,
1285
- tool_name,
1286
- input,
1287
- // 質問/承認カードの直前アシスタント説明 (browser がカード上部に表示)。
1288
- // 優先順位: (1) capture-pane で抽出した現ターンの liveContext、(2) フック由来
1289
- // の context_text (後方互換フォールバック)、(3) null。pickContextText 参照。
1290
- context_text: pickContextText(context_text, liveContext),
1291
- })
1292
- },
1293
- )
1294
- await tuiPermissionBridge.start()
1295
- ctx.tuiPermissionBridge = tuiPermissionBridge
1296
-
1297
- // TUI 閲覧マーカー registry: browser の claude.tui.viewing ハートビートを受けて
1298
- // 「閲覧中の session」をローカルのマーカーファイルとして保持する。承認フックは
1299
- // その鮮度を見て「閲覧者がいる session だけブリッジ、いなければ即 ask」する
1300
- // (watcher ゲート, WATCHER-GATE.md)。
1301
- const tuiViewerRegistry = new TuiViewerRegistry({ logger })
1302
- await tuiViewerRegistry.start()
1303
- ctx.tuiViewerRegistry = tuiViewerRegistry
1304
1203
 
1305
1204
  // sibling-inject (ask-sibling スキル, 2026-07-04): 同一 cwd の claude⇄codex
1306
1205
  // 相互依頼のためのローカル IPC 受信口。cwd スコープ capability トークン
@@ -1310,44 +1209,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1310
1209
  await siblingInjectBridge.start()
1311
1210
  ctx.siblingInjectBridge = siblingInjectBridge
1312
1211
 
1313
- // jsonl ライブ追従 (T04784 逐次表示): TUI チャットの閲覧ハートビートを起点に対象
1314
- // session の jsonl を tail し、追記行を claude.jsonl.event として逐次 push する。
1315
- // これで session.event 合図 → 全体再 hydrate + 2.5s ポーリングの「どさっと」を廃せる。
1316
- const jsonlLiveWatchers = new JsonlLiveWatchers({
1317
- send: (obj) => client.send(obj),
1318
- getProjectsRoot: getActiveProjectsRoot,
1319
- logger,
1320
- // ネイティブ回答検知 (症状1b 根治): tail 中の jsonl に tool_result が着弾し「あるツールが
1321
- // ターミナル側で解決された」と分かったら、その session の同名ツールの pending 権限カードを
1322
- // 畳み、browser へ claude.permission.cancel を送って残存カードを消す。browser が先に
1323
- // resolve() 済みなら _pending は既に空で dropBySession は [] を返す (= no-op、cancel も出ない)。
1324
- onToolResolved: ({ session_id, cwd, toolName }) => {
1325
- const dropped =
1326
- tuiPermissionBridge.dropBySession({ session_id, cwd, toolName }) || []
1327
- for (const request_id of dropped) {
1328
- try {
1329
- client.send({
1330
- type: "claude.permission.cancel",
1331
- request_id,
1332
- session_id,
1333
- cwd,
1334
- reason: "answered_in_terminal",
1335
- })
1336
- } catch {
1337
- /* ignore */
1338
- }
1339
- }
1340
- if (dropped.length) {
1341
- logger.info(
1342
- { session_id, cwd, tool: toolName, count: dropped.length },
1343
- "tui permission resolved natively → cancelled browser card",
1344
- )
1345
- }
1346
- },
1347
- })
1348
- jsonlLiveWatchers.start()
1349
- ctx.jsonlLiveWatchers = jsonlLiveWatchers
1350
-
1351
1212
  // モデル能力レジストリ (2026-07-11): SDK supportedModels() を起動時に引き、effort
1352
1213
  // 対応可否の判定を正規表現ハードコードから権威データへ置き換える。能力はセッション中
1353
1214
  // 不変なので attach ごとには引き直さない。ただし control-only query は cold start 直後に
@@ -1368,21 +1229,12 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1368
1229
  )
1369
1230
  }
1370
1231
 
1371
- // session 回転通知 (T04786 後続) の多重送信ガード: 回転キー (tmux 名 or cwd) →
1372
- // 最後に push した new_session_id。閲覧ハートビートは 5s 間隔で来るため、フロントが
1373
- // 切替を完了し new id でハートビートを送り直すまでの間に来る余分なハートビートで
1374
- // claude.session.rotated を連発しないようにする。
1375
- ctx.tuiRotationNotified = new Map()
1376
-
1377
1232
  const shutdown = async (signal) => {
1378
1233
  logger.info({ signal }, "shutting down")
1379
1234
  await runHookBroadcast(plugins, "onAgentStop", ctx)
1380
1235
  stateLoop.stop()
1381
1236
  sessionEventLoop?.stop?.()
1382
1237
  readinessLoop?.stop?.()
1383
- tuiPermissionBridge.stop()
1384
- tuiViewerRegistry.stop()
1385
- jsonlLiveWatchers.stop()
1386
1238
  siblingInjectBridge.stop()
1387
1239
  ptyBridge.shutdown()
1388
1240
  claudeBridge?.shutdown?.()
@@ -1986,39 +1838,6 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
1986
1838
  }
1987
1839
  }
1988
1840
 
1989
- /**
1990
- * 【構造版 root 2026-06-22】idle ペインへの fresh remount storm を 1 回へ collapse する閾値。
1991
- * 直近 fresh respawn からこの時間内の同一セッションへの fresh は idempotent な再送とみなす。
1992
- */
1993
- const FRESH_RESPAWN_THROTTLE_MS = 8_000
1994
- /** busy で defer した +新規 の保留 TTL。ready が来ないまま席を立った等で破棄する。 */
1995
- const PENDING_FRESH_TTL_MS = 600_000
1996
-
1997
- /**
1998
- * 【構造版 root 2026-06-22】bind 時の即時 busy プローブ (プロセス実体)。
1999
- * 「ターン進行中の claude を kill しない」判定の唯一の根拠にする。capture スクレイプや armed の
2000
- * 140s cap の脆さに依存しない:
2001
- * - isArmed: prompt_submit→ready の間 true (生成中 + Stop フック中を含む)。一次シグナル。
2002
- * - 補強: pane_pid 子孫数 > 学習 baseline を ps スナップショットで直接確認 (armed cap / 未観測の
2003
- * 隙間でも取りこぼさない)。baseline 未学習 (= ターン未観測の純 idle) は false (idle の +新規を妨げない)。
2004
- * 判定不能時は安全側 false (= kill 許可) で idle の +新規 を妨げない。armed が一次防御なので、
2005
- * 真にターン中なら isArmed=true でここに来る前に true を返す。
2006
- */
2007
- async function isPaneBusyNow(readinessTracker, name) {
2008
- if (!name || !readinessTracker) return false
2009
- if (readinessTracker.isArmed(name) === true) return true
2010
- try {
2011
- const panePid = await getPanePid(name)
2012
- if (!panePid) return false
2013
- const procMap = await snapshotProcs()
2014
- if (procMap.size === 0) return false
2015
- const baseline = readinessTracker.getBaseline(name)
2016
- if (baseline === null) return false
2017
- return busyChildCount(panePid, procMap) > baseline
2018
- } catch {
2019
- return false
2020
- }
2021
- }
2022
1841
 
2023
1842
  /**
2024
1843
  * arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
@@ -3159,17 +2978,42 @@ export function handleClaudeChatInput(msg, ctx) {
3159
2978
  * claude.attach の ready 直後にpushする履歴を組み立てる。
3160
2979
  * Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
3161
2980
  * 失敗しても空のhistory.responseへ縮退し、attach自体を失敗させない。
2981
+ *
2982
+ * ⚠️ これは **agent 発の push** であり browser 発の read ではないため epoch を持たない
2983
+ * (Read-plane 案C)。frontend の read ゲートは epoch 無しの応答を「版なしの先回り
2984
+ * hydration」として扱い、現 view (profile/cwd/session) が一致するときだけ適用する。
2985
+ *
2986
+ * ⚠️ **read root と label は 1 回の解決結果から取る** (cross-review major1/nit2)。
2987
+ * projects root (履歴を読む dir) と応答の profile_id ラベルを別々に解決すると、
2988
+ * その間にプロファイル切替が挟まったとき「A と名乗る応答に B の履歴」が入りうる。
2989
+ * これを API で表現するため、呼び出し側は `resolveProjectsRoot(profile_id)` を **1 回だけ**
2990
+ * 解決し、その戻り値 `{projectsRoot, profileId}` を **単一の `resolved` として渡す**。
2991
+ * root と label を別引数で受けないことで「別解決の混在」を構造的に不可能にする。
2992
+ *
2993
+ * @param {{projectsRoot:string, profileId?:string}} [resolved] 解決済みの root と profile を組で。
2994
+ * projectsRoot=履歴を読む dir、profileId=応答ラベル (省略時はラベルを載せない=後方互換)。
2995
+ * 未指定なら active を 1 回解決してその root を使う (ラベルは載せない)。
2996
+ * @param {(args:object)=>Promise<object>} [fetchHistoryFn] テスト用の履歴読取注入。
3162
2997
  */
3163
2998
  export async function loadClaudeAttachHistory({
3164
2999
  cwd,
3165
3000
  sessionId,
3166
3001
  logger,
3167
- getProjectsRootFn = getActiveProjectsRoot,
3002
+ resolved,
3168
3003
  fetchHistoryFn = fetchSessionHistory,
3169
3004
  }) {
3170
3005
  if (!cwd || !sessionId) return null
3006
+ const profileId = resolved?.profileId
3007
+ const withProfile = (payload) =>
3008
+ profileId ? { ...payload, profile_id: profileId } : payload
3171
3009
  try {
3172
- const projectsRoot = await getProjectsRootFn()
3010
+ // root と label は必ず同一の `resolved` 由来。resolved 省略時のみ active を読む
3011
+ // (この場合 profileId は無く、従来どおりラベル無し応答になる)。
3012
+ // ⚠️ active root 解決も try 内で行う (公開前 major): fallback 解決が失敗しても
3013
+ // 空 history.response へ縮退させ、attach を落とさない契約を守る。
3014
+ const projectsRoot = resolved
3015
+ ? resolved.projectsRoot
3016
+ : await getActiveProjectsRoot()
3173
3017
  const result = await fetchHistoryFn({
3174
3018
  cwd,
3175
3019
  session_id: sessionId,
@@ -3177,7 +3021,7 @@ export async function loadClaudeAttachHistory({
3177
3021
  projectsRoot,
3178
3022
  logger,
3179
3023
  })
3180
- return {
3024
+ return withProfile({
3181
3025
  type: "claude.history.response",
3182
3026
  session_id: sessionId,
3183
3027
  cwd,
@@ -3185,9 +3029,9 @@ export async function loadClaudeAttachHistory({
3185
3029
  total_lines: result.total_lines || 0,
3186
3030
  truncated: result.truncated === true,
3187
3031
  error: result.error,
3188
- }
3032
+ })
3189
3033
  } catch (err) {
3190
- return {
3034
+ return withProfile({
3191
3035
  type: "claude.history.response",
3192
3036
  session_id: sessionId,
3193
3037
  cwd,
@@ -3195,10 +3039,52 @@ export async function loadClaudeAttachHistory({
3195
3039
  total_lines: 0,
3196
3040
  truncated: false,
3197
3041
  error: err?.message || String(err),
3198
- }
3042
+ })
3199
3043
  }
3200
3044
  }
3201
3045
 
3046
+ /**
3047
+ * claude.attach の先回り履歴 push 用 promise を組み立てる。
3048
+ *
3049
+ * ⚠️ **この promise は決して reject しない** (公開前 major の契約)。`resolveProjectsRoot` を
3050
+ * attach ハンドラの直列 await にも try にも載せると、`~/.hub/profiles.json` 破損・権限エラー等の
3051
+ * 解決失敗で attach/ready ごと落ち (claude.error)、かつ registry 解決が ready 前の同期待ちになる。
3052
+ * ここで非同期チェーンとして開始し、解決失敗は label 無しの空 `claude.history.response`(error 付き)
3053
+ * へ縮退させる。root と label は 1 回の解決 (resolved) から取り (major1/nit2 の単一 resolved 不変
3054
+ * 条件)、attach/ready を profile/read I/O から独立させる。
3055
+ *
3056
+ * @param {object} p
3057
+ * @param {string|null} p.resumeSessionId resume 対象。null/空なら履歴を読まず null を返す。
3058
+ * @param {string} p.cwd 履歴を読む cwd。
3059
+ * @param {string} [p.profileId] read の profile ヒント (msg.profile_id)。resolveFn へ渡す。
3060
+ * @param {(profileId?:string)=>Promise<{projectsRoot:string,profileId?:string}>} [p.resolveFn]
3061
+ * @param {(args:object)=>Promise<object|null>} [p.loadFn]
3062
+ * @returns {Promise<object|null>} history.response 相当 or null (fresh)。reject しない。
3063
+ */
3064
+ export function buildAttachHistoryPromise({
3065
+ resumeSessionId,
3066
+ cwd,
3067
+ logger,
3068
+ profileId,
3069
+ resolveFn = resolveProjectsRoot,
3070
+ loadFn = loadClaudeAttachHistory,
3071
+ }) {
3072
+ if (!resumeSessionId) return Promise.resolve(null)
3073
+ return resolveFn(profileId)
3074
+ .then((resolved) =>
3075
+ loadFn({ cwd, sessionId: resumeSessionId, logger, resolved }),
3076
+ )
3077
+ .catch((err) => ({
3078
+ type: "claude.history.response",
3079
+ session_id: resumeSessionId,
3080
+ cwd,
3081
+ events: [],
3082
+ total_lines: 0,
3083
+ truncated: false,
3084
+ error: err?.message || String(err),
3085
+ }))
3086
+ }
3087
+
3202
3088
  /**
3203
3089
  * SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
3204
3090
  * `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
@@ -3274,97 +3160,6 @@ export function handleCodexChatInput(msg, ctx) {
3274
3160
  return p
3275
3161
  }
3276
3162
 
3277
- /**
3278
- * 離席中キュー投入 (claude.tui.queue.flush) の冪等化用に、処理済み flush_id を記憶する。
3279
- * WS 再送で同じ flush が二重に届いても二重 paste しないため。上限を超えたら全消去 (best-effort)。
3280
- */
3281
- const _seenFlushIds = new Set()
3282
- const _SEEN_FLUSH_CAP = 2000
3283
-
3284
- /**
3285
- * claude.tui.queue.flush の処理 (離席中キュー自動投入の agent 側受け口)。
3286
- * ブラウザの常駐ドレイン (useCockpitTuiQueueDrainAll) が、表示していないセッションの送信待ち
3287
- * キュー先頭 1 件をここへ送る。tmux send-keys (PTY 非依存) で投入するので、そのセッションの
3288
- * ターミナルを誰も開いていなくても届く。結果を flush.ack で返し、ブラウザは ack を受けて
3289
- * はじめて localStorage から 1 件 dequeue する。
3290
- *
3291
- * 冪等性は 2 段:
3292
- * 1. flush_id 単位 (_seenFlushIds): WS 再送・ack 取りこぼしによる同一 flush の二重投入を防ぐ。
3293
- * 2. 論理 item id 単位 (cross-path): browser の active 経路 (tracked PTY paste) は同一 item を
3294
- * input_id `qd:<itemId>:p` / `:r` で送る。ここと単一レジストリ (seenInputIds) を共有し、同一
3295
- * item がどちらの経路で配送されても agent は at-most-once でしか paste しない (二重送信の根治)。
3296
- *
3297
- * テストから直接呼べるよう dispatch から切り出して export する。paste 実体は ctx で差し替え可能。
3298
- */
3299
- export async function handleQueueFlush(msg, ctx) {
3300
- const paste = ctx.pasteToSessionByName || pasteToSessionByName
3301
- const sessionName =
3302
- typeof msg.session_name === "string" ? msg.session_name : ""
3303
- const text = typeof msg.text === "string" ? msg.text : ""
3304
- const flushId = typeof msg.flush_id === "string" ? msg.flush_id : ""
3305
- const itemId = typeof msg.item_id === "string" ? msg.item_id : ""
3306
- const pasteKey = itemId ? `qd:${itemId}:p` : ""
3307
- const crKey = itemId ? `qd:${itemId}:r` : ""
3308
- const ackFail = (reason) => {
3309
- if (flushId && sessionName) {
3310
- ctx.client.send({
3311
- type: "claude.tui.queue.flush.ack",
3312
- session_name: sessionName,
3313
- flush_id: flushId,
3314
- ok: false,
3315
- error: reason,
3316
- })
3317
- }
3318
- }
3319
- const ackOk = () => {
3320
- if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
3321
- _seenFlushIds.add(flushId)
3322
- ctx.client.send({
3323
- type: "claude.tui.queue.flush.ack",
3324
- session_name: sessionName,
3325
- flush_id: flushId,
3326
- ok: true,
3327
- })
3328
- }
3329
- if (!sessionName || !flushId || !text) {
3330
- ackFail("missing session_name / flush_id / text")
3331
- return
3332
- }
3333
- // 冪等化1 (message 再送)。
3334
- if (_seenFlushIds.has(flushId)) {
3335
- ackOk()
3336
- return
3337
- }
3338
- // 冪等化2 (cross-path): 同一 item を active 経路 (tracked PTY paste) が既に配送済みなら
3339
- // 再 paste せず ok を返す (browser は ack で dequeue する)。
3340
- if (pasteKey && seenInputIds.has(pasteKey)) {
3341
- ackOk()
3342
- return
3343
- }
3344
- // paste 前に cross-path キーを登録する。await 中に並走する active 経路の tracked paste
3345
- // (handleTrackedPtyData) を dedup させ二重 paste を防ぐため (登録は paste 完了より先)。
3346
- // 失敗時は登録解除し、再試行 (別 flush_id) で再配送できるようにする。
3347
- if (pasteKey) {
3348
- rememberInputId(pasteKey)
3349
- rememberInputId(crKey)
3350
- }
3351
- const result = await paste(sessionName, text, { logger: ctx.logger })
3352
- if (result.ok) {
3353
- if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
3354
- _seenFlushIds.add(flushId)
3355
- } else if (pasteKey) {
3356
- seenInputIds.delete(pasteKey)
3357
- seenInputIds.delete(crKey)
3358
- }
3359
- ctx.client.send({
3360
- type: "claude.tui.queue.flush.ack",
3361
- session_name: sessionName,
3362
- flush_id: flushId,
3363
- ok: !!result.ok,
3364
- error: result.ok ? undefined : result.error,
3365
- })
3366
- }
3367
-
3368
3163
  /**
3369
3164
  * tmux.kill_session の処理 (症状3 根治)。要求された session を kill し、要求元へ
3370
3165
  * tmux.kill_session.result を返したうえで、kill できた各 session について
@@ -3411,76 +3206,6 @@ export async function handleKillSession(msg, ctx) {
3411
3206
  }
3412
3207
  }
3413
3208
 
3414
- /**
3415
- * claude.tui.interrupt の処理 (確認付き中断 = Phase3)。生 ESC を tracked pty.data で best-effort
3416
- * 送出する旧経路 (stream 欠落で無言ドロップ + ESC 到達でも止まったか未検証) を、agent が ESC を
3417
- * tmux send-keys (PTY 非依存で確実配達) で送り、ペイン status で生成停止を観測してから結果を返す
3418
- * 方式に置き換える。
3419
- *
3420
- * 不変条件: 中断指示は、claude と同一ホストで生成状態を観測できる agent が「停止を観測 or deadline」
3421
- * まで責任を持つ。止まらなければ deadline 内で 1 度だけ ESC を再送する (初回が一過性 mode に吸われた
3422
- * 場合の保険)。Ctrl+C へはエスカレーションしない (claude を終了させる事故を避けるため。別判断)。
3423
- *
3424
- * テストから直接呼べるよう export。送出 (sendInterruptKey) / 観測 (detectSessionState) / 待機 (delay)
3425
- * は ctx で差し替え可能。
3426
- */
3427
- export async function handleTuiInterrupt(msg, ctx) {
3428
- const sessionName =
3429
- typeof msg.session_name === "string" ? msg.session_name : ""
3430
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
3431
- const requestId = typeof msg.request_id === "string" ? msg.request_id : ""
3432
- if (!sessionName || !requestId) return
3433
- const sendEsc = ctx.sendInterruptKey || sendInterruptKey
3434
- const detect = ctx.detectSessionState || detectSessionState
3435
- const delay = ctx.delay || ((ms) => new Promise((r) => setTimeout(r, ms)))
3436
- // 症状⑤: 初回チェックまでの settle と poll 間隔を 350→150ms に詰め、maxPolls を 7→16 に
3437
- // 増やす。総 deadline は ~2.4s で従来(~2.5s)とほぼ同じだが、実際に停止した瞬間の観測が
3438
- // 約2倍速くなり「停止押下→画面が止まる」体感ラグを縮める。capture-pane スクレイプ回数は
3439
- // 増えるが 150ms 間隔なら許容範囲。Ctrl+C 非エスカレーション方針は不変。
3440
- const settleMs = ctx.interruptSettleMs ?? 150
3441
- const pollMs = ctx.interruptPollMs ?? 150
3442
- const maxPolls = ctx.interruptMaxPolls ?? 16 // ~2.4s deadline (150 + 150*15)
3443
- const reply = (stopped, attempts) =>
3444
- ctx.client.send({
3445
- type: "claude.tui.interrupt.result",
3446
- request_id: requestId,
3447
- session_name: sessionName,
3448
- cwd: cwd || undefined,
3449
- stopped: !!stopped,
3450
- attempts,
3451
- })
3452
- const stillProcessing = async () => {
3453
- try {
3454
- const snap = await detect(sessionName, { logger: ctx.logger })
3455
- return (snap?.status ?? "processing") === "processing"
3456
- } catch {
3457
- // capture 失敗時は判定不能 → 保守的に「まだ生成中かも」と扱う (誤って stopped=true にしない)。
3458
- return true
3459
- }
3460
- }
3461
- // 1) ESC 送出 (= 中断)。
3462
- await sendEsc(sessionName, { logger: ctx.logger })
3463
- let attempts = 1
3464
- let resent = false
3465
- // 2) 停止 (status が processing を離れる) を deadline 内で観測。
3466
- for (let i = 0; i < maxPolls; i++) {
3467
- await delay(i === 0 ? settleMs : pollMs)
3468
- if (!(await stillProcessing())) {
3469
- reply(true, attempts)
3470
- return
3471
- }
3472
- // deadline 中盤でまだ processing なら 1 度だけ ESC 再送 (初回が一過性 mode に吸われた保険)。
3473
- // status=processing 継続が条件なので、アイドル箱への二度押し (rewind ダイアログ誤爆) は避ける。
3474
- if (!resent && i >= Math.floor(maxPolls / 2)) {
3475
- resent = true
3476
- await sendEsc(sessionName, { logger: ctx.logger })
3477
- attempts++
3478
- }
3479
- }
3480
- // 3) deadline 到達でまだ processing。停止確認できず (frontend は旧 tracked-ESC 経路へ degrade)。
3481
- reply(false, attempts)
3482
- }
3483
-
3484
3209
  async function dispatch(msg, ctx) {
3485
3210
  const t = msg?.type || ""
3486
3211
  try {
@@ -3623,13 +3348,15 @@ async function dispatch(msg, ctx) {
3623
3348
  // attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
3624
3349
  // 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
3625
3350
  // requestしてもhydrateのuuid dedupで安全に収束する。
3626
- const historyPromise = resumeSessionId
3627
- ? loadClaudeAttachHistory({
3628
- cwd: cwdArg || process.env.HOME || process.cwd(),
3629
- sessionId: resumeSessionId,
3630
- logger: ctx.logger,
3631
- })
3632
- : Promise.resolve(null)
3351
+ // 案C ①/major1/nit2/公開前 major: 履歴 push を attach/ready から独立させる。
3352
+ // resolveProjectsRoot を attach の直列 await/try に載せない (解決失敗で attach ごと
3353
+ // 落とさない・ready を遅らせない)。詳細は buildAttachHistoryPromise の doc 参照。
3354
+ const historyPromise = buildAttachHistoryPromise({
3355
+ resumeSessionId,
3356
+ cwd: cwdArg || process.env.HOME || process.cwd(),
3357
+ logger: ctx.logger,
3358
+ profileId: msg.profile_id,
3359
+ })
3633
3360
  const info = ctx.claudeBridge.attach({
3634
3361
  stream_id,
3635
3362
  cwd: cwdArg,
@@ -3790,43 +3517,6 @@ async function dispatch(msg, ctx) {
3790
3517
  return
3791
3518
  }
3792
3519
  case "claude.permission.reply": {
3793
- // TUI ブリッジの pending なら .decision を書いて返す (TUI 経路優先)。
3794
- // 管理外 (= SDK チャットの request) なら従来の permissionReply へ。
3795
- const handledByTui = ctx.tuiPermissionBridge
3796
- ? await ctx.tuiPermissionBridge.resolve(
3797
- msg.request_id,
3798
- !!msg.allow,
3799
- msg.deny_message,
3800
- msg.updated_input,
3801
- )
3802
- : false
3803
- // 遅延回答 resume (frontend が session_name + resume_text を載せた TUI 回答のみ)。
3804
- // フックが既に死んでいて即時確定 (.decision 注入) が届かない場合、回答を新メッセージ
3805
- // として同一セッションへ送り、永続 claude が文脈ごと続行できるようにする。
3806
- const resumeText =
3807
- typeof msg.resume_text === "string" ? msg.resume_text : ""
3808
- const resumeSession =
3809
- typeof msg.session_name === "string" ? msg.session_name : ""
3810
- if (resumeText && resumeSession && ctx.tuiPermissionBridge) {
3811
- if (!handledByTui) {
3812
- // 期限切れ (pending に無い) → 即 resume。再配信しないよう drop。
3813
- ctx.tuiPermissionBridge.drop(msg.request_id)
3814
- void _resumeTuiAnswer(resumeSession, resumeText, msg.request_id)
3815
- } else {
3816
- // pending だった: 生存フックは ~0.15s で .decision を消費する。猶予後も
3817
- // 残っていれば フック死亡 (Claude Code timeout で kill 等) → resume へ。
3818
- const reqId = msg.request_id
3819
- setTimeout(() => {
3820
- if (ctx.tuiPermissionBridge?.hasUnconsumedDecision(reqId)) {
3821
- ctx.tuiPermissionBridge.dropDecision(reqId).catch(() => {})
3822
- ctx.tuiPermissionBridge.drop(reqId)
3823
- void _resumeTuiAnswer(resumeSession, resumeText, reqId)
3824
- }
3825
- }, RESUME_DECISION_GRACE_MS)
3826
- }
3827
- return
3828
- }
3829
- if (handledByTui) return
3830
3520
  if (!ctx.claudeBridge) return
3831
3521
  ctx.claudeBridge.permissionReply({
3832
3522
  stream_id: msg.stream_id,
@@ -3841,675 +3531,6 @@ async function dispatch(msg, ctx) {
3841
3531
  if (!ctx.claudeBridge) return
3842
3532
  ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
3843
3533
  return
3844
- case "claude.tui.viewing": {
3845
- // T04778 watcher ゲート: browser が TUI チャットでこの session を閲覧中。
3846
- // 閲覧マーカーを更新し、承認フックが「閲覧者あり」と判定できるようにする。
3847
- ctx.tuiViewerRegistry
3848
- ?.note({ session_id: msg.session_id, cwd: msg.cwd })
3849
- .catch(() => {})
3850
- // T04784 逐次表示: 閲覧中だけ jsonl を tail して claude.jsonl.event を push。
3851
- ctx.jsonlLiveWatchers
3852
- ?.note({ session_id: msg.session_id, cwd: msg.cwd })
3853
- .catch(() => {})
3854
- // T04786 後続: session 回転検知 (/clear で裏 claude が新 session_id へ切替)。
3855
- // 追従中ビュー (follow_newest 既定 true) のときだけ、cwd の最新 session_id と
3856
- // 閲覧中 id を突き合わせ、ズレていれば claude.session.rotated を push してフロントに
3857
- // 読み出し先の切替を促す。過去セッションを意図的に開いているビュー (follow_newest=false)
3858
- // では検知しない (ピン留め閲覧を最新へ引きずらない)。
3859
- const viewSid = msg.session_id
3860
- const viewCwd = msg.cwd
3861
- const viewName =
3862
- typeof msg.session_name === "string" ? msg.session_name : ""
3863
- const followNewest = msg.follow_newest !== false
3864
- if (followNewest && viewSid && viewCwd) {
3865
- ;(async () => {
3866
- try {
3867
- const projectsRoot = await getActiveProjectsRoot()
3868
- const { sessions } = await listSessions({
3869
- cwd: viewCwd,
3870
- projectsRoot,
3871
- limit: 1,
3872
- logger,
3873
- })
3874
- const newestId = sessions?.[0]?.session_id || null
3875
- const key = viewName || viewCwd
3876
- const prev = ctx.tuiRotationNotified.get(key)
3877
- const now = Date.now()
3878
- const prevNewId =
3879
- prev && typeof prev === "object" ? prev.newId : prev
3880
- const prevCount =
3881
- prev && typeof prev === "object" ? (prev.count ?? 0) : 0
3882
- const { rotated, newSessionId, capped } = decideSessionRotation({
3883
- viewingSessionId: viewSid,
3884
- newestSessionId: newestId,
3885
- // 旧形式 (文字列) と新形式 ({newId, ts, count}) の両対応。
3886
- lastNotifiedNewId: prevNewId,
3887
- lastNotifiedAt:
3888
- prev && typeof prev === "object" ? prev.ts : null,
3889
- lastNotifiedCount: prevCount,
3890
- now,
3891
- })
3892
- if (capped) {
3893
- // 安全弁発火: フロントが採用しないまま maxReNotify 回再通知済み。これ以上の
3894
- // rotated 連発を止め、無限ピンポンを断つ (フロント側 cwd 必須化で通常は到達しない)。
3895
- logger.warn(
3896
- { session: viewName, cwd: viewCwd, view: viewSid, newest: newestId },
3897
- "tui session rotation: capped (frontend not adopting; stopped re-notify)",
3898
- )
3899
- return
3900
- }
3901
- if (!rotated) return
3902
-
3903
- // 2a 根治: rotate-bind の前に「候補が本物の /clear 先か」を head 4KB で検証する。
3904
- // subagent (parent_uuid 等) / SDK headless (type=result) / cwd 不一致のものは
3905
- // 抑止し、別アクティビティ jsonl への汚染 bind を断つ。検証 NG 時は capped と同様に
3906
- // tuiRotationNotified を進めて連発を防ぐ。検証失敗 (read error) は accept = 旧挙動。
3907
- const candidatePath = jsonlPath({
3908
- cwd: viewCwd,
3909
- session_id: newSessionId,
3910
- projectsRoot,
3911
- })
3912
- const { accept, reason } = await validateRotationCandidate({
3913
- candidatePath,
3914
- viewCwd,
3915
- logger,
3916
- })
3917
- if (!accept) {
3918
- ctx.tuiRotationNotified.set(key, {
3919
- newId: newSessionId,
3920
- ts: now,
3921
- count: newSessionId === prevNewId ? prevCount + 1 : 1,
3922
- })
3923
- logger.warn(
3924
- {
3925
- session: viewName,
3926
- cwd: viewCwd,
3927
- view: viewSid,
3928
- newest: newestId,
3929
- reason,
3930
- },
3931
- "tui session rotation: suppressed (sibling activity / not a real /clear)",
3932
- )
3933
- return
3934
- }
3935
- // {newId, ts, count} で保存。同一 new への再通知をスロットリング+回数キャップする。
3936
- // newId が変われば count を 1 にリセット、同一なら加算する。
3937
- ctx.tuiRotationNotified.set(key, {
3938
- newId: newSessionId,
3939
- ts: now,
3940
- count: newSessionId === prevNewId ? prevCount + 1 : 1,
3941
- })
3942
- // 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
3943
- // respawn (claude 再起動) してしまうのを防ぐ。
3944
- if (viewName) ctx.tuiReboundSessions.set(viewName, newSessionId)
3945
- ctx.client.send({
3946
- type: "claude.session.rotated",
3947
- cwd: viewCwd,
3948
- session_name: viewName || undefined,
3949
- old_session_id: viewSid,
3950
- new_session_id: newSessionId,
3951
- })
3952
- // ★回転 (= session_id 境界) で、この NAME に紐づく状態トラッカーを忘れる。生成中を表す
3953
- // 信号 (proc_busy=ReadinessTracker baseline/armed, status gate ラッチ, spinner freeze,
3954
- // capture cache) は全て NAME キーのため、忘れないと旧ターンの busy が新 session_id の
3955
- // 表示へ漏れ、フロントで「/clear 後に三点リーダーが固着」する。発生源 (agent) で断つのが
3956
- // 主、フロントの再点灯レジスタ reset が従の不変条件 (name→id 漏れの根治)。
3957
- if (viewName) {
3958
- ctx.readinessTracker?.forget(viewName)
3959
- invalidateSessionCache(viewName)
3960
- }
3961
- // 回転 = 旧 session_id 境界。旧 session に紐づく pending 権限カードは orphan に
3962
- // なる (claude は新会話へ移った) ので畳み、browser へ cancel して残存カードを消す
3963
- // (症状1b 根治)。旧 session_id 完全一致で drop するため、回転直後に新 session で
3964
- // 出たばかりのカード (payload.session_id === newSessionId) は誤って巻き込まない。
3965
- if (ctx.tuiPermissionBridge) {
3966
- const dropped = ctx.tuiPermissionBridge.dropBySession({
3967
- session_id: viewSid,
3968
- })
3969
- for (const request_id of dropped) {
3970
- try {
3971
- ctx.client.send({
3972
- type: "claude.permission.cancel",
3973
- request_id,
3974
- session_id: viewSid,
3975
- cwd: viewCwd,
3976
- reason: "session_rotated",
3977
- })
3978
- } catch {
3979
- /* ignore */
3980
- }
3981
- }
3982
- }
3983
- logger.info(
3984
- { session: viewName, cwd: viewCwd, old: viewSid, new: newSessionId },
3985
- "tui session rotated (/clear) → notified browser",
3986
- )
3987
- } catch (err) {
3988
- logger.warn(
3989
- { err: err?.message, cwd: viewCwd },
3990
- "tui session rotation check failed",
3991
- )
3992
- }
3993
- })()
3994
- }
3995
- return
3996
- }
3997
- case "claude.tui.unviewing":
3998
- // 閲覧終了。session_id マーカーを即失効させ、以降の Bash を即 ask に倒す。
3999
- ctx.tuiViewerRegistry
4000
- ?.unview({ session_id: msg.session_id })
4001
- .catch(() => {})
4002
- // jsonl tail も即停止 (閲覧していない session を追従し続けない)。
4003
- ctx.jsonlLiveWatchers?.unwatch({ session_id: msg.session_id })
4004
- return
4005
- case "claude.tui.cyclePermission": {
4006
- // 権限バッジ押下 → 対話 claude TUI へ shift+tab を送って権限モードを循環。
4007
- // 旧実装は frontend が raw pty.data (BACKTAB) を送り、各端末が楽観値を
4008
- // 個別に 1 段進めるだけだったため、複数端末間でズレ、かつ tmux 上で直接
4009
- // 切り替えた変更とも乖離した。ここで agent が実キー送出 → ペイン再読込で
4010
- // 実モードを確定 → 全ブラウザへ claude.tui.permission を broadcast し、
4011
- // 「実際に動いているターミナルの状態」を正本として全端末に同期する。
4012
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4013
- const sessionName =
4014
- typeof msg.session_name === "string" ? msg.session_name : ""
4015
- if (!sessionName) return
4016
- ;(async () => {
4017
- try {
4018
- await cyclePermissionMode(sessionName, { logger })
4019
- // TUI がフッターバナーを再描画するまで少し待ってから実モードを読む。
4020
- await new Promise((r) => setTimeout(r, 250))
4021
- const text = await capturePane(sessionName, { noCache: true })
4022
- const mode = detectPermissionModeFromText(text)
4023
- ctx.client.send({
4024
- type: "claude.tui.permission",
4025
- cwd: cwd || undefined,
4026
- session_name: sessionName,
4027
- permission_mode: mode,
4028
- })
4029
- logger.info(
4030
- { session: sessionName, cwd, mode },
4031
- "tui permission cycled → notified browser",
4032
- )
4033
- } catch (err) {
4034
- logger.warn(
4035
- { err: err?.message, session: sessionName },
4036
- "claude.tui.cyclePermission failed",
4037
- )
4038
- }
4039
- })()
4040
- return
4041
- }
4042
- case "claude.tui.setModel": {
4043
- // モデルバッジ選択 → 対話 claude TUI へ `/model <id>` を送ってモデルを切り替える。
4044
- // 権限循環 (cyclePermission) と同設計: agent が実キーを送出 → 確認プロンプトを確定 →
4045
- // 全ブラウザへ claude.tui.model を broadcast し、実際に動いているターミナルのモデルを
4046
- // 正本として全端末に同期する。jsonl の message.model は次の assistant ターンまで更新
4047
- // されないため、この即時 broadcast で切替直後のバッジズレを解消する。
4048
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4049
- const sessionName =
4050
- typeof msg.session_name === "string" ? msg.session_name : ""
4051
- if (!sessionName) return
4052
- // model="" は「デフォルト」= `/model default`。frontend へはそのまま空で返し、
4053
- // 解決後の実 id は次ターンの jsonl 由来 (message.model) に委ねる。
4054
- const model = typeof msg.model === "string" ? msg.model : ""
4055
- // 先に broadcast してフロントの fallback (raw `/model` paste) を即キャンセルさせる。
4056
- // setTuiModel は「Switch model?」確認ダイアログの描画検知で数秒かかり得るため、その
4057
- // 完了を待つとフロントの MODEL_SYNC_FALLBACK_MS が先に発火して二重 paste になる。
4058
- // バッジは要求モデルを即表示し、確定値は次ターンの jsonl (message.model) で補正される。
4059
- ctx.client.send({
4060
- type: "claude.tui.model",
4061
- cwd: cwd || undefined,
4062
- session_name: sessionName,
4063
- model,
4064
- })
4065
- ;(async () => {
4066
- try {
4067
- await setTuiModel(sessionName, model || "default", { logger })
4068
- logger.info(
4069
- { session: sessionName, cwd, model: model || "(default)" },
4070
- "tui model switched → notified browser",
4071
- )
4072
- } catch (err) {
4073
- logger.warn(
4074
- { err: err?.message, session: sessionName },
4075
- "claude.tui.setModel failed",
4076
- )
4077
- }
4078
- })()
4079
- return
4080
- }
4081
- case "claude.tui.setEffort": {
4082
- // effort バッジ選択 → 対話 claude TUI へ `/effort <level>` を送って reasoning effort を
4083
- // 切り替える。setModel と同設計: agent が実キーを送出 → 全ブラウザへ claude.tui.effort を
4084
- // broadcast し、実際に動いているターミナルの effort を正本として全端末に同期する。
4085
- // effort は jsonl に記録されないため、この即時 broadcast が唯一の表示根拠になる。
4086
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4087
- const sessionName =
4088
- typeof msg.session_name === "string" ? msg.session_name : ""
4089
- if (!sessionName) return
4090
- // effort="" は「auto」= `/effort auto`。frontend へはそのまま空で返し、バッジは auto 表示。
4091
- const effort = typeof msg.effort === "string" ? msg.effort : ""
4092
- // setModel と同様、先に broadcast してフロントの fallback (raw `/effort` paste) を即
4093
- // キャンセルさせる。setTuiEffort も「Change effort level?」確認ダイアログの描画検知で
4094
- // 数秒かかり得るため、完了待ちだと EFFORT_SYNC_FALLBACK_MS が先に発火して二重 paste
4095
- // になる。effort は jsonl に記録されないので、この即時 broadcast が表示の根拠になる。
4096
- ctx.client.send({
4097
- type: "claude.tui.effort",
4098
- cwd: cwd || undefined,
4099
- session_name: sessionName,
4100
- effort,
4101
- })
4102
- ;(async () => {
4103
- try {
4104
- await setTuiEffort(sessionName, effort || "auto", { logger })
4105
- logger.info(
4106
- { session: sessionName, cwd, effort: effort || "(auto)" },
4107
- "tui effort switched → notified browser",
4108
- )
4109
- } catch (err) {
4110
- logger.warn(
4111
- { err: err?.message, session: sessionName },
4112
- "claude.tui.setEffort failed",
4113
- )
4114
- }
4115
- })()
4116
- return
4117
- }
4118
- case "claude.tui.answerAskUserQuestion": {
4119
- // 症状1a 根治 (1-B): ブラウザの AskUserQuestion カード回答を、フックの updatedInput
4120
- // (AskUserQuestion では原理的に効かない) でなく、agent が同一ホストの tmux ネイティブ
4121
- // メニューへ send-keys で届ける。capturePane で描画検知してから送出し状態変化を検証する
4122
- // (blind 送出禁止)。回答後 claude が tool_result を commit → JsonlLiveWatchers が検知して
4123
- // dropBySession → claude.permission.cancel でカードを畳む (1-A 経路と合成)。
4124
- const sessionName =
4125
- typeof msg.session_name === "string" ? msg.session_name : ""
4126
- const requestId =
4127
- typeof msg.request_id === "string" ? msg.request_id : undefined
4128
- // answers: 質問順の [{question?, labels: string[]}]。frontend が card の {questions, answers}
4129
- // から構築する。labels は選んだ選択肢ラベル群 (multiSelect は複数、single は1件)。
4130
- const answers = Array.isArray(msg.answers) ? msg.answers : []
4131
- if (!sessionName) return
4132
- const reply = (payload) =>
4133
- ctx.client.send({
4134
- type: "claude.tui.answerAskUserQuestion.result",
4135
- request_id: requestId,
4136
- session_name: sessionName,
4137
- ...payload,
4138
- })
4139
- ;(async () => {
4140
- try {
4141
- const r = await answerAskUserQuestion(sessionName, answers, { logger })
4142
- logger.info(
4143
- { session: sessionName, ok: r.ok, answered: r.answered, error: r.error },
4144
- "tui answerAskUserQuestion via send-keys",
4145
- )
4146
- reply({ ok: !!r.ok, answered: r.answered ?? 0, error: r.error })
4147
- } catch (err) {
4148
- logger.warn(
4149
- { err: err?.message, session: sessionName },
4150
- "claude.tui.answerAskUserQuestion failed",
4151
- )
4152
- reply({ ok: false, error: err?.message || String(err) })
4153
- }
4154
- })()
4155
- return
4156
- }
4157
- case "claude.tui.probePermission": {
4158
- // 読み取り専用の権限モード問い合わせ (cold-load seed)。キーは送らず、ペインを
4159
- // 読んで現在の実モードを claude.tui.permission として broadcast するだけ。
4160
- // ブラウザがマウント直後に「今ターミナルが何モードか」を取得し、楽観値や
4161
- // 古い jsonl 値とのズレを開始時点から揃えるために使う。検出不能 (claude 起動中・
4162
- // 素のシェル) のときは mode=null となり、frontend 側は据え置く。
4163
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4164
- const sessionName =
4165
- typeof msg.session_name === "string" ? msg.session_name : ""
4166
- if (!sessionName) return
4167
- ;(async () => {
4168
- try {
4169
- const text = await capturePane(sessionName, { noCache: true })
4170
- const mode = detectPermissionModeFromText(text)
4171
- // 検出できた時だけ通知する (null で badge を消さない)。
4172
- if (!mode) return
4173
- ctx.client.send({
4174
- type: "claude.tui.permission",
4175
- cwd: cwd || undefined,
4176
- session_name: sessionName,
4177
- permission_mode: mode,
4178
- })
4179
- } catch (err) {
4180
- logger.warn(
4181
- { err: err?.message, session: sessionName },
4182
- "claude.tui.probePermission failed",
4183
- )
4184
- }
4185
- })()
4186
- return
4187
- }
4188
- case "claude.tui.recoverInput": {
4189
- // 中断キャンセル後の入力欄復旧 (agent >= 0.6.57)。対話 claude は「送信直後の Esc」
4190
- // で本文を入力ボックスへ戻すため、放置すると次の bracketed paste が後ろへ連結され
4191
- // メッセージが合体する (2026-06-08 報告バグ)。frontend が Esc 送出直後にこれを送り、
4192
- // agent がペインを実測して「expect_text が入力ボックスへ戻った」ことを確認してから
4193
- // クリアし、結果を claude.tui.recoverInput.result で返す (backend が user-level
4194
- // broadcast し、frontend は request_id で自分の要求と照合する)。
4195
- const sessionName =
4196
- typeof msg.session_name === "string" ? msg.session_name : ""
4197
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4198
- const requestId =
4199
- typeof msg.request_id === "string" ? msg.request_id : ""
4200
- const expectText =
4201
- typeof msg.expect_text === "string" ? msg.expect_text : ""
4202
- if (!sessionName || !requestId) return
4203
- ;(async () => {
4204
- const result = await recoverTuiInput(sessionName, expectText, {
4205
- logger,
4206
- })
4207
- ctx.client.send({
4208
- type: "claude.tui.recoverInput.result",
4209
- request_id: requestId,
4210
- session_name: sessionName,
4211
- cwd: cwd || undefined,
4212
- cleared: !!result.cleared,
4213
- })
4214
- })()
4215
- return
4216
- }
4217
- case "claude.tui.interrupt": {
4218
- // 確認付き中断 (Phase3)。ロジックは handleTuiInterrupt に切り出し (テスト可能化)。
4219
- // async だが dispatch をブロックしないよう投げっぱなし (自前で result を返す)。
4220
- void handleTuiInterrupt(msg, ctx)
4221
- return
4222
- }
4223
- case "claude.tui.queue.flush": {
4224
- // 離席中(非アクティブセッション)キュー自動投入 (案A の agent 側受け口)。
4225
- // ロジックは handleQueueFlush に切り出し (テスト可能化 + cross-path 冪等)。async だが
4226
- // dispatch をブロックしないよう投げっぱなしにする (自前で flush.ack を返す)。
4227
- void handleQueueFlush(msg, ctx)
4228
- return
4229
- }
4230
- case "claude.tui.rehydratePermissions": {
4231
- // セッション切替でビューが再マウントすると、その時点の承認/質問カードは React
4232
- // state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
4233
- // browser が再購読時にこれを送り、該当セッションの未応答リクエストを再 push して
4234
- // カードを復元する (frontend は request_id で重複排除)。
4235
- if (!ctx.tuiPermissionBridge) return
4236
- const sid =
4237
- typeof msg.session_id === "string" ? msg.session_id : null
4238
- const reqCwd = typeof msg.cwd === "string" ? msg.cwd : null
4239
- const pend = ctx.tuiPermissionBridge.listPending({
4240
- session_id: sid,
4241
- cwd: reqCwd,
4242
- })
4243
- for (const p of pend) {
4244
- ctx.client.send({
4245
- type: "claude.permission.request",
4246
- stream_id: null,
4247
- session_id: p.session_id,
4248
- cwd: p.cwd,
4249
- request_id: p.request_id,
4250
- tool_name: p.tool_name,
4251
- input: p.input,
4252
- // 再 hydrate でも直前説明を保つ (listPending が payload ごと保持している)。
4253
- context_text: p.context_text ?? null,
4254
- })
4255
- }
4256
- if (pend.length) {
4257
- logger.info(
4258
- { session_id: sid, cwd: reqCwd, count: pend.length },
4259
- "tui permission re-hydrated to browser",
4260
- )
4261
- }
4262
- return
4263
- }
4264
- case "claude.tui.bind": {
4265
- // T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
4266
- // 1. 対象 cwd の最新 session_id を確定 (browser 指定があれば優先)。
4267
- // 2. その session の SDK を完全停止 (二重ホスト/枠流出を防ぐ)。
4268
- // 3. 裏の tmux claude を `--resume <id>` で同じ会話に載せ替える (read=write 一致)。
4269
- // ベストエフォート。失敗時は frontend が従来 newest 表示に degrade する。
4270
- const request_id = msg.request_id
4271
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4272
- const sessionName =
4273
- typeof msg.session_name === "string" ? msg.session_name : ""
4274
- const reply = (payload) =>
4275
- ctx.client.send({
4276
- type: "claude.tui.bind.result",
4277
- request_id,
4278
- ...payload,
4279
- })
4280
- try {
4281
- // fresh: 「まっさらな新規セッション」要求 (ピッカーの「+新規」)。cwd 最新へ
4282
- // resume せず resume 無しの claude を起動する。session_id は起動後の初回送信で
4283
- // 生成され、frontend は回転検知 / sessions 応答で拾う。
4284
- const fresh = msg.fresh === true
4285
- // 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)。あわせて
4286
- // 現在動いているセッション (= cwd の最新 jsonl = newestId) も解決する。生成中ガードで
4287
- // 「再接続/remount による同一セッションへの再 bind」か「別 session への明示 switch」かを
4288
- // 区別するのに使う (前者は respawn を抑止して生成中 claude を温存する)。
4289
- let targetId =
4290
- typeof msg.session_id === "string" && msg.session_id
4291
- ? msg.session_id
4292
- : null
4293
- let newestId = null
4294
- if (!fresh && cwd) {
4295
- const projectsRoot = await getActiveProjectsRoot()
4296
- const { sessions } = await listSessions({
4297
- cwd,
4298
- projectsRoot,
4299
- limit: 1,
4300
- logger,
4301
- })
4302
- newestId = sessions?.[0]?.session_id || null
4303
- if (!targetId) targetId = newestId
4304
- }
4305
- // 生成中ガード (謎停止対策): respawn-pane -k は生成中 claude を強制 kill し in-flight 応答を失う。
4306
- // hub-agent 再起動直後は tuiReboundSessions が空で冪等ガードが効かず、ブラウザ再接続の bind が
4307
- // 生成中 claude を kill する。armed (ターン進行中) か pane=processing なら生成中とみなす。
4308
- let generating = ctx.readinessTracker?.isArmed?.(sessionName) === true
4309
- if (!generating && sessionName) {
4310
- try {
4311
- const snap = await detectSessionState(sessionName, {})
4312
- if (snap?.status === "processing") generating = true
4313
- } catch {
4314
- // capture 失敗時は据え置き (生成中とみなさない = 従来挙動)
4315
- }
4316
- }
4317
- // 2) SDK を停止 (cwd 全体 + 対象 id)。
4318
- let stoppedSdk = 0
4319
- if (ctx.claudeBridge) {
4320
- stoppedSdk = ctx.claudeBridge.stopSessionsFor({
4321
- cwd,
4322
- session_id: targetId || undefined,
4323
- })
4324
- }
4325
- // SDK を止めて TUI へ載せ替えた時点で、cwd のチャット信号 (SDK 用の
4326
- // status/context% 補完) は役目を終える。残すと TUI 権限カードの
4327
- // inputPending 記録が stale status の鮮度を延命し、state loop が
4328
- // ペイン実状態を waiting で上書きし続ける (2026-06-12 真因修正)。
4329
- // T05945: claude バケットのみ消す。同一 cwd で走っている codex チャットの
4330
- // 信号まで消すと、codex 行のドット/ドーナツが巻き添えで消灯する。
4331
- if (cwd) clearChatSignal(cwd, "claude")
4332
- // 3) tmux claude を起動し直す (resume or fresh)。
4333
- // 冪等性: 同じ session を同じキーへ既に載せ替え済みなら respawn しない
4334
- // (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
4335
- // claude が再起動ループになり送信不能になる。2026-06-06 leader 実機)。
4336
- // resume は targetId をキー、fresh は session_id 不在のため bind の request_id を
4337
- // キーにする (同一マウントの再送は同 request_id で skip、新規要求は再マウントで
4338
- // 新 request_id になり 1 回だけ起動する)。
4339
- // 実プロセス由来の「claude 実行中か」。respawn-pane -k による生成中 claude の誤 kill
4340
- // (症状D 再発 D2: armed 140s cap / capture gap で generating=false に倒れ、--resume bind
4341
- // でも実行中 claude を kill していた)を、capture/armed に依存しない pane_current_command で防ぐ。
4342
- const paneRunningClaude = await isPaneRunningClaude(sessionName, { logger })
4343
- // 【構造版 root 2026-06-22】プロセス実体の busy (ターン進行中) を bind 時に確定する。
4344
- // 「動いている claude を kill しない」の唯一の判定基準。frontend が fresh:true を誤送しても
4345
- // 生成中 claude を殺さない (frontend 単独依存からの脱却)。
4346
- const busy = await isPaneBusyNow(ctx.readinessTracker, sessionName)
4347
- // 非 fresh (resume/auto) の bind = ユーザーが既存会話を選んだ → 保留中の +新規 defer は破棄。
4348
- if (!fresh) ctx.pendingFreshRespawn.delete(sessionName)
4349
- let rebind = { ok: false, skipped: false }
4350
- // 新規会話の 0% は会話切替として扱うため、request_id を送れない旧 client
4351
- // 経由でも bind ごとに固有IDを作る。空の `fresh:` を再利用すると、2回目以降の
4352
- // 新規会話が同一会話扱いになり、直前値が残る。
4353
- const freshBindId = request_id || randomUUID()
4354
- const bindKey = fresh ? `fresh:${freshBindId}` : targetId
4355
- const recordDeferredFresh = () =>
4356
- ctx.pendingFreshRespawn.set(sessionName, {
4357
- cwd,
4358
- request_id,
4359
- at: Date.now(),
4360
- model: ctx.config?.claude_model || "",
4361
- permissionMode: ctx.config?.claude_permission_mode || "",
4362
- })
4363
- if (bindKey && sessionName && (fresh || targetId)) {
4364
- if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
4365
- rebind = { ok: true, skipped: true }
4366
- } else if (
4367
- shouldSkipRebindRespawn({
4368
- generating,
4369
- fresh,
4370
- targetId,
4371
- newestId,
4372
- paneRunningClaude,
4373
- busy,
4374
- })
4375
- ) {
4376
- // busy (ターン進行中) または最新セッションへの再 bind → respawn を抑止し生成中 claude を温存。
4377
- rebind = { ok: true, skipped: true }
4378
- if (fresh && busy) {
4379
- // ユーザーの +新規 を ready (ターン終了) まで defer する (in-flight ターンを殺さない)。
4380
- recordDeferredFresh()
4381
- logger?.info(
4382
- { session: sessionName },
4383
- "tui rebind: deferred +new (pane busy) until turn ends",
4384
- )
4385
- } else {
4386
- // 非 fresh の冪等記録 (動いているセッション本人への再 bind)。
4387
- ctx.tuiReboundSessions.set(sessionName, bindKey)
4388
- logger?.info(
4389
- { session: sessionName, session_id: targetId },
4390
- "tui rebind: skipped respawn (turn in progress on the running session)",
4391
- )
4392
- }
4393
- } else if (
4394
- fresh &&
4395
- Date.now() - (ctx.lastFreshRespawnAt.get(sessionName) || 0) <
4396
- FRESH_RESPAWN_THROTTLE_MS
4397
- ) {
4398
- // idle ペインへの fresh remount storm (再マウント毎に新 request_id) を 1 回へ collapse。
4399
- // 直近 fresh respawn から閾値内の fresh は再送とみなし skip (new セッション乱造を防ぐ)。
4400
- rebind = { ok: true, skipped: true }
4401
- logger?.info(
4402
- { session: sessionName },
4403
- "tui rebind: throttled fresh respawn (idempotent remount within window)",
4404
- )
4405
- } else {
4406
- rebind = await rebindClaudeSession(sessionName, targetId, {
4407
- cwd,
4408
- model: ctx.config?.claude_model || "",
4409
- permissionMode: ctx.config?.claude_permission_mode || "",
4410
- fresh,
4411
- busy,
4412
- logger,
4413
- })
4414
- if (rebind.ok) {
4415
- ctx.tuiReboundSessions.set(sessionName, bindKey)
4416
- if (fresh) ctx.lastFreshRespawnAt.set(sessionName, Date.now())
4417
- } else if (rebind.skipped && rebind.reason === "busy" && fresh) {
4418
- // チョークポイントが busy で弾いた (probe が false でも実行直前にターン開始した競合)。
4419
- // defer に載せて +新規 を取りこぼさない。
4420
- recordDeferredFresh()
4421
- }
4422
- }
4423
- }
4424
- reply({
4425
- session_id: targetId,
4426
- fresh,
4427
- stopped_sdk: stoppedSdk,
4428
- rebound: !!rebind.ok,
4429
- skipped: !!rebind.skipped,
4430
- error: rebind.ok ? undefined : rebind.error,
4431
- })
4432
- // 取りこぼし回復 (2026-06-07): 直近のターン境界が `stop` ならファイル正本から
4433
- // 再配信する。fs.watch 由来の session.event push は揮発性 (WS 切断・remount 中の
4434
- // broadcast は失われる) で、stop を取りこぼした frontend は三点リーダーが回り
4435
- // 続ける。bind は TUI ビューのマウントごとに来るので、ここで最新 stop を返せば
4436
- // 再表示時に必ずターン終了が伝わる。prompt_submit は再配信しない (stale な
4437
- // 「処理中」を誤って点灯させないため。点灯は jsonl エコー / 実 hook が担う)。
4438
- if (sessionName) {
4439
- const lastEvent = await readSessionEventFile(sessionName)
4440
- if (lastEvent && lastEvent.event === "stop") {
4441
- ctx.client.send({
4442
- type: "session.event",
4443
- session_name: sessionName,
4444
- event: lastEvent.event,
4445
- at: lastEvent.at,
4446
- replay: true,
4447
- // この再配信は bind(=フォーカス) 契機の status 復旧専用。並び順は絶対に
4448
- // 動かさない (フォーカスで浮上させない現象B 根治の一部)。
4449
- sort_affecting: false,
4450
- })
4451
- }
4452
- }
4453
- // 即時ステータススナップショット (#2): session.state は差分送信なので、アイドル
4454
- // セッションへ切替えても現在値が届かず、ペイン由来の三点リーダー補正が次の poll
4455
- // (~8-10s) まで遅れる。bind は TUI ビューのマウントごとに来るので、ここで現在状態
4456
- // を 1 回 push して初回サンプルを即届け、偽点灯/消灯確定を早める (noCache で実測)。
4457
- if (sessionName) {
4458
- try {
4459
- const snap = await detectSessionState(sessionName, {
4460
- noCache: true,
4461
- logger,
4462
- })
4463
- // bind が確定している復帰は、capture-pane の見た目や cwd 最新ファイルでなく
4464
- // **指定 session_id の JSONL** から context% を即時解決する。fresh はまだ実 ID
4465
- // が無いため 0% と fresh:<request_id> を対にして送る。
4466
- const contextSessionId = fresh ? bindKey : targetId
4467
- const boundContext =
4468
- targetId && cwd
4469
- ? await jsonlContextForCwd(cwd, targetId).catch(() => null)
4470
- : null
4471
- const contextPct = fresh
4472
- ? 0
4473
- : typeof boundContext?.percent === "number"
4474
- ? boundContext.percent
4475
- : snap.context_pct
4476
- // 【問題1 根治 2026-06-22】配る status はプロセス実体の busy を権威に解決する
4477
- // (resolveBindSnapshotStatus)。capture(SPINNER_LINE_RE)の偽陽性に勝たせる。
4478
- // - busy=true (ターン進行中): capture gap で snap.status が waiting に化けても
4479
- // processing を強制 = 症状C(再マウントで生成中の三点リーダーが消える偽陰性)の masking。
4480
- // - busy=false (プロセス実体 idle): snap.status が processing でも、それは静的完了行の
4481
- // 偽陽性(無生成時の誤点灯=問題1)なので processing を配らず waiting へ降格する。bind は
4482
- // noCache 単発読みで freeze 履歴が無く偽陽性を弾けないため、ここで busy を権威にする。
4483
- // generating(capture 由来)でなく busy(isArmed + 子孫数>baseline)を使うのが肝。
4484
- // proc_busy は配らない: outputActive(出力残り火)を含み真 idle でも true になり高速消灯
4485
- // (症状B)を再発させるため。
4486
- const snapStatus = resolveBindSnapshotStatus(busy, snap.status)
4487
- ctx.client.send({
4488
- type: "session.state",
4489
- session_name: sessionName,
4490
- status: snapStatus,
4491
- context_pct: contextPct,
4492
- context_session_id: contextSessionId ?? null,
4493
- permission_mode: snap.permission_mode,
4494
- stable:
4495
- !busy && snapStatus !== "processing" && snap.stable === true,
4496
- })
4497
- } catch (err) {
4498
- logger?.warn(
4499
- { err: err?.message, sessionName },
4500
- "claude.tui.bind status snapshot failed",
4501
- )
4502
- }
4503
- }
4504
- } catch (err) {
4505
- logger?.warn(
4506
- { err: err?.message, sessionName, cwd },
4507
- "claude.tui.bind failed",
4508
- )
4509
- reply({ session_id: null, error: err?.message || String(err) })
4510
- }
4511
- return
4512
- }
4513
3534
  case "terminal.mirror.start": {
4514
3535
  // Cockpit Codex 両対応 Stream C (2026-07-03): チャット(SDK/app-server)の会話を
4515
3536
  // 変えずに、ターミナルのペイン (普通のシェルのまま) へ読み取り専用の実況を
@@ -4984,18 +4005,27 @@ async function dispatch(msg, ctx) {
4984
4005
  const stream_id = msg.stream_id
4985
4006
  const cwd = msg.cwd || ""
4986
4007
  const limit = typeof msg.limit === "number" ? msg.limit : undefined
4008
+ // Read-plane 案C ①②: read は自己記述。読む対象はリクエストの profile_id で固定し
4009
+ // (env の揮発値に依存しない)、epoch と解決済み文脈をエコーして frontend の
4010
+ // 単一 read ゲートが stale/逆順/別プロファイル応答を判別できるようにする。
4011
+ const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
4012
+ const { sessionsDir, profileId } = await codexProfiles.resolveSessionsDir(
4013
+ msg.profile_id,
4014
+ )
4987
4015
  if (!ctx.codexBridge) {
4988
4016
  ctx.client.send({
4989
4017
  type: "codex.sessions.response",
4990
4018
  stream_id,
4991
4019
  cwd,
4020
+ epoch,
4021
+ profile_id: profileId,
4992
4022
  sessions: [],
4993
4023
  error: "codex_unavailable: codex CLI が agent の PATH に見つかりません",
4994
4024
  })
4995
4025
  return
4996
4026
  }
4997
4027
  try {
4998
- const sessions = await ctx.codexBridge.listThreads({ cwd, limit })
4028
+ const sessions = await ctx.codexBridge.listThreads({ cwd, limit, sessionsDir })
4999
4029
  // Stage 5 ③: 各 thread に「hub-agent 側で app-server が live か / busy か」を付す。
5000
4030
  // rollout 由来の一覧(listThreads)は live 状態を知らないため、describeLiveSessions
5001
4031
  // の thread_id → {live, busy} を併合する。browser は sidebar に live/reaped ドットを出す。
@@ -5012,6 +4042,8 @@ async function dispatch(msg, ctx) {
5012
4042
  type: "codex.sessions.response",
5013
4043
  stream_id,
5014
4044
  cwd,
4045
+ epoch,
4046
+ profile_id: profileId,
5015
4047
  sessions: withLive,
5016
4048
  })
5017
4049
  } catch (err) {
@@ -5019,6 +4051,8 @@ async function dispatch(msg, ctx) {
5019
4051
  type: "codex.sessions.response",
5020
4052
  stream_id,
5021
4053
  cwd,
4054
+ epoch,
4055
+ profile_id: profileId,
5022
4056
  sessions: [],
5023
4057
  error: err.message,
5024
4058
  })
@@ -5033,8 +4067,11 @@ async function dispatch(msg, ctx) {
5033
4067
  const cwd = msg.cwd || ""
5034
4068
  const session_id = msg.session_id || ""
5035
4069
  const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
5036
- // active プロファイルの projects/ から読む (アカウント別に履歴がサイロ化されているため)。
5037
- const projectsRoot = await getActiveProjectsRoot()
4070
+ // Read-plane 案C ①: 読む projects/ は **リクエストの profile_id** から解決する
4071
+ // (アカウント別に履歴がサイロ化されており、env 由来の active に頼ると切替中の
4072
+ // in-flight read が別アカウントの履歴を返す)。profile_id 未指定は active へ縮退。
4073
+ const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
4074
+ const { projectsRoot, profileId } = await resolveProjectsRoot(msg.profile_id)
5038
4075
  const result = await fetchSessionHistory({
5039
4076
  cwd,
5040
4077
  session_id,
@@ -5052,6 +4089,10 @@ async function dispatch(msg, ctx) {
5052
4089
  // その結果、無条件 hydrate → session_id 上書き → 購読スワップ → localStorage 汚染の
5053
4090
  // 経路が残っていた (sessionScope.ts が stream_id 主軸で守るのは relay イベントのみ)。
5054
4091
  cwd,
4092
+ // 案C ②: epoch と解決済み profile を必ずエコーする。frontend の単一 read ゲートは
4093
+ // この 2 つで「今の問いへの最新の答えか」を判定し、stale/逆順/別プロファイルを drop する。
4094
+ epoch,
4095
+ profile_id: profileId,
5055
4096
  events: result.events,
5056
4097
  total_lines: result.total_lines,
5057
4098
  truncated: result.truncated,
@@ -5066,8 +4107,9 @@ async function dispatch(msg, ctx) {
5066
4107
  const stream_id = msg.stream_id
5067
4108
  const cwd = msg.cwd || ""
5068
4109
  const limit = typeof msg.limit === "number" ? msg.limit : undefined
5069
- // active プロファイルの projects/ からセッション一覧を読む。
5070
- const projectsRoot = await getActiveProjectsRoot()
4110
+ // Read-plane 案C ①: 一覧もリクエストの profile_id から projects/ を解決する。
4111
+ const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
4112
+ const { projectsRoot, profileId } = await resolveProjectsRoot(msg.profile_id)
5071
4113
  const result = await listSessions({
5072
4114
  cwd,
5073
4115
  limit,
@@ -5078,6 +4120,8 @@ async function dispatch(msg, ctx) {
5078
4120
  type: "claude.sessions.response",
5079
4121
  stream_id,
5080
4122
  cwd,
4123
+ epoch,
4124
+ profile_id: profileId,
5081
4125
  sessions: result.sessions || [],
5082
4126
  error: result.error,
5083
4127
  })