@cocorograph/hub-agent 0.7.105 → 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 ||
@@ -1190,50 +1152,17 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1190
1152
  // ready-for-input 判定: claude プロセスの子プロセスを内省し「Stop フック/ツール実行が
1191
1153
  // 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
1192
1154
  // Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
1193
- // TUI の tmux session_name → Claude session_id。state loop 起動より**前**に作る。
1194
- // 後で作ると state loop には undefined が渡り、バックグラウンド時だけ cwd 最新 JSONL
1195
- // へフォールバックして別会話の 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
+ // ライブセッションへフォールバック」経路 (下記) が正しく効く。
1196
1162
  ctx.tuiReboundSessions = new Map()
1197
1163
 
1198
1164
  const readinessTracker = new ReadinessTracker()
1199
- // claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
1200
- // respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
1201
1165
  ctx.readinessTracker = readinessTracker
1202
- // 【+新規 defer — 構造版 root 2026-06-22】busy (ターン進行中) のペインへ来た fresh (+新規) は
1203
- // respawn-pane -k で殺さず保留し、ready (ターン終了) で 1 回だけ実行する。
1204
- // session_name → {cwd, request_id, at, model, permissionMode}。
1205
- const pendingFreshRespawn = new Map()
1206
- ctx.pendingFreshRespawn = pendingFreshRespawn
1207
- // fresh respawn のスロットル: idle ペインへの remount storm (再マウント毎に新 request_id) で
1208
- // fresh が連打され new セッションを乱造するのを防ぐ。session_name → epoch ms。
1209
- const lastFreshRespawnAt = new Map()
1210
- ctx.lastFreshRespawnAt = lastFreshRespawnAt
1211
- // ready エッジで呼ばれ、保留中の +新規 を実行する (busy が解けたので非破壊)。
1212
- const flushDeferredFreshRespawn = async (name) => {
1213
- const pending = pendingFreshRespawn.get(name)
1214
- if (!pending) return
1215
- pendingFreshRespawn.delete(name)
1216
- if (Date.now() - pending.at > PENDING_FRESH_TTL_MS) {
1217
- logger.info({ session: name }, "tui rebind: deferred +new expired before ready, dropped")
1218
- return
1219
- }
1220
- try {
1221
- const rebind = await rebindClaudeSession(name, null, {
1222
- cwd: pending.cwd,
1223
- model: pending.model || "",
1224
- permissionMode: pending.permissionMode || "",
1225
- fresh: true,
1226
- busy: false,
1227
- logger,
1228
- })
1229
- if (rebind.ok) {
1230
- lastFreshRespawnAt.set(name, Date.now())
1231
- logger.info({ session: name }, "tui rebind: executed deferred +new after ready")
1232
- }
1233
- } catch (err) {
1234
- logger.warn({ session: name, err: err?.message }, "deferred +new respawn failed")
1235
- }
1236
- }
1237
1166
  const stateLoop = startStateLoop({
1238
1167
  client,
1239
1168
  plugins,
@@ -1269,79 +1198,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1269
1198
  logger,
1270
1199
  tracker: readinessTracker,
1271
1200
  intervalMs: 700,
1272
- onReady: flushDeferredFreshRespawn,
1273
1201
  })
1274
1202
 
1275
- // TUI 権限ブリッジ: 対話 TUI の PreToolUse フックが書く権限要求を fs.watch で
1276
- // 拾い、既存 SDK 経路と同じ `claude.permission.request` として push する。
1277
- // 決定 (claude.permission.reply) は dispatch 側で request_id を見て
1278
- // tuiPermissionBridge.resolve() に優先ルーティングする。
1279
- const tuiPermissionBridge = new TuiPermissionBridge({ logger })
1280
- tuiPermissionBridge.on(
1281
- "permission",
1282
- async ({ request_id, session_id, cwd, tool_name, input, context_text }) => {
1283
- if (cwd) {
1284
- try {
1285
- // TUI 権限ブリッジ (PreToolUse フック) は Claude Code TUI 専用の経路なので
1286
- // 常に claude バケットへ記録する (T05945)。
1287
- recordChatActivity(cwd, "claude", { inputPending: true })
1288
- } catch {
1289
- /* ignore */
1290
- }
1291
- }
1292
- // ── 直前アシスタント説明の抽出 (症状4 根治) ─────────────────────────────
1293
- // PreToolUse フック発火時点で jsonl はまだ前ターン止まり (フックがブロック
1294
- // している間 claude は現ターンを jsonl に commit しない=デッドロック構造) の
1295
- // ため、フック側の transcript 抽出は前ターンの古い説明を掴む「取り違え」を
1296
- // 起こす。代わりに hub-agent がここで tmux capture-pane を打ち、TUI のペイン上
1297
- // に既に描画されている「現ターンの assistant 本文段落 (⏺ プレフィックス)」を
1298
- // 抜き出す。これが回答前に説明を届ける唯一の信頼ソースなので、フック由来の
1299
- // context_text の有無に関わらず **常に** capture-pane を試み、pickContextText で
1300
- // liveContext を最優先する (旧 bundle が古い context_text を送ってきても汚染
1301
- // しない)。抽出失敗時は null フォールバック (= 説明なし) で安全縮退。
1302
- let liveContext = null
1303
- if (cwd) {
1304
- try {
1305
- const sessions = await listTmuxSessions({ logger })
1306
- const match = Array.isArray(sessions)
1307
- ? sessions.find((s) => s && s.cwd === cwd)
1308
- : null
1309
- if (match && match.name) {
1310
- const pane = await capturePane(match.name, { noCache: true })
1311
- liveContext = extractLastAssistantText(pane)
1312
- }
1313
- } catch (err) {
1314
- logger?.debug?.(
1315
- { err: err?.message, cwd },
1316
- "capture-pane based context_text extraction failed; falling back to null",
1317
- )
1318
- }
1319
- }
1320
- client.send({
1321
- type: "claude.permission.request",
1322
- stream_id: null,
1323
- session_id,
1324
- cwd,
1325
- request_id,
1326
- tool_name,
1327
- input,
1328
- // 質問/承認カードの直前アシスタント説明 (browser がカード上部に表示)。
1329
- // 優先順位: (1) capture-pane で抽出した現ターンの liveContext、(2) フック由来
1330
- // の context_text (後方互換フォールバック)、(3) null。pickContextText 参照。
1331
- context_text: pickContextText(context_text, liveContext),
1332
- })
1333
- },
1334
- )
1335
- await tuiPermissionBridge.start()
1336
- ctx.tuiPermissionBridge = tuiPermissionBridge
1337
-
1338
- // TUI 閲覧マーカー registry: browser の claude.tui.viewing ハートビートを受けて
1339
- // 「閲覧中の session」をローカルのマーカーファイルとして保持する。承認フックは
1340
- // その鮮度を見て「閲覧者がいる session だけブリッジ、いなければ即 ask」する
1341
- // (watcher ゲート, WATCHER-GATE.md)。
1342
- const tuiViewerRegistry = new TuiViewerRegistry({ logger })
1343
- await tuiViewerRegistry.start()
1344
- ctx.tuiViewerRegistry = tuiViewerRegistry
1345
1203
 
1346
1204
  // sibling-inject (ask-sibling スキル, 2026-07-04): 同一 cwd の claude⇄codex
1347
1205
  // 相互依頼のためのローカル IPC 受信口。cwd スコープ capability トークン
@@ -1351,44 +1209,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1351
1209
  await siblingInjectBridge.start()
1352
1210
  ctx.siblingInjectBridge = siblingInjectBridge
1353
1211
 
