@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/package.json +1 -1
- package/src/claude-history.mjs +0 -113
- package/src/codex-profiles.mjs +43 -0
- package/src/codex-stream-bridge.mjs +30 -42
- package/src/main.mjs +118 -1115
- package/src/profiles.mjs +42 -0
- package/src/tmux.mjs +6 -763
- package/src/usage.mjs +184 -3
- package/src/extract-paragraph.mjs +0 -149
- package/src/jsonl-live-watchers.mjs +0 -283
- package/src/tui-permission-bridge.mjs +0 -280
- package/src/tui-viewer-registry.mjs +0 -201
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
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
:
|
|
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
|
-
//
|
|
5078
|
-
|
|
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
|
-
//
|
|
5111
|
-
const
|
|
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
|
})
|