@cocorograph/hub-agent 0.7.17 → 0.7.19
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/main.mjs +95 -8
- package/src/tmux.mjs +49 -2
- package/src/usage.mjs +222 -42
package/package.json
CHANGED
package/src/main.mjs
CHANGED
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
listWorktreeNameHistory,
|
|
72
72
|
listWorktreeStubs,
|
|
73
73
|
isPaneRunningClaude,
|
|
74
|
+
paneClaudeAliveOrUnknown,
|
|
74
75
|
rebindClaudeSession,
|
|
75
76
|
shouldSkipRebindRespawn,
|
|
76
77
|
recoverTuiInput,
|
|
@@ -87,6 +88,7 @@ import { TuiPermissionBridge } from "./tui-permission-bridge.mjs"
|
|
|
87
88
|
import { TuiViewerRegistry } from "./tui-viewer-registry.mjs"
|
|
88
89
|
import { JsonlLiveWatchers } from "./jsonl-live-watchers.mjs"
|
|
89
90
|
import {
|
|
91
|
+
boundSessionId,
|
|
90
92
|
contextWindowSize,
|
|
91
93
|
getSessionUsages,
|
|
92
94
|
getUsage,
|
|
@@ -662,7 +664,15 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
|
|
|
662
664
|
intervalMs: 5_000,
|
|
663
665
|
claudeBridge,
|
|
664
666
|
readinessTracker,
|
|
667
|
+
// tmux session_name → bind 中の session_id。turn_active を「そのペインが実際に走らせている
|
|
668
|
+
// セッションの jsonl」で判定するために渡す (cwd dir 内の別 jsonl 取り違え=メタ欠陥#1 の根治)。
|
|
669
|
+
boundSessions: ctx.tuiReboundSessions,
|
|
665
670
|
})
|
|
671
|
+
// T1: tmux.list_sessions poll fallback で turn_active/proc_busy/child_busy/stalled を
|
|
672
|
+
// 同梱できるよう、state loop の最新値スナップショット accessor を ctx に晒す。
|
|
673
|
+
// push 専用の 4 信号を poll でも回復可能にする (生産点は state loop で単一のまま、
|
|
674
|
+
// 取り出し口を 2 つに増やすだけ = §5 の用途別出し分けを侵さない)。
|
|
675
|
+
ctx.getSessionStateSnapshot = stateLoop.getSnapshot
|
|
666
676
|
// bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
|
|
667
677
|
// に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
|
|
668
678
|
// text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
|
|
@@ -1188,7 +1198,47 @@ const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
|
|
|
1188
1198
|
// TURN_STALL_WARN_MS と揃える。
|
|
1189
1199
|
const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
|
|
1190
1200
|
|
|
1191
|
-
|
|
1201
|
+
/**
|
|
1202
|
+
* tmux.list_sessions 応答に同梱する session オブジェクトを組み立てる純関数。
|
|
1203
|
+
*
|
|
1204
|
+
* T1: push 専用だった 4 信号(turn_active/proc_busy/child_busy/stalled)を poll 応答にも
|
|
1205
|
+
* 同梱して、WS 再接続直後/push 取りこぼし時のドット系古値固着を解消する。snapshot 未取得
|
|
1206
|
+
* (新規セッション cold window <5s) なら 4 信号を付与せず、frontend は legacy フォールバック
|
|
1207
|
+
* (現状維持で退行なし)。設計正本 §5 の不変条件は侵さない(生産点は startStateLoop で単一の
|
|
1208
|
+
* まま、取り出し口を push と poll の 2 つに増やすだけ)。
|
|
1209
|
+
*
|
|
1210
|
+
* 純関数として export することでテスト容易性を上げる(getSessionStateSnapshot を mock で
|
|
1211
|
+
* 注入し、snapshot 有/無/null/欠落フィールドの各ケースで挙動を検証可能)。
|
|
1212
|
+
*
|
|
1213
|
+
* @param {Array} sessions tmux.list_sessions が返した素の session 行
|
|
1214
|
+
* @param {Map<string, object>} lastEventByName session_name → 最新 hook event
|
|
1215
|
+
* @param {((name: string) => object | null) | undefined} getSnap state loop の getSnapshot
|
|
1216
|
+
* accessor。undefined のときは 4 信号を一切付与しない(snapshot 経路 OFF と等価)
|
|
1217
|
+
*/
|
|
1218
|
+
export function enrichTmuxSessionsForListResponse(
|
|
1219
|
+
sessions,
|
|
1220
|
+
lastEventByName,
|
|
1221
|
+
getSnap,
|
|
1222
|
+
) {
|
|
1223
|
+
return sessions.map((s) => {
|
|
1224
|
+
const base = {
|
|
1225
|
+
...s,
|
|
1226
|
+
last_event: lastEventByName.get(s.name) || null,
|
|
1227
|
+
}
|
|
1228
|
+
if (!getSnap) return base
|
|
1229
|
+
const snap = getSnap(s.name)
|
|
1230
|
+
if (!snap) return base
|
|
1231
|
+
// snapshot は startStateLoop.getSnapshot が shallow copy で返しているため、ここでの
|
|
1232
|
+
// 上書きが lastByName を汚染することはない(症状A/B 再発防止)。
|
|
1233
|
+
base.turn_active = snap.turn_active
|
|
1234
|
+
base.proc_busy = snap.proc_busy
|
|
1235
|
+
base.child_busy = snap.child_busy
|
|
1236
|
+
base.stalled = snap.stalled
|
|
1237
|
+
return base
|
|
1238
|
+
})
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
export function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker, boundSessions }) {
|
|
1192
1242
|
const lastByName = new Map() // session_name → {status, context_pct}
|
|
1193
1243
|
const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
|
|
1194
1244
|
// (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
|
|
@@ -1304,14 +1354,25 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1304
1354
|
// CockpitStatusDot の青/赤の権威ソース。サイドバーのドットと送信/停止ボタンを同じ真実で
|
|
1305
1355
|
// 同期させ、Esc 中断後の青固着 (PROMPT_STALE_SHORT_MS=60s) を解消する。
|
|
1306
1356
|
// - チャットモード: SDK の rate_limit_event/assistant が最も新鮮なので chat.status を採用
|
|
1307
|
-
// - TUI モード: jsonl 末尾 assistant.stop_reason から判定 (turnActiveForCwd)
|
|
1357
|
+
// - TUI モード: bound session の jsonl 末尾 assistant.stop_reason から判定 (turnActiveForCwd)
|
|
1358
|
+
// bound session_id を渡すことで、同一 cwd dir 内の別セッション / サブエージェント /
|
|
1359
|
+
// headless `claude -p` / `/clear` ローテの jsonl を「最新」と取り違えて turn_active を
|
|
1360
|
+
// 誤る (cross-activity contamination = メタ欠陥#1) のを防ぐ。
|
|
1361
|
+
// T2: tool_use 末尾 active 中の中断マーカー無しクラッシュ判定のため、tmux pane_current_command
|
|
1362
|
+
// から claude プロセス生存を 3 値で返す paneClaudeAliveOrUnknown を probe として渡す。
|
|
1363
|
+
// 'dead' (シェル前景)→active=false に倒す。'alive'/'unknown' は active 維持(偽陰性ゼロ)。
|
|
1308
1364
|
let turnActive = null
|
|
1309
1365
|
if (chat?.status === "processing") {
|
|
1310
1366
|
turnActive = true
|
|
1311
1367
|
} else if (chat?.status === "waiting" || chat?.status === "idle") {
|
|
1312
1368
|
turnActive = false
|
|
1313
1369
|
} else if (s.cwd) {
|
|
1314
|
-
turnActive = await turnActiveForCwd(
|
|
1370
|
+
turnActive = await turnActiveForCwd(
|
|
1371
|
+
s.cwd,
|
|
1372
|
+
boundSessionId(boundSessions, s.session_name),
|
|
1373
|
+
s.session_name,
|
|
1374
|
+
{ paneAliveProbe: paneClaudeAliveOrUnknown },
|
|
1375
|
+
)
|
|
1315
1376
|
}
|
|
1316
1377
|
|
|
1317
1378
|
const prev = lastByName.get(s.session_name)
|
|
@@ -1387,6 +1448,8 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1387
1448
|
lastByName.delete(name)
|
|
1388
1449
|
outputFlowByName.delete(name)
|
|
1389
1450
|
lastTurnAtByName.delete(name)
|
|
1451
|
+
// 死んだセッションの bind 記録も掃除する (同名再作成時は claude.tui.bind が再登録)。
|
|
1452
|
+
boundSessions?.delete(name)
|
|
1390
1453
|
}
|
|
1391
1454
|
}
|
|
1392
1455
|
}
|
|
@@ -1408,6 +1471,23 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
|
|
|
1408
1471
|
clearTimeout(t0)
|
|
1409
1472
|
client.off?.("open", onReopen)
|
|
1410
1473
|
},
|
|
1474
|
+
/**
|
|
1475
|
+
* tmux.list_sessions poll fallback 用の session.state snapshot を返す。
|
|
1476
|
+
*
|
|
1477
|
+
* 背景: session.state push は差分送信(prev 比較で変化時のみ送信)のため、WS 再接続
|
|
1478
|
+
* 直後や push 取りこぼし時にドット権威信号(turn_active/proc_busy/child_busy/stalled)が
|
|
1479
|
+
* 古値固着し得る(2026-06-28 ワークフローで検出した「事故的非対称」)。tmux.list_sessions
|
|
1480
|
+
* poll の応答にこれら 4 信号を同梱することで、status/context_pct と同じ精度で poll 経路
|
|
1481
|
+
* からも最新化できるようにする(T1)。
|
|
1482
|
+
*
|
|
1483
|
+
* shallow copy で返却して呼び出し側の mutate が lastByName を汚染するのを防ぐ
|
|
1484
|
+
* (state loop の差分送信ロジックが prev 比較で誤判定するのを避ける = 症状A/B 再発防止)。
|
|
1485
|
+
* 名前が未知 or まだ tick が走っていなければ null(frontend は legacy フォールバックへ)。
|
|
1486
|
+
*/
|
|
1487
|
+
getSnapshot(name) {
|
|
1488
|
+
const entry = lastByName.get(name)
|
|
1489
|
+
return entry ? { ...entry } : null
|
|
1490
|
+
},
|
|
1411
1491
|
}
|
|
1412
1492
|
}
|
|
1413
1493
|
|
|
@@ -2901,16 +2981,23 @@ async function dispatch(msg, ctx) {
|
|
|
2901
2981
|
}
|
|
2902
2982
|
case "tmux.list_sessions": {
|
|
2903
2983
|
try {
|
|
2904
|
-
|
|
2984
|
+
// boundSessions: 各行の context% (ドーナツ) を bound session の jsonl から引く
|
|
2985
|
+
// (cwd dir 内の別セッション jsonl を最新と取り違える=メタ欠陥#1 の根治)。
|
|
2986
|
+
const sessions = await listTmuxSessions({
|
|
2987
|
+
plugins: ctx.plugins,
|
|
2988
|
+
logger: ctx.logger,
|
|
2989
|
+
boundSessions: ctx.tuiReboundSessions,
|
|
2990
|
+
})
|
|
2905
2991
|
// 各 session の最新 hook event を /tmp/cockpit_session_events/ から読んで
|
|
2906
2992
|
// レスポンスに含める (Phase 4: ハードリロード時の lastEvent 復元用)。
|
|
2907
2993
|
// fs.watch 由来の session.event push は揮発性のため、frontend が新規
|
|
2908
2994
|
// マウントすると過去 event を取れず全グレー表示になる事象を解消する。
|
|
2909
2995
|
const lastEventByName = await readAllSessionEvents()
|
|
2910
|
-
const enriched =
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2996
|
+
const enriched = enrichTmuxSessionsForListResponse(
|
|
2997
|
+
sessions,
|
|
2998
|
+
lastEventByName,
|
|
2999
|
+
ctx.getSessionStateSnapshot,
|
|
3000
|
+
)
|
|
2914
3001
|
// cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
|
|
2915
3002
|
// filesystem 上は存在するが tmux session が無い worktree dir のリストを
|
|
2916
3003
|
// 同梱する。古い cockpit は worktree_stubs を無視するので互換 OK。
|
package/src/tmux.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
detectSessionState,
|
|
28
28
|
getSessionCwd,
|
|
29
29
|
} from "./state.mjs"
|
|
30
|
-
import { getSessionUsages, jsonlContextForCwd } from "./usage.mjs"
|
|
30
|
+
import { boundSessionId, getSessionUsages, jsonlContextForCwd } from "./usage.mjs"
|
|
31
31
|
|
|
32
32
|
const execFileP = promisify(execFile)
|
|
33
33
|
|
|
@@ -642,7 +642,9 @@ export async function listSessions(opts = {}) {
|
|
|
642
642
|
// 3. statusLine が無ければ jsonl per-cwd の末尾 assistant.usage 由来 %。
|
|
643
643
|
// 4. それも無ければ pane scrape の正規表現フォールバック。
|
|
644
644
|
const fromStatusLine = cwd ? ctxByCwd.get(cwd)?.contextPercent : null
|
|
645
|
-
|
|
645
|
+
// bound session があればその jsonl で context% を引く (cwd dir の別セッション jsonl の取り違え回避)。
|
|
646
|
+
const boundId = boundSessionId(opts.boundSessions, s.name)
|
|
647
|
+
const jsonlInfo = cwd ? await jsonlContextForCwd(cwd, boundId) : null
|
|
646
648
|
const context_pct =
|
|
647
649
|
jsonlInfo?.isReset && typeof jsonlInfo.percent === "number"
|
|
648
650
|
? jsonlInfo.percent
|
|
@@ -1436,6 +1438,51 @@ export async function isPaneRunningClaude(name, opts = {}) {
|
|
|
1436
1438
|
}
|
|
1437
1439
|
}
|
|
1438
1440
|
|
|
1441
|
+
/**
|
|
1442
|
+
* pane_current_command から claude プロセスの生存を 3 値で返す (T2)。
|
|
1443
|
+
*
|
|
1444
|
+
* `isPaneRunningClaude` は判定不能時に false (= respawn 許可) に倒す保守的な 2 値版で、
|
|
1445
|
+
* `shouldSkipRebindRespawn` の安全弁(「動いている claude は誰の要求でも殺さない」)に
|
|
1446
|
+
* 使われている。これに対して T2 (`scanTurnActive` の tool_use 末尾クラッシュ判定) は
|
|
1447
|
+
* 「claude が確実に死んだ ('dead') ときだけ active=false に倒し、判定不能 ('unknown') /
|
|
1448
|
+
* 生存 ('alive') では active を維持する」という symmetry が逆向きの設計が必要で、
|
|
1449
|
+
* 既存 isPaneRunningClaude の意味論を再利用すると tmux 失敗 → false → 誤って idle 化
|
|
1450
|
+
* = 偽陰性増加 = 中断後の長時間ツール中ペインを灰化させてしまう。
|
|
1451
|
+
*
|
|
1452
|
+
* 3 値の意味:
|
|
1453
|
+
* - 'dead' : pane の前景がシェル (fish/bash/zsh/sh/dash/tmux/login) = claude プロセス
|
|
1454
|
+
* 突然死 (中断マーカー無しの crash) でシェルに戻った確証あり
|
|
1455
|
+
* - 'alive' : pane の前景がシェル以外 (claude / claude.exe / node 等) = 生存
|
|
1456
|
+
* - 'unknown' : tmux 失敗 / 空出力 = 判定不能 = 安全側で active 維持に倒す
|
|
1457
|
+
*
|
|
1458
|
+
* 既存 isPaneRunningClaude は触らない (shouldSkipRebindRespawn の意味論互換のため
|
|
1459
|
+
* 独立 export とする)。命名 (`AliveOrUnknown`) も呼び出し側で「3 値である」ことを
|
|
1460
|
+
* 明示するため。
|
|
1461
|
+
*
|
|
1462
|
+
* @param {string} name
|
|
1463
|
+
* @param {{ tmux?: string }} [opts]
|
|
1464
|
+
* @returns {Promise<'alive' | 'dead' | 'unknown'>}
|
|
1465
|
+
*/
|
|
1466
|
+
export async function paneClaudeAliveOrUnknown(name, opts = {}) {
|
|
1467
|
+
if (!name) return "unknown"
|
|
1468
|
+
try {
|
|
1469
|
+
const { stdout } = await execFileP(tmuxBin(opts), [
|
|
1470
|
+
"display-message",
|
|
1471
|
+
"-p",
|
|
1472
|
+
"-t",
|
|
1473
|
+
`${name}:`,
|
|
1474
|
+
"-F",
|
|
1475
|
+
"#{pane_current_command}",
|
|
1476
|
+
])
|
|
1477
|
+
const cmd = (stdout || "").trim().toLowerCase()
|
|
1478
|
+
if (!cmd) return "unknown"
|
|
1479
|
+
const SHELLS = new Set(["fish", "bash", "zsh", "sh", "dash", "tmux", "login"])
|
|
1480
|
+
return SHELLS.has(cmd) ? "dead" : "alive"
|
|
1481
|
+
} catch {
|
|
1482
|
+
return "unknown"
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1439
1486
|
export function shouldSkipRebindRespawn({
|
|
1440
1487
|
generating,
|
|
1441
1488
|
fresh,
|
package/src/usage.mjs
CHANGED
|
@@ -537,6 +537,72 @@ async function latestJsonlContext(now) {
|
|
|
537
537
|
*/
|
|
538
538
|
const _jsonlSessionUuidByCwd = new Map()
|
|
539
539
|
|
|
540
|
+
/**
|
|
541
|
+
* cwd の jsonl ディレクトリから「対象セッションの jsonl 1 本」を解決する。
|
|
542
|
+
*
|
|
543
|
+
* bound `sessionId` が与えられ、その `<sessionId>.jsonl` が実在すれば **最優先で採用**する
|
|
544
|
+
* (= その tmux ペインが実際に bind/resume して走らせているセッション)。無ければ従来どおり
|
|
545
|
+
* mtime 最新へフォールバックする。
|
|
546
|
+
*
|
|
547
|
+
* 狙い: 1 つの cwd-encode dir には過去の全セッション / サブエージェント / headless `claude -p`
|
|
548
|
+
* (記憶蒸留 Stop hook 等) / `/clear` ローテの jsonl が堆積する (実測 52〜144 本)。mtime 最新を
|
|
549
|
+
* 「そのセッション」とみなすと、別アクティビティの jsonl が最新化した瞬間に turn_active /
|
|
550
|
+
* context% を取り違える (cross-activity contamination)。bound session_id 解決でこれを断つ。
|
|
551
|
+
*
|
|
552
|
+
* @param {string} dir cwd を encode した projects 配下のディレクトリ
|
|
553
|
+
* @param {string[]} files dir 内のファイル名一覧 (呼び出し側が readdir 済み)
|
|
554
|
+
* @param {string|null} sessionId その tmux ペインが bind しているセッション id (任意)
|
|
555
|
+
* @returns {Promise<{fp:string, mtimeMs:number, size:number, uuid:string, bound:boolean}|null>}
|
|
556
|
+
*/
|
|
557
|
+
async function resolveTargetJsonl(dir, files, sessionId) {
|
|
558
|
+
if (typeof sessionId === "string" && sessionId) {
|
|
559
|
+
const want = `${sessionId}.jsonl`
|
|
560
|
+
if (files.includes(want)) {
|
|
561
|
+
const fp = path.join(dir, want)
|
|
562
|
+
const st = await fs.stat(fp).catch(() => null)
|
|
563
|
+
if (st) {
|
|
564
|
+
return { fp, mtimeMs: st.mtimeMs, size: st.size, uuid: sessionId, bound: true }
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// フォールバック: bound 未指定 / その jsonl が未だ存在しない (起動直後など) → mtime 最新。
|
|
569
|
+
let newest = null
|
|
570
|
+
await Promise.all(
|
|
571
|
+
files.map(async (f) => {
|
|
572
|
+
if (!f.endsWith(".jsonl")) return
|
|
573
|
+
const fp = path.join(dir, f)
|
|
574
|
+
const st = await fs.stat(fp).catch(() => null)
|
|
575
|
+
if (!st) return
|
|
576
|
+
if (!newest || st.mtimeMs > newest.mtimeMs) {
|
|
577
|
+
newest = {
|
|
578
|
+
fp,
|
|
579
|
+
mtimeMs: st.mtimeMs,
|
|
580
|
+
size: st.size,
|
|
581
|
+
uuid: f.replace(/\.jsonl$/, ""),
|
|
582
|
+
bound: false,
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}),
|
|
586
|
+
)
|
|
587
|
+
return newest
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* `tuiReboundSessions` (tmux session_name → session_id or "fresh:<req>") の値から、
|
|
592
|
+
* 実セッション id を取り出す。fresh プレースホルダ / 未 bind は null。
|
|
593
|
+
* turn_active / context% 解決の呼び出し側 (state loop / listSessions) が、cwd でなく
|
|
594
|
+
* 「そのペインが実際に走らせているセッション」で jsonl を引くために使う。
|
|
595
|
+
*
|
|
596
|
+
* @param {Map<string,string>|null|undefined} boundSessions
|
|
597
|
+
* @param {string} name tmux セッション名
|
|
598
|
+
* @returns {string|null}
|
|
599
|
+
*/
|
|
600
|
+
export function boundSessionId(boundSessions, name) {
|
|
601
|
+
if (!boundSessions || !name) return null
|
|
602
|
+
const v = boundSessions.get(name)
|
|
603
|
+
return typeof v === "string" && v && !v.startsWith("fresh:") ? v : null
|
|
604
|
+
}
|
|
605
|
+
|
|
540
606
|
/**
|
|
541
607
|
* 指定 cwd の Claude セッション jsonl から context% (USED %) を算出する。
|
|
542
608
|
*
|
|
@@ -563,7 +629,7 @@ const _jsonlSessionUuidByCwd = new Map()
|
|
|
563
629
|
* @param {string} cwd
|
|
564
630
|
* @returns {Promise<{ percent: number | null, uuid: string | null, isReset: boolean }>}
|
|
565
631
|
*/
|
|
566
|
-
export async function jsonlContextForCwd(cwd) {
|
|
632
|
+
export async function jsonlContextForCwd(cwd, sessionId = null) {
|
|
567
633
|
const empty = { percent: null, uuid: null, isReset: false }
|
|
568
634
|
if (!cwd) return empty
|
|
569
635
|
const dirName = encodeCwdToDirName(cwd)
|
|
@@ -572,23 +638,8 @@ export async function jsonlContextForCwd(cwd) {
|
|
|
572
638
|
const files = await fs.readdir(dir).catch(() => null)
|
|
573
639
|
if (!files) return empty
|
|
574
640
|
|
|
575
|
-
|
|
576
|
-
await
|
|
577
|
-
files.map(async (f) => {
|
|
578
|
-
if (!f.endsWith(".jsonl")) return
|
|
579
|
-
const fp = path.join(dir, f)
|
|
580
|
-
const st = await fs.stat(fp).catch(() => null)
|
|
581
|
-
if (!st) return
|
|
582
|
-
if (!newest || st.mtimeMs > newest.mtimeMs) {
|
|
583
|
-
newest = {
|
|
584
|
-
fp,
|
|
585
|
-
mtimeMs: st.mtimeMs,
|
|
586
|
-
size: st.size,
|
|
587
|
-
uuid: f.replace(/\.jsonl$/, ""),
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
}),
|
|
591
|
-
)
|
|
641
|
+
// bound session_id があればその jsonl を、無ければ mtime 最新を採用 (cross-activity 汚染回避)。
|
|
642
|
+
const newest = await resolveTargetJsonl(dir, files, sessionId)
|
|
592
643
|
if (!newest) return empty
|
|
593
644
|
|
|
594
645
|
// 末尾 assistant.usage を探す。`_jsonlCtxMemo` は fp+mtime+size をキーにしているので
|
|
@@ -637,8 +688,11 @@ export async function jsonlContextForCwd(cwd) {
|
|
|
637
688
|
})
|
|
638
689
|
}
|
|
639
690
|
|
|
640
|
-
|
|
641
|
-
|
|
691
|
+
// isReset 検知のキーは bound session があればそれ、無ければ cwd。同一 cwd を複数の tmux
|
|
692
|
+
// セッションが共有しても、bound 時は各セッション独立に /clear (uuid 変化) を検知できる。
|
|
693
|
+
const trackKey = sessionId || cwd
|
|
694
|
+
const prevUuid = _jsonlSessionUuidByCwd.get(trackKey)
|
|
695
|
+
_jsonlSessionUuidByCwd.set(trackKey, newest.uuid)
|
|
642
696
|
const uuidChanged = prevUuid !== undefined && prevUuid !== newest.uuid
|
|
643
697
|
|
|
644
698
|
if (percent == null) {
|
|
@@ -665,6 +719,47 @@ const TURN_END_STOP_REASONS = new Set([
|
|
|
665
719
|
"max_tokens",
|
|
666
720
|
])
|
|
667
721
|
|
|
722
|
+
// 末尾が実ユーザー入力 (応答未着) でも、その入力がこの時間より古ければ「中断/クラッシュ/送信
|
|
723
|
+
// 直後終了で応答が来ないまま終わったセッション」とみなし active=false に倒す。frontend
|
|
724
|
+
// events.ts の STALE_USER_TAIL_MS と揃える。ステータスドット 0.7.17 一本化で agent 側に
|
|
725
|
+
// 移植漏れた鮮度フロアで、Esc 中断後に放置した jsonl 等が永久に turn_active=true (青) に
|
|
726
|
+
// 居座り 0.7.17 の「赤=閉じろナッジ」を相殺していた問題を根治する。
|
|
727
|
+
// ※ tool_use 末尾 (assistant が active を立てたケース) は floor しない (frontend と同じ。
|
|
728
|
+
// 長時間ツール実行を誤って畳まない)。ツール中クラッシュは下記 STALE_TOOL_USE_MS + PID 生存判定。
|
|
729
|
+
const STALE_USER_TAIL_MS = 60_000
|
|
730
|
+
|
|
731
|
+
// T2: tool_use 末尾 active (assistant 由来) の「中断マーカー無しのプロセス突然死」検知用フロア。
|
|
732
|
+
// scanTurnActive は tool_use 末尾を floor しない (長時間ツールの偽陰性を防ぐ) ため、
|
|
733
|
+
// claude プロセスが clean に死ぬ (Stop hook を経ず crash) ケースで永久青固着し得る。
|
|
734
|
+
// turnActiveForCwd で tool_use 末尾 active + この時間経過時に tmux pane_current_command で
|
|
735
|
+
// claude プロセス生存を確認し、'dead' (= シェル前景) なら active=false に倒す。
|
|
736
|
+
// 'alive' / 'unknown' (tmux 失敗) は active 維持 = 長時間ツール / tmux 失敗時の偽陰性ゼロ。
|
|
737
|
+
// 5s は「pane scrape も capture もすり抜けた assistant 行が確実に書かれた猶予」を最短で取る値。
|
|
738
|
+
const STALE_TOOL_USE_MS = 5_000
|
|
739
|
+
|
|
740
|
+
/** jsonl の user 行から表示テキスト (string content / text ブロック連結) を取り出す。
|
|
741
|
+
* コマンド echo (`<command-name>`) 判定に使う。 */
|
|
742
|
+
function userEventText(d) {
|
|
743
|
+
const content = d?.message?.content
|
|
744
|
+
if (typeof content === "string") return content
|
|
745
|
+
if (Array.isArray(content)) {
|
|
746
|
+
return content
|
|
747
|
+
.map((c) => (c && c.type === "text" ? String(c.text || "") : ""))
|
|
748
|
+
.join("")
|
|
749
|
+
}
|
|
750
|
+
return ""
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** ISO 文字列 / 数値の timestamp を ms に。取れなければ null。 */
|
|
754
|
+
function parseEntryTs(ts) {
|
|
755
|
+
if (typeof ts === "number") return Number.isFinite(ts) ? ts : null
|
|
756
|
+
if (typeof ts === "string") {
|
|
757
|
+
const n = Date.parse(ts)
|
|
758
|
+
return Number.isFinite(n) ? n : null
|
|
759
|
+
}
|
|
760
|
+
return null
|
|
761
|
+
}
|
|
762
|
+
|
|
668
763
|
/**
|
|
669
764
|
* jsonl の末尾を順走査してターンが現在 active かを返す。
|
|
670
765
|
*
|
|
@@ -679,13 +774,33 @@ const TURN_END_STOP_REASONS = new Set([
|
|
|
679
774
|
* 初期値は「ターン完了状態」(lastTurnEnded=true)。空 jsonl や応答未着 のセッション
|
|
680
775
|
* は active=false でフォールバックする。
|
|
681
776
|
*
|
|
777
|
+
* 鮮度フロア: 最終的な active=true が「実ユーザー入力」由来 (応答未着) で、その入力が
|
|
778
|
+
* STALE_USER_TAIL_MS より古ければ false に倒す (中断/クラッシュ/放置の永久青を解消)。
|
|
779
|
+
* assistant の tool_use 由来 active は floor しない (長時間ツール実行を畳まない)。
|
|
780
|
+
* コマンド echo (`<command-name>` 注入: /clear /compact /skill 等) は LLM ターンを起こさない
|
|
781
|
+
* 行があるため、実ユーザー入力とみなさずスキップする (frontend isCommandEchoEvent skip と同等)。
|
|
782
|
+
*
|
|
682
783
|
* @param {string} text jsonl の (末尾) テキスト
|
|
784
|
+
* @param {number} [now] テスト用の現在時刻 (ms)。省略時は Date.now()
|
|
683
785
|
* @returns {boolean}
|
|
684
786
|
*/
|
|
685
|
-
|
|
686
|
-
|
|
787
|
+
/**
|
|
788
|
+
* jsonl テキストを走査して「最終 active 状態 + 何が立てたか + その時刻」の 3 つ組を返す
|
|
789
|
+
* 内部ヘルパー (T2)。scanTurnActive は本関数の `.active` を返す薄ラッパで API 互換。
|
|
790
|
+
* turnActiveForCwd は assistant 由来 active の鮮度判定で PID 生存 probe を挟むために
|
|
791
|
+
* activeBy/activeSinceTs を必要とする。
|
|
792
|
+
*
|
|
793
|
+
* @returns {{active: boolean, activeBy: 'user'|'assistant'|null, activeSinceTs: number|null}}
|
|
794
|
+
*/
|
|
795
|
+
export function _scanTurnState(text, now = Date.now()) {
|
|
796
|
+
const empty = { active: false, activeBy: null, activeSinceTs: null }
|
|
797
|
+
if (!text) return empty
|
|
687
798
|
let active = false
|
|
688
799
|
let lastTurnEnded = true
|
|
800
|
+
// 最終 active=true を立てた要因 ("user"=実入力 / "assistant"=tool_use) と、その入力の時刻。
|
|
801
|
+
// 鮮度フロアは "user" 由来かつ古いときだけ適用する (T2 は assistant 由来でも別フロアで probe)。
|
|
802
|
+
let activeBy = null
|
|
803
|
+
let activeSinceTs = null
|
|
689
804
|
const lines = text.split("\n")
|
|
690
805
|
for (const line of lines) {
|
|
691
806
|
if (!line) continue
|
|
@@ -700,10 +815,14 @@ export function scanTurnActive(text) {
|
|
|
700
815
|
if (stop && TURN_END_STOP_REASONS.has(stop)) {
|
|
701
816
|
active = false
|
|
702
817
|
lastTurnEnded = true
|
|
818
|
+
activeBy = null
|
|
819
|
+
activeSinceTs = null
|
|
703
820
|
} else {
|
|
704
821
|
// tool_use / pause_turn / null / 未知 → ターン継続中扱い (安全側)
|
|
705
822
|
active = true
|
|
706
823
|
lastTurnEnded = false
|
|
824
|
+
activeBy = "assistant"
|
|
825
|
+
activeSinceTs = parseEntryTs(d.timestamp)
|
|
707
826
|
}
|
|
708
827
|
} else if (d.type === "user") {
|
|
709
828
|
// 「実ユーザー入力」と「tool_result の自動 user ラッパー」を区別する。
|
|
@@ -712,13 +831,45 @@ export function scanTurnActive(text) {
|
|
|
712
831
|
// 来たら新ターン開始 → active=true。
|
|
713
832
|
const content = Array.isArray(d.message?.content) ? d.message.content : []
|
|
714
833
|
const isToolResult = content.some((c) => c?.type === "tool_result")
|
|
715
|
-
if (
|
|
834
|
+
if (isToolResult) continue
|
|
835
|
+
const userText = userEventText(d)
|
|
836
|
+
// 中断マーカー ([Request interrupted by user...]) = ユーザーが Esc 等でターンを止めた。
|
|
837
|
+
// 直前が tool_use (active) でも明示的に完了扱いにする (= 生成は止まっている)。これが無いと
|
|
838
|
+
// ツール実行中の中断後に放置した jsonl が永久に active=true (青) に居座る。
|
|
839
|
+
if (userText.trim().startsWith("[Request interrupted by user")) {
|
|
840
|
+
active = false
|
|
841
|
+
lastTurnEnded = true
|
|
842
|
+
activeBy = null
|
|
843
|
+
activeSinceTs = null
|
|
844
|
+
continue
|
|
845
|
+
}
|
|
846
|
+
// コマンド echo (/clear /compact /skill 等) は LLM ターンを起こさない行があるため、
|
|
847
|
+
// 「実ユーザー入力」とみなさずスキップする (直前の会話状態を保つ)。
|
|
848
|
+
if (/<command-name>/.test(userText)) continue
|
|
849
|
+
if (lastTurnEnded) {
|
|
716
850
|
active = true
|
|
717
851
|
lastTurnEnded = false
|
|
852
|
+
activeBy = "user"
|
|
853
|
+
activeSinceTs = parseEntryTs(d.timestamp)
|
|
718
854
|
}
|
|
719
855
|
}
|
|
720
856
|
}
|
|
721
|
-
|
|
857
|
+
// 鮮度フロア (user 側): 実ユーザー入力で active になったまま応答が来ず、その入力が古ければ
|
|
858
|
+
// idle に倒す。assistant 側 (tool_use 末尾) はここでは floor しない (長時間ツールの偽陰性を防ぐ)。
|
|
859
|
+
// assistant 側のクラッシュ判定は turnActiveForCwd が PID 生存 probe で別途行う (T2)。
|
|
860
|
+
if (
|
|
861
|
+
active &&
|
|
862
|
+
activeBy === "user" &&
|
|
863
|
+
activeSinceTs !== null &&
|
|
864
|
+
now - activeSinceTs > STALE_USER_TAIL_MS
|
|
865
|
+
) {
|
|
866
|
+
return { active: false, activeBy: null, activeSinceTs: null }
|
|
867
|
+
}
|
|
868
|
+
return { active, activeBy, activeSinceTs }
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
export function scanTurnActive(text, now = Date.now()) {
|
|
872
|
+
return _scanTurnState(text, now).active
|
|
722
873
|
}
|
|
723
874
|
|
|
724
875
|
/** P5: turnActiveForCwd のメモ (newest fp + mtime + size をキー)。 */
|
|
@@ -732,14 +883,30 @@ const _turnActiveMemo = new Map()
|
|
|
732
883
|
* 同じ真実で同期させ、Esc 中断後の「青固着 60s」を解消する。
|
|
733
884
|
*
|
|
734
885
|
* 動作:
|
|
735
|
-
* 1. cwd → encoded dir →
|
|
886
|
+
* 1. cwd → encoded dir → bound `sessionId` の jsonl (あれば最優先) / 無ければ mtime 最新を選ぶ
|
|
736
887
|
* 2. 末尾 256KB を tail 読みし `scanTurnActive` でターン状態を判定
|
|
737
888
|
* 3. tail で結論が出ない巨大ツール結果ケースのみ全文 read にフォールバック
|
|
889
|
+
* 4. (T2) tool_use 末尾 active かつ STALE_TOOL_USE_MS 経過のとき paneAliveProbe で
|
|
890
|
+
* claude プロセス生存を確認 → 'dead' なら active=false に倒す (中断マーカー無し
|
|
891
|
+
* の crash を idle 化)。'alive' / 'unknown' は active 維持 (長時間ツール + tmux
|
|
892
|
+
* 失敗時の偽陰性ゼロ)。sessionName / paneAliveProbe が無ければ probe をスキップ。
|
|
738
893
|
*
|
|
739
894
|
* @param {string} cwd
|
|
895
|
+
* @param {string|null} [sessionId] その tmux ペインが bind しているセッション id。指定時はその
|
|
896
|
+
* jsonl を直接読み、cwd dir 内の別セッション/サブエージェント/headless jsonl の取り違えを防ぐ。
|
|
897
|
+
* @param {string|null} [sessionName] tmux ペイン名。指定時かつ paneAliveProbe ありで tool_use
|
|
898
|
+
* 末尾 active のクラッシュ判定を行う (T2)。
|
|
899
|
+
* @param {{ paneAliveProbe?: (name: string) => Promise<'alive'|'dead'|'unknown'>, now?: number }} [opts]
|
|
900
|
+
* paneAliveProbe: tmux pane_current_command から claude プロセス生存を 3 値で返す関数。
|
|
901
|
+
* 循環 import 回避のため呼び出し側 (main.mjs) から DI で渡す。テストではモックを渡す。
|
|
740
902
|
* @returns {Promise<boolean | null>} true=生成中 / false=待機中 / null=jsonl 不在
|
|
741
903
|
*/
|
|
742
|
-
export async function turnActiveForCwd(
|
|
904
|
+
export async function turnActiveForCwd(
|
|
905
|
+
cwd,
|
|
906
|
+
sessionId = null,
|
|
907
|
+
sessionName = null,
|
|
908
|
+
opts = {},
|
|
909
|
+
) {
|
|
743
910
|
if (!cwd) return null
|
|
744
911
|
const dirName = encodeCwdToDirName(cwd)
|
|
745
912
|
if (!dirName) return null
|
|
@@ -747,18 +914,8 @@ export async function turnActiveForCwd(cwd) {
|
|
|
747
914
|
const files = await fs.readdir(dir).catch(() => null)
|
|
748
915
|
if (!files) return null
|
|
749
916
|
|
|
750
|
-
|
|
751
|
-
await
|
|
752
|
-
files.map(async (f) => {
|
|
753
|
-
if (!f.endsWith(".jsonl")) return
|
|
754
|
-
const fp = path.join(dir, f)
|
|
755
|
-
const st = await fs.stat(fp).catch(() => null)
|
|
756
|
-
if (!st) return
|
|
757
|
-
if (!newest || st.mtimeMs > newest.mtimeMs) {
|
|
758
|
-
newest = { fp, mtimeMs: st.mtimeMs, size: st.size }
|
|
759
|
-
}
|
|
760
|
-
}),
|
|
761
|
-
)
|
|
917
|
+
// bound session_id があればその jsonl を、無ければ mtime 最新を採用 (cross-activity 汚染回避)。
|
|
918
|
+
const newest = await resolveTargetJsonl(dir, files, sessionId)
|
|
762
919
|
if (!newest) return null
|
|
763
920
|
|
|
764
921
|
const memo = _turnActiveMemo.get(newest.fp)
|
|
@@ -766,20 +923,43 @@ export async function turnActiveForCwd(cwd) {
|
|
|
766
923
|
return memo.result
|
|
767
924
|
}
|
|
768
925
|
|
|
926
|
+
const now = typeof opts.now === "number" ? opts.now : Date.now()
|
|
927
|
+
|
|
769
928
|
// 256KB tail で多くの場合は決着がつく (1 ターン分の assistant + tool_use/result サイクル) 。
|
|
770
929
|
// tail 内に assistant 行が 0 件のみのケース (= 巨大 tool_result で先頭を tail から外れた)
|
|
771
930
|
// に備え、結果が「全行で active 状態が変化しない」場合は全文読みにフォールバックする。
|
|
772
931
|
const tail = await readTail(newest.fp, 256 * 1024)
|
|
773
|
-
let
|
|
932
|
+
let state = { active: false, activeBy: null, activeSinceTs: null }
|
|
774
933
|
if (tail != null) {
|
|
775
|
-
|
|
934
|
+
state = _scanTurnState(tail, now)
|
|
776
935
|
}
|
|
777
936
|
// tail で active=false の場合のみ、巨大 tool_result で先頭の assistant 行が
|
|
778
937
|
// 落ちて誤って完了扱いになっていないか全文で確認する (高コストだが必要時のみ)。
|
|
779
|
-
if (active === false) {
|
|
938
|
+
if (state.active === false) {
|
|
780
939
|
const full = await readOrNull(newest.fp)
|
|
781
|
-
if (full != null)
|
|
940
|
+
if (full != null) state = _scanTurnState(full, now)
|
|
782
941
|
}
|
|
942
|
+
|
|
943
|
+
let active = state.active
|
|
944
|
+
|
|
945
|
+
// T2: tool_use 末尾 active (assistant 由来) で、その記入から STALE_TOOL_USE_MS 経過していて、
|
|
946
|
+
// tmux pane_current_command から probe 可能なら、claude プロセス生存を確認する。
|
|
947
|
+
// 'dead' (シェル前景) → 中断マーカー無しの crash 確証 → active=false に倒す。
|
|
948
|
+
// 'alive' (claude 等) → 長時間ツール継続中 → active 維持 (偽陰性ゼロ)。
|
|
949
|
+
// 'unknown' (tmux 失敗 / 空出力) → 判定不能 → 安全側で active 維持 (偽陰性ゼロ)。
|
|
950
|
+
// sessionName 未指定 / probe 関数未指定の場合は本ロジックをスキップ (後方互換、SDK チャット保護)。
|
|
951
|
+
if (
|
|
952
|
+
active &&
|
|
953
|
+
state.activeBy === "assistant" &&
|
|
954
|
+
state.activeSinceTs !== null &&
|
|
955
|
+
now - state.activeSinceTs > STALE_TOOL_USE_MS &&
|
|
956
|
+
sessionName &&
|
|
957
|
+
typeof opts.paneAliveProbe === "function"
|
|
958
|
+
) {
|
|
959
|
+
const verdict = await opts.paneAliveProbe(sessionName)
|
|
960
|
+
if (verdict === "dead") active = false
|
|
961
|
+
}
|
|
962
|
+
|
|
783
963
|
_turnActiveMemo.set(newest.fp, {
|
|
784
964
|
mtimeMs: newest.mtimeMs,
|
|
785
965
|
size: newest.size,
|