1354
- // jsonl ライブ追従 (T04784 逐次表示): TUI チャットの閲覧ハートビートを起点に対象
1355
- // session の jsonl を tail し、追記行を claude.jsonl.event として逐次 push する。
1356
- // これで session.event 合図 → 全体再 hydrate + 2.5s ポーリングの「どさっと」を廃せる。
1357
- const jsonlLiveWatchers = new JsonlLiveWatchers({
1358
- send: (obj) => client.send(obj),
1359
- getProjectsRoot: getActiveProjectsRoot,
1360
- logger,
1361
- // ネイティブ回答検知 (症状1b 根治): tail 中の jsonl に tool_result が着弾し「あるツールが
1362
- // ターミナル側で解決された」と分かったら、その session の同名ツールの pending 権限カードを
1363
- // 畳み、browser へ claude.permission.cancel を送って残存カードを消す。browser が先に
1364
- // resolve() 済みなら _pending は既に空で dropBySession は [] を返す (= no-op、cancel も出ない)。
1365
- onToolResolved: ({ session_id, cwd, toolName }) => {
1366
- const dropped =
1367
- tuiPermissionBridge.dropBySession({ session_id, cwd, toolName }) || []
1368
- for (const request_id of dropped) {
1369
- try {
1370
- client.send({
1371
- type: "claude.permission.cancel",
1372
- request_id,
1373
- session_id,
1374
- cwd,
1375
- reason: "answered_in_terminal",
1376
- })
1377
- } catch {
1378
- /* ignore */
1379
- }
1380
- }
1381
- if (dropped.length) {
1382
- logger.info(
1383
- { session_id, cwd, tool: toolName, count: dropped.length },
1384
- "tui permission resolved natively → cancelled browser card",
1385
- )
1386
- }
1387
- },
1388
- })
1389
- jsonlLiveWatchers.start()
1390
- ctx.jsonlLiveWatchers = jsonlLiveWatchers
1391
-
1392
1212
  // モデル能力レジストリ (2026-07-11): SDK supportedModels() を起動時に引き、effort
1393
1213
  // 対応可否の判定を正規表現ハードコードから権威データへ置き換える。能力はセッション中
1394
1214
  // 不変なので attach ごとには引き直さない。ただし control-only query は cold start 直後に
@@ -1409,21 +1229,12 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
1409
1229
  )
1410
1230
  }
1411
1231
 
1412
- // session 回転通知 (T04786 後続) の多重送信ガード: 回転キー (tmux 名 or cwd) →
1413
- // 最後に push した new_session_id。閲覧ハートビートは 5s 間隔で来るため、フロントが
1414
- // 切替を完了し new id でハートビートを送り直すまでの間に来る余分なハートビートで
1415
- // claude.session.rotated を連発しないようにする。
1416
- ctx.tuiRotationNotified = new Map()
1417
-
1418
1232
  const shutdown = async (signal) => {
1419
1233
  logger.info({ signal }, "shutting down")
1420
1234
  await runHookBroadcast(plugins, "onAgentStop", ctx)
1421
1235
  stateLoop.stop()
1422
1236
  sessionEventLoop?.stop?.()
1423
1237
  readinessLoop?.stop?.()
1424
- tuiPermissionBridge.stop()
1425
- tuiViewerRegistry.stop()
1426
- jsonlLiveWatchers.stop()
1427
1238
  siblingInjectBridge.stop()
1428
1239
  ptyBridge.shutdown()
1429
1240
  claudeBridge?.shutdown?.()
@@ -2027,39 +1838,6 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
2027
1838
  }
2028
1839
  }
2029
1840
 
2030
- /**
2031
- * 【構造版 root 2026-06-22】idle ペインへの fresh remount storm を 1 回へ collapse する閾値。
2032
- * 直近 fresh respawn からこの時間内の同一セッションへの fresh は idempotent な再送とみなす。
2033
- */
2034
- const FRESH_RESPAWN_THROTTLE_MS = 8_000
2035
- /** busy で defer した +新規 の保留 TTL。ready が来ないまま席を立った等で破棄する。 */
2036
- const PENDING_FRESH_TTL_MS = 600_000
2037
-
2038
- /**
2039
- * 【構造版 root 2026-06-22】bind 時の即時 busy プローブ (プロセス実体)。
2040
- * 「ターン進行中の claude を kill しない」判定の唯一の根拠にする。capture スクレイプや armed の
2041
- * 140s cap の脆さに依存しない:
2042
- * - isArmed: prompt_submit→ready の間 true (生成中 + Stop フック中を含む)。一次シグナル。
2043
- * - 補強: pane_pid 子孫数 > 学習 baseline を ps スナップショットで直接確認 (armed cap / 未観測の
2044
- * 隙間でも取りこぼさない)。baseline 未学習 (= ターン未観測の純 idle) は false (idle の +新規を妨げない)。
2045
- * 判定不能時は安全側 false (= kill 許可) で idle の +新規 を妨げない。armed が一次防御なので、
2046
- * 真にターン中なら isArmed=true でここに来る前に true を返す。
2047
- */
2048
- async function isPaneBusyNow(readinessTracker, name) {
2049
- if (!name || !readinessTracker) return false
2050
- if (readinessTracker.isArmed(name) === true) return true
2051
- try {
2052
- const panePid = await getPanePid(name)
2053
- if (!panePid) return false
2054
- const procMap = await snapshotProcs()
2055
- if (procMap.size === 0) return false
2056
- const baseline = readinessTracker.getBaseline(name)
2057
- if (baseline === null) return false
2058
- return busyChildCount(panePid, procMap) > baseline
2059
- } catch {
2060
- return false
2061
- }
2062
- }
2063
1841
 
2064
1842
  /**
2065
1843
  * arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
@@ -3200,17 +2978,42 @@ export function handleClaudeChatInput(msg, ctx) {
3200
2978
  * claude.attach の ready 直後にpushする履歴を組み立てる。
3201
2979
  * Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
3202
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] テスト用の履歴読取注入。
3203
2997
  */
