@cocorograph/hub-agent 0.7.105 → 0.8.1
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/claude-stream-bridge.mjs +183 -10
- package/src/codex-profiles.mjs +43 -0
- package/src/codex-stream-bridge.mjs +30 -42
- package/src/main.mjs +216 -1120
- 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 ||
|
|
@@ -365,7 +327,11 @@ export function isReadOnlyRpc(type) {
|
|
|
365
327
|
return (
|
|
366
328
|
type === "claude.history.request" ||
|
|
367
329
|
type === "claude.sessions.request" ||
|
|
368
|
-
type === "codex.sessions.request"
|
|
330
|
+
type === "codex.sessions.request" ||
|
|
331
|
+
// T06603 (2026-07-25): キュー snapshot の pull。in-memory 配列を読むだけで副作用が無く、
|
|
332
|
+
// かつ「直列キュー前方の重い処理で再同期が遅延する」と送信待ちチップの固着が長引く
|
|
333
|
+
// (再同期は固着を直すための経路なので、詰まりやすい経路に置いてはならない)。
|
|
334
|
+
type === "claude.queue.request"
|
|
369
335
|
)
|
|
370
336
|
}
|
|
371
337
|
|
|
@@ -1190,50 +1156,17 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1190
1156
|
// ready-for-input 判定: claude プロセスの子プロセスを内省し「Stop フック/ツール実行が
|
|
1191
1157
|
// 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
|
|
1192
1158
|
// Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
//
|
|
1159
|
+
// state loop に渡す boundSessions (tmux session_name → session_id) の器。
|
|
1160
|
+
// ⚠️ 純 SDK チャット化 (2026-07) で唯一の writer だった claude.tui.bind が消えたため、
|
|
1161
|
+
// この Map は **常に空**。それでも state loop 起動より前に「空 Map として」用意する:
|
|
1162
|
+
// undefined を渡すと state loop がバックグラウンド時に cwd 最新 JSONL へフォールバックして
|
|
1163
|
+
// 別会話の context% を出す。空 Map なら boundSessionId が常に null を返し、
|
|
1164
|
+
// resolveStateLoopBoundSessionId の「SDK チャットは bind 台帳を持たないので claudeBridge の
|
|
1165
|
+
// ライブセッションへフォールバック」経路 (下記) が正しく効く。
|
|
1196
1166
|
ctx.tuiReboundSessions = new Map()
|
|
1197
1167
|
|
|
1198
1168
|
const readinessTracker = new ReadinessTracker()
|
|
1199
|
-
// claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
|
|
1200
|
-
// respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
|
|
1201
1169
|
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
1170
|
const stateLoop = startStateLoop({
|
|
1238
1171
|
client,
|
|
1239
1172
|
plugins,
|
|
@@ -1269,79 +1202,8 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1269
1202
|
logger,
|
|
1270
1203
|
tracker: readinessTracker,
|
|
1271
1204
|
intervalMs: 700,
|
|
1272
|
-
onReady: flushDeferredFreshRespawn,
|
|
1273
1205
|
})
|
|
1274
1206
|
|
|
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
1207
|
|
|
1346
1208
|
// sibling-inject (ask-sibling スキル, 2026-07-04): 同一 cwd の claude⇄codex
|
|
1347
1209
|
// 相互依頼のためのローカル IPC 受信口。cwd スコープ capability トークン
|
|
@@ -1351,44 +1213,6 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1351
1213
|
await siblingInjectBridge.start()
|
|
1352
1214
|
ctx.siblingInjectBridge = siblingInjectBridge
|
|
1353
1215
|
|
|
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
1216
|
// モデル能力レジストリ (2026-07-11): SDK supportedModels() を起動時に引き、effort
|
|
1393
1217
|
// 対応可否の判定を正規表現ハードコードから権威データへ置き換える。能力はセッション中
|
|
1394
1218
|
// 不変なので attach ごとには引き直さない。ただし control-only query は cold start 直後に
|
|
@@ -1409,21 +1233,12 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
1409
1233
|
)
|
|
1410
1234
|
}
|
|
1411
1235
|
|
|
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
1236
|
const shutdown = async (signal) => {
|
|
1419
1237
|
logger.info({ signal }, "shutting down")
|
|
1420
1238
|
await runHookBroadcast(plugins, "onAgentStop", ctx)
|
|
1421
1239
|
stateLoop.stop()
|
|
1422
1240
|
sessionEventLoop?.stop?.()
|
|
1423
1241
|
readinessLoop?.stop?.()
|
|
1424
|
-
tuiPermissionBridge.stop()
|
|
1425
|
-
tuiViewerRegistry.stop()
|
|
1426
|
-
jsonlLiveWatchers.stop()
|
|
1427
1242
|
siblingInjectBridge.stop()
|
|
1428
1243
|
ptyBridge.shutdown()
|
|
1429
1244
|
claudeBridge?.shutdown?.()
|
|
@@ -2027,39 +1842,6 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
|
|
|
2027
1842
|
}
|
|
2028
1843
|
}
|
|
2029
1844
|
|
|
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
1845
|
|
|
2064
1846
|
/**
|
|
2065
1847
|
* arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
|
|
@@ -3081,6 +2863,13 @@ const seenCodexChatInputIds = makeSeenInputIdRegistry()
|
|
|
3081
2863
|
// Promise へ合流させる (settle 時に元の .then/.catch が 1 回だけ ack/nack を出す)。
|
|
3082
2864
|
const codexInflightByInputId = new Map()
|
|
3083
2865
|
|
|
2866
|
+
// T06603 (2026-07-25): claude.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
|
|
2867
|
+
// 上の codex 版と同じ理由・同じ流儀。claude 側は sendMessage が resident query への
|
|
2868
|
+
// reconcile (applyFlagSettings の control request 往復) を await するため settle まで数秒
|
|
2869
|
+
// かかることがあり、その窓で ackWatchdog(5s) の再送が入ると `seen` 未登録のまま二重投入され、
|
|
2870
|
+
// 同一 input_id がキューに 2 件積まれて drain 後も 1 件残る (送信待ちチップが消えない)。
|
|
2871
|
+
const claudeInflightByInputId = new Map()
|
|
2872
|
+
|
|
3084
2873
|
/**
|
|
3085
2874
|
* input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
|
|
3086
2875
|
* stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
|
|
@@ -3155,7 +2944,20 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
3155
2944
|
ctx.client.send({ type: "claude.input.ack", stream_id, input_id })
|
|
3156
2945
|
return undefined
|
|
3157
2946
|
}
|
|
3158
|
-
|
|
2947
|
+
if (input_id && claudeInflightByInputId.has(input_id)) {
|
|
2948
|
+
// T06603 (2026-07-25): **処理中** の同一 input_id (browser の ackWatchdog が 5s で
|
|
2949
|
+
// 再送してくるが、こちらはまだ settle していない窓)。codex 側は round4 major#1 で
|
|
2950
|
+
// 対処済みだったが claude 側だけ欠けており、`seen` は settle 後にしか登録されないため
|
|
2951
|
+
// 再送がそのまま二重投入されていた (同じ本文が 2 回実行され、かつ同一 id の項目が
|
|
2952
|
+
// キューに 2 つ積まれて drain 後も 1 つ残る = 「実行されたのにキューから消えない」)。
|
|
2953
|
+
// 既存 Promise へ合流させ、ack/nack は元の 1 回だけ出す。
|
|
2954
|
+
ctx.logger?.info?.(
|
|
2955
|
+
{ stream_id, input_id },
|
|
2956
|
+
"claude.input in-flight dedup (join, no re-dispatch)",
|
|
2957
|
+
)
|
|
2958
|
+
return claudeInflightByInputId.get(input_id)
|
|
2959
|
+
}
|
|
2960
|
+
const p = ctx.claudeBridge
|
|
3159
2961
|
.input({
|
|
3160
2962
|
stream_id,
|
|
3161
2963
|
message: msg.message,
|
|
@@ -3194,23 +2996,55 @@ export function handleClaudeChatInput(msg, ctx) {
|
|
|
3194
2996
|
})
|
|
3195
2997
|
}
|
|
3196
2998
|
})
|
|
2999
|
+
.finally(() => {
|
|
3000
|
+
// in-flight レジストリから外す (成功時は seen が以降の再送を dedup、
|
|
3001
|
+
// 失敗時は再送で正当にリトライさせる)。codex 版と同じ流儀。
|
|
3002
|
+
if (input_id) claudeInflightByInputId.delete(input_id)
|
|
3003
|
+
})
|
|
3004
|
+
if (input_id) claudeInflightByInputId.set(input_id, p)
|
|
3005
|
+
return p
|
|
3197
3006
|
}
|
|
3198
3007
|
|
|
3199
3008
|
/**
|
|
3200
3009
|
* claude.attach の ready 直後にpushする履歴を組み立てる。
|
|
3201
3010
|
* Browserからの claude.history.request 往復を省くため、resume時だけ先回りして読む。
|
|
3202
3011
|
* 失敗しても空のhistory.responseへ縮退し、attach自体を失敗させない。
|
|
3012
|
+
*
|
|
3013
|
+
* ⚠️ これは **agent 発の push** であり browser 発の read ではないため epoch を持たない
|
|
3014
|
+
* (Read-plane 案C)。frontend の read ゲートは epoch 無しの応答を「版なしの先回り
|
|
3015
|
+
* hydration」として扱い、現 view (profile/cwd/session) が一致するときだけ適用する。
|
|
3016
|
+
*
|
|
3017
|
+
* ⚠️ **read root と label は 1 回の解決結果から取る** (cross-review major1/nit2)。
|
|
3018
|
+
* projects root (履歴を読む dir) と応答の profile_id ラベルを別々に解決すると、
|
|
3019
|
+
* その間にプロファイル切替が挟まったとき「A と名乗る応答に B の履歴」が入りうる。
|
|
3020
|
+
* これを API で表現するため、呼び出し側は `resolveProjectsRoot(profile_id)` を **1 回だけ**
|
|
3021
|
+
* 解決し、その戻り値 `{projectsRoot, profileId}` を **単一の `resolved` として渡す**。
|
|
3022
|
+
* root と label を別引数で受けないことで「別解決の混在」を構造的に不可能にする。
|
|
3023
|
+
*
|
|
3024
|
+
* @param {{projectsRoot:string, profileId?:string}} [resolved] 解決済みの root と profile を組で。
|
|
3025
|
+
* projectsRoot=履歴を読む dir、profileId=応答ラベル (省略時はラベルを載せない=後方互換)。
|
|
3026
|
+
* 未指定なら active を 1 回解決してその root を使う (ラベルは載せない)。
|
|
3027
|
+
* @param {(args:object)=>Promise<object>} [fetchHistoryFn] テスト用の履歴読取注入。
|
|
3203
3028
|
*/
|
|
3204
3029
|
export async function loadClaudeAttachHistory({
|
|
3205
3030
|
cwd,
|
|
3206
3031
|
sessionId,
|
|
3207
3032
|
logger,
|
|
3208
|
-
|
|
3033
|
+
resolved,
|
|
3209
3034
|
fetchHistoryFn = fetchSessionHistory,
|
|
3210
3035
|
}) {
|
|
3211
3036
|
if (!cwd || !sessionId) return null
|
|
3037
|
+
const profileId = resolved?.profileId
|
|
3038
|
+
const withProfile = (payload) =>
|
|
3039
|
+
profileId ? { ...payload, profile_id: profileId } : payload
|
|
3212
3040
|
try {
|
|
3213
|
-
|
|
3041
|
+
// root と label は必ず同一の `resolved` 由来。resolved 省略時のみ active を読む
|
|
3042
|
+
// (この場合 profileId は無く、従来どおりラベル無し応答になる)。
|
|
3043
|
+
// ⚠️ active root 解決も try 内で行う (公開前 major): fallback 解決が失敗しても
|
|
3044
|
+
// 空 history.response へ縮退させ、attach を落とさない契約を守る。
|
|
3045
|
+
const projectsRoot = resolved
|
|
3046
|
+
? resolved.projectsRoot
|
|
3047
|
+
: await getActiveProjectsRoot()
|
|
3214
3048
|
const result = await fetchHistoryFn({
|
|
3215
3049
|
cwd,
|
|
3216
3050
|
session_id: sessionId,
|
|
@@ -3218,7 +3052,7 @@ export async function loadClaudeAttachHistory({
|
|
|
3218
3052
|
projectsRoot,
|
|
3219
3053
|
logger,
|
|
3220
3054
|
})
|
|
3221
|
-
return {
|
|
3055
|
+
return withProfile({
|
|
3222
3056
|
type: "claude.history.response",
|
|
3223
3057
|
session_id: sessionId,
|
|
3224
3058
|
cwd,
|
|
@@ -3226,9 +3060,9 @@ export async function loadClaudeAttachHistory({
|
|
|
3226
3060
|
total_lines: result.total_lines || 0,
|
|
3227
3061
|
truncated: result.truncated === true,
|
|
3228
3062
|
error: result.error,
|
|
3229
|
-
}
|
|
3063
|
+
})
|
|
3230
3064
|
} catch (err) {
|
|
3231
|
-
return {
|
|
3065
|
+
return withProfile({
|
|
3232
3066
|
type: "claude.history.response",
|
|
3233
3067
|
session_id: sessionId,
|
|
3234
3068
|
cwd,
|
|
@@ -3236,10 +3070,52 @@ export async function loadClaudeAttachHistory({
|
|
|
3236
3070
|
total_lines: 0,
|
|
3237
3071
|
truncated: false,
|
|
3238
3072
|
error: err?.message || String(err),
|
|
3239
|
-
}
|
|
3073
|
+
})
|
|
3240
3074
|
}
|
|
3241
3075
|
}
|
|
3242
3076
|
|
|
3077
|
+
/**
|
|
3078
|
+
* claude.attach の先回り履歴 push 用 promise を組み立てる。
|
|
3079
|
+
*
|
|
3080
|
+
* ⚠️ **この promise は決して reject しない** (公開前 major の契約)。`resolveProjectsRoot` を
|
|
3081
|
+
* attach ハンドラの直列 await にも try にも載せると、`~/.hub/profiles.json` 破損・権限エラー等の
|
|
3082
|
+
* 解決失敗で attach/ready ごと落ち (claude.error)、かつ registry 解決が ready 前の同期待ちになる。
|
|
3083
|
+
* ここで非同期チェーンとして開始し、解決失敗は label 無しの空 `claude.history.response`(error 付き)
|
|
3084
|
+
* へ縮退させる。root と label は 1 回の解決 (resolved) から取り (major1/nit2 の単一 resolved 不変
|
|
3085
|
+
* 条件)、attach/ready を profile/read I/O から独立させる。
|
|
3086
|
+
*
|
|
3087
|
+
* @param {object} p
|
|
3088
|
+
* @param {string|null} p.resumeSessionId resume 対象。null/空なら履歴を読まず null を返す。
|
|
3089
|
+
* @param {string} p.cwd 履歴を読む cwd。
|
|
3090
|
+
* @param {string} [p.profileId] read の profile ヒント (msg.profile_id)。resolveFn へ渡す。
|
|
3091
|
+
* @param {(profileId?:string)=>Promise<{projectsRoot:string,profileId?:string}>} [p.resolveFn]
|
|
3092
|
+
* @param {(args:object)=>Promise<object|null>} [p.loadFn]
|
|
3093
|
+
* @returns {Promise<object|null>} history.response 相当 or null (fresh)。reject しない。
|
|
3094
|
+
*/
|
|
3095
|
+
export function buildAttachHistoryPromise({
|
|
3096
|
+
resumeSessionId,
|
|
3097
|
+
cwd,
|
|
3098
|
+
logger,
|
|
3099
|
+
profileId,
|
|
3100
|
+
resolveFn = resolveProjectsRoot,
|
|
3101
|
+
loadFn = loadClaudeAttachHistory,
|
|
3102
|
+
}) {
|
|
3103
|
+
if (!resumeSessionId) return Promise.resolve(null)
|
|
3104
|
+
return resolveFn(profileId)
|
|
3105
|
+
.then((resolved) =>
|
|
3106
|
+
loadFn({ cwd, sessionId: resumeSessionId, logger, resolved }),
|
|
3107
|
+
)
|
|
3108
|
+
.catch((err) => ({
|
|
3109
|
+
type: "claude.history.response",
|
|
3110
|
+
session_id: resumeSessionId,
|
|
3111
|
+
cwd,
|
|
3112
|
+
events: [],
|
|
3113
|
+
total_lines: 0,
|
|
3114
|
+
truncated: false,
|
|
3115
|
+
error: err?.message || String(err),
|
|
3116
|
+
}))
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3243
3119
|
/**
|
|
3244
3120
|
* SDK チャット配達保証 (S2, 2026-07-05): `codex.input` を処理し、browser が付与した
|
|
3245
3121
|
* `input_id` があれば `codex.input.ack` / `codex.input.nack` を返す (claude.input と
|
|
@@ -3315,97 +3191,6 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
3315
3191
|
return p
|
|
3316
3192
|
}
|
|
3317
3193
|
|
|
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
3194
|
/**
|
|
3410
3195
|
* tmux.kill_session の処理 (症状3 根治)。要求された session を kill し、要求元へ
|
|
3411
3196
|
* tmux.kill_session.result を返したうえで、kill できた各 session について
|
|
@@ -3452,76 +3237,6 @@ export async function handleKillSession(msg, ctx) {
|
|
|
3452
3237
|
}
|
|
3453
3238
|
}
|
|
3454
3239
|
|
|
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
3240
|
async function dispatch(msg, ctx) {
|
|
3526
3241
|
const t = msg?.type || ""
|
|
3527
3242
|
try {
|
|
@@ -3664,13 +3379,15 @@ async function dispatch(msg, ctx) {
|
|
|
3664
3379
|
// attachは同期処理なので、履歴I/Oを先に開始してreadyまでの待ちを重ねる。
|
|
3665
3380
|
// 新Browserはhistory_pushedを見て別requestを省略し、旧Browserは従来どおり
|
|
3666
3381
|
// requestしてもhydrateのuuid dedupで安全に収束する。
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
:
|
|
3382
|
+
// 案C ①/major1/nit2/公開前 major: 履歴 push を attach/ready から独立させる。
|
|
3383
|
+
// resolveProjectsRoot を attach の直列 await/try に載せない (解決失敗で attach ごと
|
|
3384
|
+
// 落とさない・ready を遅らせない)。詳細は buildAttachHistoryPromise の doc 参照。
|
|
3385
|
+
const historyPromise = buildAttachHistoryPromise({
|
|
3386
|
+
resumeSessionId,
|
|
3387
|
+
cwd: cwdArg || process.env.HOME || process.cwd(),
|
|
3388
|
+
logger: ctx.logger,
|
|
3389
|
+
profileId: msg.profile_id,
|
|
3390
|
+
})
|
|
3674
3391
|
const info = ctx.claudeBridge.attach({
|
|
3675
3392
|
stream_id,
|
|
3676
3393
|
cwd: cwdArg,
|
|
@@ -3700,6 +3417,12 @@ async function dispatch(msg, ctx) {
|
|
|
3700
3417
|
// 旧 agent にはこのフラグが無く、browser は楽観アイテムの×を出さない (ゴースト
|
|
3701
3418
|
// 実行防止のフォールバック)。
|
|
3702
3419
|
supports_queue_input_id: true,
|
|
3420
|
+
// pull 型再同期 capability (T06603, 2026-07-25): この agent は
|
|
3421
|
+
// claude.queue.request に claude.queue.response で答え、queue_state に
|
|
3422
|
+
// 版番号 (seq) と受理済み input_id (accepted) を載せることを通知する。
|
|
3423
|
+
// 旧 agent はこのフラグが無く、browser は従来の「配信到達に依存する」
|
|
3424
|
+
// 挙動へフォールバックする (混在期間も安全に段階デプロイできる)。
|
|
3425
|
+
supports_queue_snapshot: true,
|
|
3703
3426
|
})
|
|
3704
3427
|
// dispatch全体はWSメッセージ順を守る直列キュー上にある。ここで履歴I/Oをawaitすると
|
|
3705
3428
|
// 同一接続のpermission/input等まで停止するため、readyの同期送信後は非同期でpushする。
|
|
@@ -3831,43 +3554,6 @@ async function dispatch(msg, ctx) {
|
|
|
3831
3554
|
return
|
|
3832
3555
|
}
|
|
3833
3556
|
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
3557
|
if (!ctx.claudeBridge) return
|
|
3872
3558
|
ctx.claudeBridge.permissionReply({
|
|
3873
3559
|
stream_id: msg.stream_id,
|
|
@@ -3882,675 +3568,6 @@ async function dispatch(msg, ctx) {
|
|
|
3882
3568
|
if (!ctx.claudeBridge) return
|
|
3883
3569
|
ctx.claudeBridge.interrupt({ stream_id: msg.stream_id })
|
|
3884
3570
|
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
3571
|
case "terminal.mirror.start": {
|
|
4555
3572
|
// Cockpit Codex 両対応 Stream C (2026-07-03): チャット(SDK/app-server)の会話を
|
|
4556
3573
|
// 変えずに、ターミナルのペイン (普通のシェルのまま) へ読み取り専用の実況を
|
|
@@ -4790,11 +3807,67 @@ async function dispatch(msg, ctx) {
|
|
|
4790
3807
|
}
|
|
4791
3808
|
return
|
|
4792
3809
|
}
|
|
4793
|
-
case "claude.queue.cancel":
|
|
3810
|
+
case "claude.queue.cancel": {
|
|
4794
3811
|
// 送信待ち (pending) メッセージを id 指定で取り消す (0.6.26)。
|
|
4795
|
-
|
|
4796
|
-
|
|
3812
|
+
// T06603 cross-review 4 巡目: 取り消せなかった (stream 不在) ときは browser へ
|
|
3813
|
+
// 失敗を返す。browser は要求時にチップを消さず「取消中」印を付けて権威の確認を
|
|
3814
|
+
// 待つため、無応答だと印が外れず固着する。request_id をエコーして相関させる。
|
|
3815
|
+
// T06603 cross-review 5 巡目: 結末 (cancelled / already_started / stream_missing) を
|
|
3816
|
+
// **必ず** 返す。「pending から消えた」だけでは取消成功と実行開始済みを区別できず、
|
|
3817
|
+
// 特に drain の started フレームを取りこぼしていると、browser は取消成功に見せかけた
|
|
3818
|
+
// まま実行を続行してしまう (発言バブルも欠けたまま)。request_id + input_id で相関させる。
|
|
3819
|
+
const cancelRes = {
|
|
3820
|
+
type: "claude.queue.response",
|
|
3821
|
+
stream_id: msg.stream_id,
|
|
3822
|
+
request_id: msg.request_id || null,
|
|
3823
|
+
input_id: msg.id || null,
|
|
3824
|
+
}
|
|
3825
|
+
if (!ctx.claudeBridge) {
|
|
3826
|
+
ctx.client.send({ ...cancelRes, outcome: "stream_missing" })
|
|
3827
|
+
return
|
|
3828
|
+
}
|
|
3829
|
+
const canceled = ctx.claudeBridge.cancelQueued({
|
|
3830
|
+
stream_id: msg.stream_id,
|
|
3831
|
+
id: msg.id,
|
|
3832
|
+
})
|
|
3833
|
+
// ⚠️ T06603 cross-review 6 巡目: **outcome を先に送る**。キュー snapshot の再表明を
|
|
3834
|
+
// 先に流すと、backend は到着順に relay するため browser は空 snapshot で
|
|
3835
|
+
// 「キューに無い + 受理済み = 実行完了」と判定してチップを消し、後続の outcome は
|
|
3836
|
+
// 対象不在で no-op になる (already_started のバブル補償が発火しない)。
|
|
3837
|
+
ctx.client.send({
|
|
3838
|
+
...cancelRes,
|
|
3839
|
+
outcome: canceled?.outcome || "stream_missing",
|
|
3840
|
+
})
|
|
3841
|
+
canceled?.flush?.()
|
|
3842
|
+
return
|
|
3843
|
+
}
|
|
3844
|
+
case "claude.queue.request": {
|
|
3845
|
+
// pull 型再同期 (T06603, 2026-07-25): browser が権威キュー snapshot を引きにくる。
|
|
3846
|
+
// 従来キュー表示の是正手段は「agent が turn settle 毎に force 再送する queue_state」
|
|
3847
|
+
// だけで、その配信が 1 通落ちると browser には自己修復の手段が無く送信待ちチップが
|
|
3848
|
+
// 恒久固着した。ここで「いつでも引き直せる」経路を与えるのが根治の中核。
|
|
3849
|
+
// 応答は要求元 stream へ返す (backend が cockpit_stream_<stream_id> へ relay)。
|
|
3850
|
+
const queueReq = {
|
|
3851
|
+
type: "claude.queue.response",
|
|
3852
|
+
stream_id: msg.stream_id,
|
|
3853
|
+
request_id: msg.request_id || null,
|
|
3854
|
+
}
|
|
3855
|
+
if (!ctx.claudeBridge) {
|
|
3856
|
+
ctx.client.send({ ...queueReq, error: "stream_missing" })
|
|
3857
|
+
return
|
|
3858
|
+
}
|
|
3859
|
+
const got = ctx.claudeBridge.queueSnapshot({ stream_id: msg.stream_id })
|
|
3860
|
+
if (!got) {
|
|
3861
|
+
ctx.client.send({ ...queueReq, error: "stream_missing" })
|
|
3862
|
+
return
|
|
3863
|
+
}
|
|
3864
|
+
ctx.client.send({
|
|
3865
|
+
...queueReq,
|
|
3866
|
+
session_id: got.session_id,
|
|
3867
|
+
event: got.snapshot,
|
|
3868
|
+
})
|
|
4797
3869
|
return
|
|
3870
|
+
}
|
|
4798
3871
|
case "claude.detach":
|
|
4799
3872
|
if (!ctx.claudeBridge) return
|
|
4800
3873
|
ctx.claudeBridge.detach({ stream_id: msg.stream_id })
|
|
@@ -5025,18 +4098,27 @@ async function dispatch(msg, ctx) {
|
|
|
5025
4098
|
const stream_id = msg.stream_id
|
|
5026
4099
|
const cwd = msg.cwd || ""
|
|
5027
4100
|
const limit = typeof msg.limit === "number" ? msg.limit : undefined
|
|
4101
|
+
// Read-plane 案C ①②: read は自己記述。読む対象はリクエストの profile_id で固定し
|
|
4102
|
+
// (env の揮発値に依存しない)、epoch と解決済み文脈をエコーして frontend の
|
|
4103
|
+
// 単一 read ゲートが stale/逆順/別プロファイル応答を判別できるようにする。
|
|
4104
|
+
const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
|
|
4105
|
+
const { sessionsDir, profileId } = await codexProfiles.resolveSessionsDir(
|
|
4106
|
+
msg.profile_id,
|
|
4107
|
+
)
|
|
5028
4108
|
if (!ctx.codexBridge) {
|
|
5029
4109
|
ctx.client.send({
|
|
5030
4110
|
type: "codex.sessions.response",
|
|
5031
4111
|
stream_id,
|
|
5032
4112
|
cwd,
|
|
4113
|
+
epoch,
|
|
4114
|
+
profile_id: profileId,
|
|
5033
4115
|
sessions: [],
|
|
5034
4116
|
error: "codex_unavailable: codex CLI が agent の PATH に見つかりません",
|
|
5035
4117
|
})
|
|
5036
4118
|
return
|
|
5037
4119
|
}
|
|
5038
4120
|
try {
|
|
5039
|
-
const sessions = await ctx.codexBridge.listThreads({ cwd, limit })
|
|
4121
|
+
const sessions = await ctx.codexBridge.listThreads({ cwd, limit, sessionsDir })
|
|
5040
4122
|
// Stage 5 ③: 各 thread に「hub-agent 側で app-server が live か / busy か」を付す。
|
|
5041
4123
|
// rollout 由来の一覧(listThreads)は live 状態を知らないため、describeLiveSessions
|
|
5042
4124
|
// の thread_id → {live, busy} を併合する。browser は sidebar に live/reaped ドットを出す。
|
|
@@ -5053,6 +4135,8 @@ async function dispatch(msg, ctx) {
|
|
|
5053
4135
|
type: "codex.sessions.response",
|
|
5054
4136
|
stream_id,
|
|
5055
4137
|
cwd,
|
|
4138
|
+
epoch,
|
|
4139
|
+
profile_id: profileId,
|
|
5056
4140
|
sessions: withLive,
|
|
5057
4141
|
})
|
|
5058
4142
|
} catch (err) {
|
|
@@ -5060,6 +4144,8 @@ async function dispatch(msg, ctx) {
|
|
|
5060
4144
|
type: "codex.sessions.response",
|
|
5061
4145
|
stream_id,
|
|
5062
4146
|
cwd,
|
|
4147
|
+
epoch,
|
|
4148
|
+
profile_id: profileId,
|
|
5063
4149
|
sessions: [],
|
|
5064
4150
|
error: err.message,
|
|
5065
4151
|
})
|
|
@@ -5074,8 +4160,11 @@ async function dispatch(msg, ctx) {
|
|
|
5074
4160
|
const cwd = msg.cwd || ""
|
|
5075
4161
|
const session_id = msg.session_id || ""
|
|
5076
4162
|
const maxLines = typeof msg.max_lines === "number" ? msg.max_lines : undefined
|
|
5077
|
-
//
|
|
5078
|
-
|
|
4163
|
+
// Read-plane 案C ①: 読む projects/ は **リクエストの profile_id** から解決する
|
|
4164
|
+
// (アカウント別に履歴がサイロ化されており、env 由来の active に頼ると切替中の
|
|
4165
|
+
// in-flight read が別アカウントの履歴を返す)。profile_id 未指定は active へ縮退。
|
|
4166
|
+
const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
|
|
4167
|
+
const { projectsRoot, profileId } = await resolveProjectsRoot(msg.profile_id)
|
|
5079
4168
|
const result = await fetchSessionHistory({
|
|
5080
4169
|
cwd,
|
|
5081
4170
|
session_id,
|
|
@@ -5093,6 +4182,10 @@ async function dispatch(msg, ctx) {
|
|
|
5093
4182
|
// その結果、無条件 hydrate → session_id 上書き → 購読スワップ → localStorage 汚染の
|
|
5094
4183
|
// 経路が残っていた (sessionScope.ts が stream_id 主軸で守るのは relay イベントのみ)。
|
|
5095
4184
|
cwd,
|
|
4185
|
+
// 案C ②: epoch と解決済み profile を必ずエコーする。frontend の単一 read ゲートは
|
|
4186
|
+
// この 2 つで「今の問いへの最新の答えか」を判定し、stale/逆順/別プロファイルを drop する。
|
|
4187
|
+
epoch,
|
|
4188
|
+
profile_id: profileId,
|
|
5096
4189
|
events: result.events,
|
|
5097
4190
|
total_lines: result.total_lines,
|
|
5098
4191
|
truncated: result.truncated,
|
|
@@ -5107,8 +4200,9 @@ async function dispatch(msg, ctx) {
|
|
|
5107
4200
|
const stream_id = msg.stream_id
|
|
5108
4201
|
const cwd = msg.cwd || ""
|
|
5109
4202
|
const limit = typeof msg.limit === "number" ? msg.limit : undefined
|
|
5110
|
-
//
|
|
5111
|
-
const
|
|
4203
|
+
// Read-plane 案C ①: 一覧もリクエストの profile_id から projects/ を解決する。
|
|
4204
|
+
const epoch = typeof msg.epoch === "number" ? msg.epoch : undefined
|
|
4205
|
+
const { projectsRoot, profileId } = await resolveProjectsRoot(msg.profile_id)
|
|
5112
4206
|
const result = await listSessions({
|
|
5113
4207
|
cwd,
|
|
5114
4208
|
limit,
|
|
@@ -5119,6 +4213,8 @@ async function dispatch(msg, ctx) {
|
|
|
5119
4213
|
type: "claude.sessions.response",
|
|
5120
4214
|
stream_id,
|
|
5121
4215
|
cwd,
|
|
4216
|
+
epoch,
|
|
4217
|
+
profile_id: profileId,
|
|
5122
4218
|
sessions: result.sessions || [],
|
|
5123
4219
|
error: result.error,
|
|
5124
4220
|
})
|