3204
2998
  export async function loadClaudeAttachHistory({
3205
2999
  cwd,
3206
3000
  sessionId,
3207
3001
  logger,
3208
- getProjectsRootFn = getActiveProjectsRoot,
3002
+ resolved,
3209
3003
  fetchHistoryFn = fetchSessionHistory,
3210
3004
  }) {
3211
3005
  if (!cwd || !sessionId) return null
3006
+ const profileId = resolved?.profileId
3007
+ const withProfile = (payload) =>
3008
+ profileId ? { ...payload, profile_id: profileId } : payload
3212
3009
  try {
3213
- 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()
3214
3017
  const result = await fetchHistoryFn({
3215
3018
  cwd,
3216
3019
  session_id: sessionId,
@@ -3218,7 +3021,7 @@ export async function loadClaudeAttachHistory({
3218
3021
  projectsRoot,
3219
3022
  logger,
3220
3023
  })
3221
- return {
3024
+ return withProfile({
3222
3025
  type: "claude.history.response",
3223
3026
  session_id: sessionId,
3224
3027
  cwd,
@@ -3226,9 +3029,9 @@ export async function loadClaudeAttachHistory({
3226
3029
  total_lines: result.total_lines || 0,
3227
3030
  truncated: result.truncated === true,
3228
3031
  error: result.error,
3229
- }
3032
+ })
3230
3033
  } catch (err) {
3231
- return {
3034
+ return withProfile({
3232
3035
  type: "claude.history.response",
3233
3036
  session_id: sessionId,
3234
3037
  cwd,
@@ -3236,10 +3039,52 @@ export async function loadClaudeAttachHistory({
3236
3039
  total_lines: 0,
3237
3040
  truncated: false,
3238
3041
  error: err?.message || String(err),
3239
- }
3042
+ })
3240
3043
  }
3241
3044
  }
3242
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
+
3243
3088
  /**
3244
3089
  * SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
3245
3090
  * `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
@@ -3315,97 +3160,6 @@ export function handleCodexChatInput(msg, ctx) {
3315
3160
  return p
3316
3161
  }
3317
3162
 
3318
- /**
3319
- * 離席中キュー投入 (claude.tui.queue.flush) の冪等化用に、処理済み flush_id を記憶する。
3320
- * WS 再送で同じ flush が二重に届いても二重 paste しないため。上限を超えたら全消去 (best-effort)。
3321
- */
3322
- const _seenFlushIds = new Set()
3323
- const _SEEN_FLUSH_CAP = 2000
3324
-
3325
- /**
3326
- * claude.tui.queue.flush の処理 (離席中キュー自動投入の agent 側受け口)。
3327
- * ブラウザの常駐ドレイン (useCockpitTuiQueueDrainAll) が、表示していないセッションの送信待ち
3328
- * キュー先頭 1 件をここへ送る。tmux send-keys (PTY 非依存) で投入するので、そのセッションの
3329
- * ターミナルを誰も開いていなくても届く。結果を flush.ack で返し、ブラウザは ack を受けて
3330
- * はじめて localStorage から 1 件 dequeue する。
3331
- *
3332
- * 冪等性は 2 段:
3333
- * 1. flush_id 単位 (_seenFlushIds): WS 再送・ack 取りこぼしによる同一 flush の二重投入を防ぐ。
3334
- * 2. 論理 item id 単位 (cross-path): browser の active 経路 (tracked PTY paste) は同一 item を
3335
- * input_id `qd:<itemId>:p` / `:r` で送る。ここと単一レジストリ (seenInputIds) を共有し、同一
3336
- * item がどちらの経路で配送されても agent は at-most-once でしか paste しない (二重送信の根治)。
3337
- *
3338
- * テストから直接呼べるよう dispatch から切り出して export する。paste 実体は ctx で差し替え可能。
3339
- */
3340
- export async function handleQueueFlush(msg, ctx) {
3341
- const paste = ctx.pasteToSessionByName || pasteToSessionByName
3342
- const sessionName =
3343
- typeof msg.session_name === "string" ? msg.session_name : ""
3344
- const text = typeof msg.text === "string" ? msg.text : ""
3345
- const flushId = typeof msg.flush_id === "string" ? msg.flush_id : ""
3346
- const itemId = typeof msg.item_id === "string" ? msg.item_id : ""
3347
- const pasteKey = itemId ? `qd:${itemId}:p` : ""
3348
- const crKey = itemId ? `qd:${itemId}:r` : ""
3349
- const ackFail = (reason) => {
3350
- if (flushId && sessionName) {
3351
- ctx.client.send({
3352
- type: "claude.tui.queue.flush.ack",
3353
- session_name: sessionName,
3354
- flush_id: flushId,
3355
- ok: false,
3356
- error: reason,
3357
- })
3358
- }
3359
- }
3360
- const ackOk = () => {
3361
- if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
3362
- _seenFlushIds.add(flushId)
3363
- ctx.client.send({
3364
- type: "claude.tui.queue.flush.ack",
3365
- session_name: sessionName,
3366
- flush_id: flushId,
3367
- ok: true,
3368
- })
3369
- }
3370
- if (!sessionName || !flushId || !text) {
3371
- ackFail("missing session_name / flush_id / text")
3372
- return
3373
- }
3374
- // 冪等化1 (message 再送)。
3375
- if (_seenFlushIds.has(flushId)) {
3376
- ackOk()
3377
- return
3378
- }
3379
- // 冪等化2 (cross-path): 同一 item を active 経路 (tracked PTY paste) が既に配送済みなら
3380
- // 再 paste せず ok を返す (browser は ack で dequeue する)。
3381
- if (pasteKey && seenInputIds.has(pasteKey)) {
3382
- ackOk()
3383
- return
3384
- }
3385
- // paste 前に cross-path キーを登録する。await 中に並走する active 経路の tracked paste
3386
- // (handleTrackedPtyData) を dedup させ二重 paste を防ぐため (登録は paste 完了より先)。
3387
- // 失敗時は登録解除し、再試行 (別 flush_id) で再配送できるようにする。
3388
- if (pasteKey) {
3389
- rememberInputId(pasteKey)
3390
- rememberInputId(crKey)
3391
- }
3392
- const result = await paste(sessionName, text, { logger: ctx.logger })
3393
- if (result.ok) {
3394
- if (_seenFlushIds.size >= _SEEN_FLUSH_CAP) _seenFlushIds.clear()
3395
- _seenFlushIds.add(flushId)
3396
- } else if (pasteKey) {
3397
- seenInputIds.delete(pasteKey)
3398
- seenInputIds.delete(crKey)
3399
- }
3400
- ctx.client.send({
3401
- type: "claude.tui.queue.flush.ack",
3402
- session_name: sessionName,
3403
- flush_id: flushId,
3404
- ok: !!result.ok,
3405
- error: result.ok ? undefined : result.error,
3406
- })
3407
- }
3408
-
3409
3163
  /**
3410
3164
  * tmux.kill_session の処理 (症状3 根治)。要求された session を kill し、要求元へ
3411
3165
  * tmux.kill_session.result を返したうえで、kill できた各 session について
@@ -3452,76 +3206,6 @@ export async function handleKillSession(msg, ctx) {
3452
3206
  }
3453
3207
  }
3454
3208
 
3455
- /**
3456
- * claude.tui.interrupt の処理 (確認付き中断 = Phase3)。生 ESC を tracked pty.data で best-effort
3457
- * 送出する旧経路 (stream 欠落で無言ドロップ + ESC 到達でも止まったか未検証) を、agent が ESC を
3458
- * tmux send-keys (PTY 非依存で確実配達) で送り、ペイン status で生成停止を観測してから結果を返す
3459
- * 方式に置き換える。
3460
- *
3461
- * 不変条件: 中断指示は、claude と同一ホストで生成状態を観測できる agent が「停止を観測 or deadline」
3462
- * まで責任を持つ。止まらなければ deadline 内で 1 度だけ ESC を再送する (初回が一過性 mode に吸われた
3463
- * 場合の保険)。Ctrl+C へはエスカレーションしない (claude を終了させる事故を避けるため。別判断)。
3464
- *
3465
- * テストから直接呼べるよう export。送出 (sendInterruptKey) / 観測 (detectSessionState) / 待機 (delay)
3466
- * は ctx で差し替え可能。
3467
- */
3468
- export async function handleTuiInterrupt(msg, ctx) {
3469
- const sessionName =
3470
- typeof msg.session_name === "string" ? msg.session_name : ""
3471
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
3472
- const requestId = typeof msg.request_id === "string" ? msg.request_id : ""
3473
- if (!sessionName || !requestId) return
3474
- const sendEsc = ctx.sendInterruptKey || sendInterruptKey
3475
- const detect = ctx.detectSessionState || detectSessionState
3476
- const delay = ctx.delay || ((ms) => new Promise((r) => setTimeout(r, ms)))
3477
- // 症状⑤: 初回チェックまでの settle と poll 間隔を 350→150ms に詰め、maxPolls を 7→16 に
3478
- // 増やす。総 deadline は ~2.4s で従来(~2.5s)とほぼ同じだが、実際に停止した瞬間の観測が
3479
- // 約2倍速くなり「停止押下→画面が止まる」体感ラグを縮める。capture-pane スクレイプ回数は
3480
- // 増えるが 150ms 間隔なら許容範囲。Ctrl+C 非エスカレーション方針は不変。
3481
- const settleMs = ctx.interruptSettleMs ?? 150
3482
- const pollMs = ctx.interruptPollMs ?? 150
3483
- const maxPolls = ctx.interruptMaxPolls ?? 16 // ~2.4s deadline (150 + 150*15)
3484
- const reply = (stopped, attempts) =>
3485
- ctx.client.send({
3486
- type: "claude.tui.interrupt.result",
3487
- request_id: requestId,
3488
- session_name: sessionName,
3489
- cwd: cwd || undefined,
3490
- stopped: !!stopped,
3491
- attempts,
3492
- })
3493
- const stillProcessing = async () => {
3494
- try {
3495
- const snap = await detect(sessionName, { logger: ctx.logger })
3496
- return (snap?.status ?? "processing") === "processing"
3497
- } catch {
3498
- // capture 失敗時は判定不能 → 保守的に「まだ生成中かも」と扱う (誤って stopped=true にしない)。
3499
- return true
3500
- }
3501
- }
3502
- // 1) ESC 送出 (= 中断)。
3503
- await sendEsc(sessionName, { logger: ctx.logger })
3504
- let attempts = 1
3505
- let resent = false
3506
- // 2) 停止 (status が processing を離れる) を deadline 内で観測。
3507
- for (let i = 0; i < maxPolls; i++) {
3508
- await delay(i === 0 ? settleMs : pollMs)
3509
- if (!(await stillProcessing())) {
3510
- reply(true, attempts)
3511
- return
3512
- }
3513
- // deadline 中盤でまだ processing なら 1 度だけ ESC 再送 (初回が一過性 mode に吸われた保険)。
3514
- // status=processing 継続が条件なので、アイドル箱への二度押し (rewind ダイアログ誤爆) は避ける。
3515
- if (!resent && i >= Math.floor(maxPolls / 2)) {
3516
- resent = true
3517
- await sendEsc(sessionName, { logger: ctx.logger })
3518
- attempts++
3519
- }
3520
- }
3521
- // 3) deadline 到達でまだ processing。停止確認できず (frontend は旧 tracked-ESC 経路へ degrade)。
3522
- reply(false, attempts)
3523
- }
3524
-
3525
3209
  async function dispatch(msg, ctx) {
3526
3210
  const t = msg?.type || ""
3527
3211
  try {
@@ -3664,13 +3348,15 @@ async function dispatch(msg, ctx) {
3664
3348
  // attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
3665
3349
  // 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
3666
3350
  // requestしてもhydrateのuuid dedupで安全に収束する。
3667
- const historyPromise = resumeSessionId
3668
- ? loadClaudeAttachHistory({
3669
- cwd: cwdArg || process.env.HOME || process.cwd(),
3670
- sessionId: resumeSessionId,
3671
- logger: ctx.logger,
3672
- })
3673
- : 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
+ })
3674
3360
  const info = ctx.claudeBridge.attach({
3675
3361
  stream_id,
3676
3362
  cwd: cwdArg,
@@ -3831,43 +3517,6 @@ async function dispatch(msg, ctx) {
3831
3517
  return
3832
3518
  }
3833
3519
  case "claude.permission.reply": {
3834
- // TUI ブリッジの pending なら .decision を書いて返す (TUI 経路優先)。
3835
- // 管理外 (= SDK チャットの request) なら従来の permissionReply へ。
3836
- const handledByTui = ctx.tuiPermissionBridge
3837
- ? await ctx.tuiPermissionBridge.resolve(
3838
- msg.request_id,
3839
- !!msg.allow,
3840
- msg.deny_message,
3841
- msg.updated_input,
3842
- )
3843
- : false
3844
- // 遅延回答 resume (frontend が session_name + resume_text を載せた TUI 回答のみ)。
3845
- // フックが既に死んでいて即時確定 (.decision 注入) が届かない場合、回答を新メッセージ
3846
- // として同一セッションへ送り、永続 claude が文脈ごと続行できるようにする。
3847
- const resumeText =
3848
- typeof msg.resume_text === "string" ? msg.resume_text : ""
3849
- const resumeSession =
3850
- typeof msg.session_name === "string" ? msg.session_name : ""
3851
- if (resumeText && resumeSession && ctx.tuiPermissionBridge) {
3852
- if (!handledByTui) {
3853
- // 期限切れ (pending に無い) → 即 resume。再配信しないよう drop。
3854
- ctx.tuiPermissionBridge.drop(msg.request_id)
3855
- void _resumeTuiAnswer(resumeSession, resumeText, msg.request_id)
3856
- } else {
3857
- // pending だった: 生存フックは ~0.15s で .decision を消費する。猶予後も
3858
- // 残っていれば フック死亡 (Claude Code timeout で kill 等) → resume へ。
3859
- const reqId = msg.request_id
3860
- setTimeout(() => {
3861
- if (ctx.tuiPermissionBridge?.hasUnconsumedDecision(reqId)) {
3862
- ctx.tuiPermissionBridge.dropDecision(reqId).catch(() => {})
3863
- ctx.tuiPermissionBridge.drop(reqId)
3864
- void _resumeTuiAnswer(resumeSession, resumeText, reqId)
3865
- }
3866
- }, RESUME_DECISION_GRACE_MS)
3867
- }
3868
- return
3869
- }
3870
- if (handledByTui) return
3871
3520
  if (!ctx.claudeBridge) return
3872
3521
  ctx.claudeBridge.permissionReply({
3873
3522
  stream_id: msg.stream_id,
@@ -3882,675 +3531,6 @@ async function dispatch(msg, ctx) {
3882
3531
  if (!ctx.claudeBridge) return
3883
3532
  ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
3884
3533
  return
3885
- case "claude.tui.viewing": {
3886
- // T04778 watcher ゲート: browser が TUI チャットでこの session を閲覧中。
3887
- // 閲覧マーカーを更新し、承認フックが「閲覧者あり」と判定できるようにする。
3888
- ctx.tuiViewerRegistry
3889
- ?.note({ session_id: msg.session_id, cwd: msg.cwd })
3890
- .catch(() => {})
3891
- // T04784 逐次表示: 閲覧中だけ jsonl を tail して claude.jsonl.event を push。
3892
- ctx.jsonlLiveWatchers
3893
- ?.note({ session_id: msg.session_id, cwd: msg.cwd })
3894
- .catch(() => {})
3895
- // T04786 後続: session 回転検知 (/clear で裏 claude が新 session_id へ切替)。
3896
- // 追従中ビュー (follow_newest 既定 true) のときだけ、cwd の最新 session_id と
3897
- // 閲覧中 id を突き合わせ、ズレていれば claude.session.rotated を push してフロントに
3898
- // 読み出し先の切替を促す。過去セッションを意図的に開いているビュー (follow_newest=false)
3899
- // では検知しない (ピン留め閲覧を最新へ引きずらない)。
3900
- const viewSid = msg.session_id
3901
- const viewCwd = msg.cwd
3902
- const viewName =
3903
- typeof msg.session_name === "string" ? msg.session_name : ""
3904
- const followNewest = msg.follow_newest !== false
3905
- if (followNewest && viewSid && viewCwd) {
3906
- ;(async () => {
3907
- try {
3908
- const projectsRoot = await getActiveProjectsRoot()
3909
- const { sessions } = await listSessions({
3910
- cwd: viewCwd,
3911
- projectsRoot,
3912
- limit: 1,
3913
- logger,
3914
- })
3915
- const newestId = sessions?.[0]?.session_id || null
3916
- const key = viewName || viewCwd
3917
- const prev = ctx.tuiRotationNotified.get(key)
3918
- const now = Date.now()
3919
- const prevNewId =
3920
- prev && typeof prev === "object" ? prev.newId : prev
3921
- const prevCount =
3922
- prev && typeof prev === "object" ? (prev.count ?? 0) : 0
3923
- const { rotated, newSessionId, capped } = decideSessionRotation({
3924
- viewingSessionId: viewSid,
3925
- newestSessionId: newestId,
3926
- // 旧形式 (文字列) と新形式 ({newId, ts, count}) の両対応。
3927
- lastNotifiedNewId: prevNewId,
3928
- lastNotifiedAt:
3929
- prev && typeof prev === "object" ? prev.ts : null,
3930
- lastNotifiedCount: prevCount,
3931
- now,
3932
- })
3933
- if (capped) {
3934
- // 安全弁発火: フロントが採用しないまま maxReNotify 回再通知済み。これ以上の
3935
- // rotated 連発を止め、無限ピンポンを断つ (フロント側 cwd 必須化で通常は到達しない)。
3936
- logger.warn(
3937
- { session: viewName, cwd: viewCwd, view: viewSid, newest: newestId },
3938
- "tui session rotation: capped (frontend not adopting; stopped re-notify)",
3939
- )
3940
- return
3941
- }
3942
- if (!rotated) return
3943
-
3944
- // 2a 根治: rotate-bind の前に「候補が本物の /clear 先か」を head 4KB で検証する。
3945
- // subagent (parent_uuid 等) / SDK headless (type=result) / cwd 不一致のものは
3946
- // 抑止し、別アクティビティ jsonl への汚染 bind を断つ。検証 NG 時は capped と同様に
3947
- // tuiRotationNotified を進めて連発を防ぐ。検証失敗 (read error) は accept = 旧挙動。
3948
- const candidatePath = jsonlPath({
3949
- cwd: viewCwd,
3950
- session_id: newSessionId,
3951
- projectsRoot,
3952
- })
3953
- const { accept, reason } = await validateRotationCandidate({
3954
- candidatePath,
3955
- viewCwd,
3956
- logger,
3957
- })
3958
- if (!accept) {
3959
- ctx.tuiRotationNotified.set(key, {
3960
- newId: newSessionId,
3961
- ts: now,
3962
- count: newSessionId === prevNewId ? prevCount + 1 : 1,
3963
- })
3964
- logger.warn(
3965
- {
3966
- session: viewName,
3967
- cwd: viewCwd,
3968
- view: viewSid,
3969
- newest: newestId,
3970
- reason,
3971
- },
3972
- "tui session rotation: suppressed (sibling activity / not a real /clear)",
3973
- )
3974
- return
3975
- }
3976
- // {newId, ts, count} で保存。同一 new への再通知をスロットリング+回数キャップする。
3977
- // newId が変われば count を 1 にリセット、同一なら加算する。
3978
- ctx.tuiRotationNotified.set(key, {
3979
- newId: newSessionId,
3980
- ts: now,
3981
- count: newSessionId === prevNewId ? prevCount + 1 : 1,
3982
- })
3983
- // 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
3984
- // respawn (claude 再起動) してしまうのを防ぐ。
3985
- if (viewName) ctx.tuiReboundSessions.set(viewName, newSessionId)
3986
- ctx.client.send({
3987
- type: "claude.session.rotated",
3988
- cwd: viewCwd,
3989
- session_name: viewName || undefined,
3990
- old_session_id: viewSid,
3991
- new_session_id: newSessionId,
3992
- })
3993
- // ★回転 (= session_id 境界) で、この NAME に紐づく状態トラッカーを忘れる。生成中を表す
3994
- // 信号 (proc_busy=ReadinessTracker baseline/armed, status gate ラッチ, spinner freeze,
3995
- // capture cache) は全て NAME キーのため、忘れないと旧ターンの busy が新 session_id の
3996
- // 表示へ漏れ、フロントで「/clear 後に三点リーダーが固着」する。発生源 (agent) で断つのが
3997
- // 主、フロントの再点灯レジスタ reset が従の不変条件 (name→id 漏れの根治)。
3998
- if (viewName) {
3999
- ctx.readinessTracker?.forget(viewName)
4000
- invalidateSessionCache(viewName)
4001
- }
4002
- // 回転 = 旧 session_id 境界。旧 session に紐づく pending 権限カードは orphan に
4003
- // なる (claude は新会話へ移った) ので畳み、browser へ cancel して残存カードを消す
4004
- // (症状1b 根治)。旧 session_id 完全一致で drop するため、回転直後に新 session で
4005
- // 出たばかりのカード (payload.session_id === newSessionId) は誤って巻き込まない。
4006
- if (ctx.tuiPermissionBridge) {
4007
- const dropped = ctx.tuiPermissionBridge.dropBySession({
4008
- session_id: viewSid,
4009
- })
4010
- for (const request_id of dropped) {
4011
- try {
4012
- ctx.client.send({
4013
- type: "claude.permission.cancel",
4014
- request_id,
4015
- session_id: viewSid,
4016
- cwd: viewCwd,
4017
- reason: "session_rotated",
4018
- })
4019
- } catch {
4020
- /* ignore */
4021
- }
4022
- }
4023
- }
4024
- logger.info(
4025
- { session: viewName, cwd: viewCwd, old: viewSid, new: newSessionId },
4026
- "tui session rotated (/clear) → notified browser",
4027
- )
4028
- } catch (err) {
4029
- logger.warn(
4030
- { err: err?.message, cwd: viewCwd },
4031
- "tui session rotation check failed",
4032
- )
4033
- }
4034
- })()
4035
- }
4036
- return
4037
- }
4038
- case "claude.tui.unviewing":
4039
- // 閲覧終了。session_id マーカーを即失効させ、以降の Bash を即 ask に倒す。
4040
- ctx.tuiViewerRegistry
4041
- ?.unview({ session_id: msg.session_id })
4042
- .catch(() => {})
4043
- // jsonl tail も即停止 (閲覧していない session を追従し続けない)。
4044
- ctx.jsonlLiveWatchers?.unwatch({ session_id: msg.session_id })
4045
- return
4046
- case "claude.tui.cyclePermission": {
4047
- // 権限バッジ押下 → 対話 claude TUI へ shift+tab を送って権限モードを循環。
4048
- // 旧実装は frontend が raw pty.data (BACKTAB) を送り、各端末が楽観値を
4049
- // 個別に 1 段進めるだけだったため、複数端末間でズレ、かつ tmux 上で直接
4050
- // 切り替えた変更とも乖離した。ここで agent が実キー送出 → ペイン再読込で
4051
- // 実モードを確定 → 全ブラウザへ claude.tui.permission を broadcast し、
4052
- // 「実際に動いているターミナルの状態」を正本として全端末に同期する。
4053
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4054
- const sessionName =
4055
- typeof msg.session_name === "string" ? msg.session_name : ""
4056
- if (!sessionName) return
4057
- ;(async () => {
4058
- try {
4059
- await cyclePermissionMode(sessionName, { logger })
4060
- // TUI がフッターバナーを再描画するまで少し待ってから実モードを読む。
4061
- await new Promise((r) => setTimeout(r, 250))
4062
- const text = await capturePane(sessionName, { noCache: true })
4063
- const mode = detectPermissionModeFromText(text)
4064
- ctx.client.send({
4065
- type: "claude.tui.permission",
4066
- cwd: cwd || undefined,
4067
- session_name: sessionName,
4068
- permission_mode: mode,
4069
- })
4070
- logger.info(
4071
- { session: sessionName, cwd, mode },
4072
- "tui permission cycled → notified browser",
4073
- )
4074
- } catch (err) {
4075
- logger.warn(
4076
- { err: err?.message, session: sessionName },
4077
- "claude.tui.cyclePermission failed",
4078
- )
4079
- }
4080
- })()
4081
- return
4082
- }
4083
- case "claude.tui.setModel": {
4084
- // モデルバッジ選択 → 対話 claude TUI へ `/model <id>` を送ってモデルを切り替える。
4085
- // 権限循環 (cyclePermission) と同設計: agent が実キーを送出 → 確認プロンプトを確定 →
4086
- // 全ブラウザへ claude.tui.model を broadcast し、実際に動いているターミナルのモデルを
4087
- // 正本として全端末に同期する。jsonl の message.model は次の assistant ターンまで更新
4088
- // されないため、この即時 broadcast で切替直後のバッジズレを解消する。
4089
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4090
- const sessionName =
4091
- typeof msg.session_name === "string" ? msg.session_name : ""
4092
- if (!sessionName) return
4093
- // model="" は「デフォルト」= `/model default`。frontend へはそのまま空で返し、
4094
- // 解決後の実 id は次ターンの jsonl 由来 (message.model) に委ねる。
4095
- const model = typeof msg.model === "string" ? msg.model : ""
4096
- // 先に broadcast してフロントの fallback (raw `/model` paste) を即キャンセルさせる。
4097
- // setTuiModel は「Switch model?」確認ダイアログの描画検知で数秒かかり得るため、その
4098
- // 完了を待つとフロントの MODEL_SYNC_FALLBACK_MS が先に発火して二重 paste になる。
4099
- // バッジは要求モデルを即表示し、確定値は次ターンの jsonl (message.model) で補正される。
4100
- ctx.client.send({
4101
- type: "claude.tui.model",
4102
- cwd: cwd || undefined,
4103
- session_name: sessionName,
4104
- model,
4105
- })
4106
- ;(async () => {
4107
- try {
4108
- await setTuiModel(sessionName, model || "default", { logger })
4109
- logger.info(
4110
- { session: sessionName, cwd, model: model || "(default)" },
4111
- "tui model switched → notified browser",
4112
- )
4113
- } catch (err) {
4114
- logger.warn(
4115
- { err: err?.message, session: sessionName },
4116
- "claude.tui.setModel failed",
4117
- )
4118
- }
4119
- })()
4120
- return
4121
- }
4122
- case "claude.tui.setEffort": {
4123
- // effort バッジ選択 → 対話 claude TUI へ `/effort <level>` を送って reasoning effort を
4124
- // 切り替える。setModel と同設計: agent が実キーを送出 → 全ブラウザへ claude.tui.effort を
4125
- // broadcast し、実際に動いているターミナルの effort を正本として全端末に同期する。
4126
- // effort は jsonl に記録されないため、この即時 broadcast が唯一の表示根拠になる。
4127
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4128
- const sessionName =
4129
- typeof msg.session_name === "string" ? msg.session_name : ""
4130
- if (!sessionName) return
4131
- // effort="" は「auto」= `/effort auto`。frontend へはそのまま空で返し、バッジは auto 表示。
4132
- const effort = typeof msg.effort === "string" ? msg.effort : ""
4133
- // setModel と同様、先に broadcast してフロントの fallback (raw `/effort` paste) を即
4134
- // キャンセルさせる。setTuiEffort も「Change effort level?」確認ダイアログの描画検知で
4135
- // 数秒かかり得るため、完了待ちだと EFFORT_SYNC_FALLBACK_MS が先に発火して二重 paste
4136
- // になる。effort は jsonl に記録されないので、この即時 broadcast が表示の根拠になる。
4137
- ctx.client.send({
4138
- type: "claude.tui.effort",
4139
- cwd: cwd || undefined,
4140
- session_name: sessionName,
4141
- effort,
4142
- })
4143
- ;(async () => {
4144
- try {
4145
- await setTuiEffort(sessionName, effort || "auto", { logger })
4146
- logger.info(
4147
- { session: sessionName, cwd, effort: effort || "(auto)" },
4148
- "tui effort switched → notified browser",
4149
- )
4150
- } catch (err) {
4151
- logger.warn(
4152
- { err: err?.message, session: sessionName },
4153
- "claude.tui.setEffort failed",
4154
- )
4155
- }
4156
- })()
4157
- return
4158
- }
4159
- case "claude.tui.answerAskUserQuestion": {
4160
- // 症状1a 根治 (1-B): ブラウザの AskUserQuestion カード回答を、フックの updatedInput
4161
- // (AskUserQuestion では原理的に効かない) でなく、agent が同一ホストの tmux ネイティブ
4162
- // メニューへ send-keys で届ける。capturePane で描画検知してから送出し状態変化を検証する
4163
- // (blind 送出禁止)。回答後 claude が tool_result を commit → JsonlLiveWatchers が検知して
4164
- // dropBySession → claude.permission.cancel でカードを畳む (1-A 経路と合成)。
4165
- const sessionName =
4166
- typeof msg.session_name === "string" ? msg.session_name : ""
4167
- const requestId =
4168
- typeof msg.request_id === "string" ? msg.request_id : undefined
4169
- // answers: 質問順の [{question?, labels: string[]}]。frontend が card の {questions, answers}
4170
- // から構築する。labels は選んだ選択肢ラベル群 (multiSelect は複数、single は1件)。
4171
- const answers = Array.isArray(msg.answers) ? msg.answers : []
4172
- if (!sessionName) return
4173
- const reply = (payload) =>
4174
- ctx.client.send({
4175
- type: "claude.tui.answerAskUserQuestion.result",
4176
- request_id: requestId,
4177
- session_name: sessionName,
4178
- ...payload,
4179
- })
4180
- ;(async () => {
4181
- try {
4182
- const r = await answerAskUserQuestion(sessionName, answers, { logger })
4183
- logger.info(
4184
- { session: sessionName, ok: r.ok, answered: r.answered, error: r.error },
4185
- "tui answerAskUserQuestion via send-keys",
4186
- )
4187
- reply({ ok: !!r.ok, answered: r.answered ?? 0, error: r.error })
4188
- } catch (err) {
4189
- logger.warn(
4190
- { err: err?.message, session: sessionName },
4191
- "claude.tui.answerAskUserQuestion failed",
4192
- )
4193
- reply({ ok: false, error: err?.message || String(err) })
4194
- }
4195
- })()
4196
- return
4197
- }
4198
- case "claude.tui.probePermission": {
4199
- // 読み取り専用の権限モード問い合わせ (cold-load seed)。キーは送らず、ペインを
4200
- // 読んで現在の実モードを claude.tui.permission として broadcast するだけ。
4201
- // ブラウザがマウント直後に「今ターミナルが何モードか」を取得し、楽観値や
4202
- // 古い jsonl 値とのズレを開始時点から揃えるために使う。検出不能 (claude 起動中・
4203
- // 素のシェル) のときは mode=null となり、frontend 側は据え置く。
4204
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4205
- const sessionName =
4206
- typeof msg.session_name === "string" ? msg.session_name : ""
4207
- if (!sessionName) return
4208
- ;(async () => {
4209
- try {
4210
- const text = await capturePane(sessionName, { noCache: true })
4211
- const mode = detectPermissionModeFromText(text)
4212
- // 検出できた時だけ通知する (null で badge を消さない)。
4213
- if (!mode) return
4214
- ctx.client.send({
4215
- type: "claude.tui.permission",
4216
- cwd: cwd || undefined,
4217
- session_name: sessionName,
4218
- permission_mode: mode,
4219
- })
4220
- } catch (err) {
4221
- logger.warn(
4222
- { err: err?.message, session: sessionName },
4223
- "claude.tui.probePermission failed",
4224
- )
4225
- }
4226
- })()
4227
- return
4228
- }
4229
- case "claude.tui.recoverInput": {
4230
- // 中断キャンセル後の入力欄復旧 (agent >= 0.6.57)。対話 claude は「送信直後の Esc」
4231
- // で本文を入力ボックスへ戻すため、放置すると次の bracketed paste が後ろへ連結され
4232
- // メッセージが合体する (2026-06-08 報告バグ)。frontend が Esc 送出直後にこれを送り、
4233
- // agent がペインを実測して「expect_text が入力ボックスへ戻った」ことを確認してから
4234
- // クリアし、結果を claude.tui.recoverInput.result で返す (backend が user-level
4235
- // broadcast し、frontend は request_id で自分の要求と照合する)。
4236
- const sessionName =
4237
- typeof msg.session_name === "string" ? msg.session_name : ""
4238
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4239
- const requestId =
4240
- typeof msg.request_id === "string" ? msg.request_id : ""
4241
- const expectText =
4242
- typeof msg.expect_text === "string" ? msg.expect_text : ""
4243
- if (!sessionName || !requestId) return
4244
- ;(async () => {
4245
- const result = await recoverTuiInput(sessionName, expectText, {
4246
- logger,
4247
- })
4248
- ctx.client.send({
4249
- type: "claude.tui.recoverInput.result",
4250
- request_id: requestId,
4251
- session_name: sessionName,
4252
- cwd: cwd || undefined,
4253
- cleared: !!result.cleared,
4254
- })
4255
- })()
4256
- return
4257
- }
4258
- case "claude.tui.interrupt": {
4259
- // 確認付き中断 (Phase3)。ロジックは handleTuiInterrupt に切り出し (テスト可能化)。
4260
- // async だが dispatch をブロックしないよう投げっぱなし (自前で result を返す)。
4261
- void handleTuiInterrupt(msg, ctx)
4262
- return
4263
- }
4264
- case "claude.tui.queue.flush": {
4265
- // 離席中(非アクティブセッション)キュー自動投入 (案A の agent 側受け口)。
4266
- // ロジックは handleQueueFlush に切り出し (テスト可能化 + cross-path 冪等)。async だが
4267
- // dispatch をブロックしないよう投げっぱなしにする (自前で flush.ack を返す)。
4268
- void handleQueueFlush(msg, ctx)
4269
- return
4270
- }
4271
- case "claude.tui.rehydratePermissions": {
4272
- // セッション切替でビューが再マウントすると、その時点の承認/質問カードは React
4273
- // state ごと消える (agent は claude.permission.request を 1 回しか push しない)。
4274
- // browser が再購読時にこれを送り、該当セッションの未応答リクエストを再 push して
4275
- // カードを復元する (frontend は request_id で重複排除)。
4276
- if (!ctx.tuiPermissionBridge) return
4277
- const sid =
4278
- typeof msg.session_id === "string" ? msg.session_id : null
4279
- const reqCwd = typeof msg.cwd === "string" ? msg.cwd : null
4280
- const pend = ctx.tuiPermissionBridge.listPending({
4281
- session_id: sid,
4282
- cwd: reqCwd,
4283
- })
4284
- for (const p of pend) {
4285
- ctx.client.send({
4286
- type: "claude.permission.request",
4287
- stream_id: null,
4288
- session_id: p.session_id,
4289
- cwd: p.cwd,
4290
- request_id: p.request_id,
4291
- tool_name: p.tool_name,
4292
- input: p.input,
4293
- // 再 hydrate でも直前説明を保つ (listPending が payload ごと保持している)。
4294
- context_text: p.context_text ?? null,
4295
- })
4296
- }
4297
- if (pend.length) {
4298
- logger.info(
4299
- { session_id: sid, cwd: reqCwd, count: pend.length },
4300
- "tui permission re-hydrated to browser",
4301
- )
4302
- }
4303
- return
4304
- }
4305
- case "claude.tui.bind": {
4306
- // T04784 会話継続バインディング: TUI モード突入時に 1 回送られる。
4307
- // 1. 対象 cwd の最新 session_id を確定 (browser 指定があれば優先)。
4308
- // 2. その session の SDK を完全停止 (二重ホスト/枠流出を防ぐ)。
4309
- // 3. 裏の tmux claude を `--resume <id>` で同じ会話に載せ替える (read=write 一致)。
4310
- // ベストエフォート。失敗時は frontend が従来 newest 表示に degrade する。
4311
- const request_id = msg.request_id
4312
- const cwd = typeof msg.cwd === "string" ? msg.cwd : ""
4313
- const sessionName =
4314
- typeof msg.session_name === "string" ? msg.session_name : ""
4315
- const reply = (payload) =>
4316
- ctx.client.send({
4317
- type: "claude.tui.bind.result",
4318
- request_id,
4319
- ...payload,
4320
- })
4321
- try {
4322
- // fresh: 「まっさらな新規セッション」要求 (ピッカーの「+新規」)。cwd 最新へ
4323
- // resume せず resume 無しの claude を起動する。session_id は起動後の初回送信で
4324
- // 生成され、frontend は回転検知 / sessions 応答で拾う。
4325
- const fresh = msg.fresh === true
4326
- // 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)。あわせて
4327
- // 現在動いているセッション (= cwd の最新 jsonl = newestId) も解決する。生成中ガードで
4328
- // 「再接続/remount による同一セッションへの再 bind」か「別 session への明示 switch」かを
4329
- // 区別するのに使う (前者は respawn を抑止して生成中 claude を温存する)。
4330
- let targetId =
4331
- typeof msg.session_id === "string" && msg.session_id
4332
- ? msg.session_id
4333
- : null
4334
- let newestId = null
4335
- if (!fresh && cwd) {
4336
- const projectsRoot = await getActiveProjectsRoot()
4337
- const { sessions } = await listSessions({
4338
- cwd,
4339
- projectsRoot,
4340
- limit: 1,
4341
- logger,
4342
- })
4343
- newestId = sessions?.[0]?.session_id || null
4344
- if (!targetId) targetId = newestId
4345
- }
4346
- // 生成中ガード (謎停止対策): respawn-pane -k は生成中 claude を強制 kill し in-flight 応答を失う。
4347
- // hub-agent 再起動直後は tuiReboundSessions が空で冪等ガードが効かず、ブラウザ再接続の bind が
4348
- // 生成中 claude を kill する。armed (ターン進行中) か pane=processing なら生成中とみなす。
4349
- let generating = ctx.readinessTracker?.isArmed?.(sessionName) === true
4350
- if (!generating && sessionName) {
4351
- try {
4352
- const snap = await detectSessionState(sessionName, {})
4353
- if (snap?.status === "processing") generating = true
4354
- } catch {
4355
- // capture 失敗時は据え置き (生成中とみなさない = 従来挙動)
4356
- }
4357
- }
4358
- // 2) SDK を停止 (cwd 全体 + 対象 id)。
4359
- let stoppedSdk = 0
4360
- if (ctx.claudeBridge) {
4361
- stoppedSdk = ctx.claudeBridge.stopSessionsFor({
4362
- cwd,
4363
- session_id: targetId || undefined,
4364
- })
4365
- }
4366
- // SDK を止めて TUI へ載せ替えた時点で、cwd のチャット信号 (SDK 用の
4367
- // status/context% 補完) は役目を終える。残すと TUI 権限カードの
4368
- // inputPending 記録が stale status の鮮度を延命し、state loop が
4369
- // ペイン実状態を waiting で上書きし続ける (2026-06-12 真因修正)。
4370
- // T05945: claude バケットのみ消す。同一 cwd で走っている codex チャットの
4371
- // 信号まで消すと、codex 行のドット/ドーナツが巻き添えで消灯する。
4372
- if (cwd) clearChatSignal(cwd, "claude")
4373
- // 3) tmux claude を起動し直す (resume or fresh)。
4374
- // 冪等性: 同じ session を同じキーへ既に載せ替え済みなら respawn しない
4375
- // (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
4376
- // claude が再起動ループになり送信不能になる。2026-06-06 leader 実機)。
4377
- // resume は targetId をキー、fresh は session_id 不在のため bind の request_id を
4378
- // キーにする (同一マウントの再送は同 request_id で skip、新規要求は再マウントで
4379
- // 新 request_id になり 1 回だけ起動する)。
4380
- // 実プロセス由来の「claude 実行中か」。respawn-pane -k による生成中 claude の誤 kill
4381
- // (症状D 再発 D2: armed 140s cap / capture gap で generating=false に倒れ、--resume bind
4382
- // でも実行中 claude を kill していた)を、capture/armed に依存しない pane_current_command で防ぐ。
4383
- const paneRunningClaude = await isPaneRunningClaude(sessionName, { logger })
4384
- // 【構造版 root 2026-06-22】プロセス実体の busy (ターン進行中) を bind 時に確定する。
4385
- // 「動いている claude を kill しない」の唯一の判定基準。frontend が fresh:true を誤送しても
4386
- // 生成中 claude を殺さない (frontend 単独依存からの脱却)。
4387
- const busy = await isPaneBusyNow(ctx.readinessTracker, sessionName)
4388
- // 非 fresh (resume/auto) の bind = ユーザーが既存会話を選んだ → 保留中の +新規 defer は破棄。
4389
- if (!fresh) ctx.pendingFreshRespawn.delete(sessionName)
4390
- let rebind = { ok: false, skipped: false }
4391
- // 新規会話の 0% は会話切替として扱うため、request_id を送れない旧 client
4392
- // 経由でも bind ごとに固有IDを作る。空の `fresh:` を再利用すると、2回目以降の
4393
- // 新規会話が同一会話扱いになり、直前値が残る。
4394
- const freshBindId = request_id || randomUUID()
4395
- const bindKey = fresh ? `fresh:${freshBindId}` : targetId
4396
- const recordDeferredFresh = () =>
4397
- ctx.pendingFreshRespawn.set(sessionName, {
4398
- cwd,
4399
- request_id,
4400
- at: Date.now(),
4401
- model: ctx.config?.claude_model || "",
4402
- permissionMode: ctx.config?.claude_permission_mode || "",
4403
- })
4404
- if (bindKey && sessionName && (fresh || targetId)) {
4405
- if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
4406
- rebind = { ok: true, skipped: true }
4407
- } else if (
4408
- shouldSkipRebindRespawn({
4409
- generating,
4410
- fresh,
4411
- targetId,
4412
- newestId,
4413
- paneRunningClaude,
4414
- busy,
4415
- })
4416
- ) {
4417
- // busy (ターン進行中) または最新セッションへの再 bind → respawn を抑止し生成中 claude を温存。
4418
- rebind = { ok: true, skipped: true }
4419
- if (fresh && busy) {
4420
- // ユーザーの +新規 を ready (ターン終了) まで defer する (in-flight ターンを殺さない)。
4421
- recordDeferredFresh()
4422
- logger?.info(
4423
- { session: sessionName },
4424
- "tui rebind: deferred +new (pane busy) until turn ends",
4425
- )
4426
- } else {
4427
- // 非 fresh の冪等記録 (動いているセッション本人への再 bind)。
4428
- ctx.tuiReboundSessions.set(sessionName, bindKey)
4429
- logger?.info(
4430
- { session: sessionName, session_id: targetId },
4431
- "tui rebind: skipped respawn (turn in progress on the running session)",
4432
- )
4433
- }
4434
- } else if (
4435
- fresh &&
4436
- Date.now() - (ctx.lastFreshRespawnAt.get(sessionName) || 0) <
4437
- FRESH_RESPAWN_THROTTLE_MS
4438
- ) {
4439
- // idle ペインへの fresh remount storm (再マウント毎に新 request_id) を 1 回へ collapse。
4440
- // 直近 fresh respawn から閾値内の fresh は再送とみなし skip (new セッション乱造を防ぐ)。
4441
- rebind = { ok: true, skipped: true }
4442
- logger?.info(
4443
- { session: sessionName },
4444
- "tui rebind: throttled fresh respawn (idempotent remount within window)",
4445
- )
4446
- } else {
4447
- rebind = await rebindClaudeSession(sessionName, targetId, {
4448
- cwd,
4449
- model: ctx.config?.claude_model || "",
4450
- permissionMode: ctx.config?.claude_permission_mode || "",
4451
- fresh,
4452
- busy,
4453
- logger,
4454
- })
4455
- if (rebind.ok) {
4456
- ctx.tuiReboundSessions.set(sessionName, bindKey)
4457
- if (fresh) ctx.lastFreshRespawnAt.set(sessionName, Date.now())
4458
- } else if (rebind.skipped && rebind.reason === "busy" && fresh) {
4459
- // チョークポイントが busy で弾いた (probe が false でも実行直前にターン開始した競合)。
4460
- // defer に載せて +新規 を取りこぼさない。
4461
- recordDeferredFresh()
4462
- }
4463
- }
4464
- }
4465
- reply({
4466
- session_id: targetId,
4467
- fresh,
4468
- stopped_sdk: stoppedSdk,
4469
- rebound: !!rebind.ok,
4470
- skipped: !!rebind.skipped,
4471
- error: rebind.ok ? undefined : rebind.error,
4472
- })
4473
- // 取りこぼし回復 (2026-06-07): 直近のターン境界が `stop` ならファイル正本から
4474
- // 再配信する。fs.watch 由来の session.event push は揮発性 (WS 切断・remount 中の
4475
- // broadcast は失われる) で、stop を取りこぼした frontend は三点リーダーが回り
4476
- // 続ける。bind は TUI ビューのマウントごとに来るので、ここで最新 stop を返せば
4477
- // 再表示時に必ずターン終了が伝わる。prompt_submit は再配信しない (stale な
4478
- // 「処理中」を誤って点灯させないため。点灯は jsonl エコー / 実 hook が担う)。
4479
- if (sessionName) {
4480
- const lastEvent = await readSessionEventFile(sessionName)
4481
- if (lastEvent && lastEvent.event === "stop") {
4482
- ctx.client.send({
4483
- type: "session.event",
4484
- session_name: sessionName,
4485
- event: lastEvent.event,
4486
- at: lastEvent.at,
4487
- replay: true,
4488
- // この再配信は bind(=フォーカス) 契機の status 復旧専用。並び順は絶対に
4489
- // 動かさない (フォーカスで浮上させない現象B 根治の一部)。
4490
- sort_affecting: false,
4491
- })
4492
- }
4493
- }
4494
- // 即時ステータススナップショット (#2): session.state は差分送信なので、アイドル
4495
- // セッションへ切替えても現在値が届かず、ペイン由来の三点リーダー補正が次の poll
4496
- // (~8-10s) まで遅れる。bind は TUI ビューのマウントごとに来るので、ここで現在状態
4497
- // を 1 回 push して初回サンプルを即届け、偽点灯/消灯確定を早める (noCache で実測)。
4498
- if (sessionName) {
4499
- try {
4500
- const snap = await detectSessionState(sessionName, {
4501
- noCache: true,
4502
- logger,
4503
- })
4504
- // bind が確定している復帰は、capture-pane の見た目や cwd 最新ファイルでなく
4505
- // **指定 session_id の JSONL** から context% を即時解決する。fresh はまだ実 ID
4506
- // が無いため 0% と fresh:<request_id> を対にして送る。
4507
- const contextSessionId = fresh ? bindKey : targetId
4508
- const boundContext =
4509
- targetId && cwd
4510
- ? await jsonlContextForCwd(cwd, targetId).catch(() => null)
4511
- : null
4512
- const contextPct = fresh
4513
- ? 0
4514
- : typeof boundContext?.percent === "number"
4515
- ? boundContext.percent
4516
- : snap.context_pct
4517
- // 【問題1 根治 2026-06-22】配る status はプロセス実体の busy を権威に解決する
4518
- // (resolveBindSnapshotStatus)。capture(SPINNER_LINE_RE)の偽陽性に勝たせる。
4519
- // - busy=true (ターン進行中): capture gap で snap.status が waiting に化けても
4520
- // processing を強制 = 症状C(再マウントで生成中の三点リーダーが消える偽陰性)の masking。
4521
- // - busy=false (プロセス実体 idle): snap.status が processing でも、それは静的完了行の
4522
- // 偽陽性(無生成時の誤点灯=問題1)なので processing を配らず waiting へ降格する。bind は
4523
- // noCache 単発読みで freeze 履歴が無く偽陽性を弾けないため、ここで busy を権威にする。
4524
- // generating(capture 由来)でなく busy(isArmed + 子孫数>baseline)を使うのが肝。
4525
- // proc_busy は配らない: outputActive(出力残り火)を含み真 idle でも true になり高速消灯
4526
- // (症状B)を再発させるため。
4527
- const snapStatus = resolveBindSnapshotStatus(busy, snap.status)
4528
- ctx.client.send({
4529
- type: "session.state",
4530
- session_name: sessionName,
4531
- status: snapStatus,
4532
- context_pct: contextPct,
4533
- context_session_id: contextSessionId ?? null,
4534
- permission_mode: snap.permission_mode,
4535
- stable:
4536
- !busy && snapStatus !== "processing" && snap.stable === true,
4537
- })
4538
- } catch (err) {
4539
- logger?.warn(
4540
- { err: err?.message, sessionName },
4541
- "claude.tui.bind status snapshot failed",
4542
- )
4543
- }
4544
- }
4545
- } catch (err) {
4546
- logger?.warn(
4547
- { err: err?.message, sessionName, cwd },
4548
- "claude.tui.bind failed",
4549
- )
4550
- reply({ session_id: null, error: err?.message || String(err) })
4551
- }
4552
- return
4553
- }
4554
3534
  case "terminal.mirror.start": {
4555
3535
  // Cockpit Codex 両対応 Stream C (2026-07-03): チャット(SDK/app-server)の会話を
4556
3536
  // 変えずに、ターミナルのペイン (普通のシェルのまま) へ読み取り専用の実況を
@@ -5025,18 +4005,27 @@ async function dispatch(msg, ctx) {
5025
4005
  const stream_id = msg.stream_id
5026
4006
  const cwd = msg.cwd || ""
5027
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
+ )
5028
4015
  if (!ctx.codexBridge) {
5029
4016
  ctx.client.send({
5030
4017
  type: "codex.sessions.response",
5031
4018
  stream_id,
5032
4019
  cwd,
4020
+ epoch,
4021
+ profile_id: profileId,
5033
4022
  sessions: [],
5034
4023
  error: "codex_unavailable: codex CLI が agent の PATH に見つかりません",
5035
4024
  })
5036
4025
  return
5037
4026
  }
5038
4027
  try {
5039
- const sessions = await ctx.codexBridge.listThreads({ cwd, limit })
4028
+ const sessions = await ctx.codexBridge.listThreads({ cwd, limit, sessionsDir })
5040
4029
  // Stage 5 ③: 各 thread に「hub-agent 側で app-server が live か / busy か」を付す。
5041
4030
  // rollout 由来の一覧(listThreads)は live 状態を知らないため、describeLiveSessions
5042
4031
  // の thread_id → {live, busy} を併合する。browser は sidebar に live/reaped ドットを出す。
@@ -5053,6 +4042,8 @@ async function dispatch(msg, ctx) {
5053
4042
  type: "codex.sessions.response",
5054
4043
  stream_id,
5055
4044
  cwd,
4045
+ epoch,
4046
+ profile_id: profileId,
5056
4047
  sessions: withLive,
5057
4048
  })
5058
4049
  } catch (err) {
@@ -5060,6 +4051,8 @@ async function dispatch(msg, ctx) {
5060
4051
  type: "codex.sessions.response",
5061
4052
  stream_id,
5062
4053
  cwd,
4054
+ epoch,
4055
+ profile_id: profileId,
5063
4056
  sessions: [],
5064
4057
  error: err.message,
5065
4058
  })
@@ -5074,8 +4067,11 @@ async function dispatch(msg, ctx) {
5074
4067
  const cwd = msg.cwd || ""
5075
4068
  const session_id = msg.session_id || ""
5076
4069
  const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
5077
- // active プロファイルの projects/ から読む (アカウント別に履歴がサイロ化されているため)。
5078
- 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)
5079
4075
  const result = await fetchSessionHistory({
5080
4076
  cwd,
5081
4077
  session_id,
@@ -5093,6 +4089,10 @@ async function dispatch(msg, ctx) {
5093
4089
  // その結果、無条件 hydrate → session_id 上書き → 購読スワップ → localStorage 汚染の
5094
4090
  // 経路が残っていた (sessionScope.ts が stream_id 主軸で守るのは relay イベントのみ)。
5095
4091
  cwd,
4092
+ // 案C ②: epoch と解決済み profile を必ずエコーする。frontend の単一 read ゲートは
4093
+ // この 2 つで「今の問いへの最新の答えか」を判定し、stale/逆順/別プロファイルを drop する。
4094
+ epoch,
4095
+ profile_id: profileId,
5096
4096
  events: result.events,
5097
4097
  total_lines: result.total_lines,
5098
4098
  truncated: result.truncated,
@@ -5107,8 +4107,9 @@ async function dispatch(msg, ctx) {
5107
4107
  const stream_id = msg.stream_id
5108
4108
  const cwd = msg.cwd || ""
5109
4109
  const limit = typeof msg.limit === "number" ? msg.limit : undefined
5110
- // active プロファイルの projects/ からセッション一覧を読む。
5111
- 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)
5112
4113
  const result = await listSessions({
5113
4114
  cwd,
5114
4115
  limit,
@@ -5119,6 +4120,8 @@ async function dispatch(msg, ctx) {
5119
4120
  type: "claude.sessions.response",
5120
4121
  stream_id,
5121
4122
  cwd,
4123
+ epoch,
4124
+ profile_id: profileId,
5122
4125
  sessions: result.sessions || [],
5123
4126
  error: result.error,
5124
4127
  })