@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
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
- function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker }) {
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(s.cwd)
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
- const sessions = await listTmuxSessions({ plugins: ctx.plugins, logger: ctx.logger })
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 = sessions.map((s) => ({
2911
- ...s,
2912
- last_event: lastEventByName.get(s.name) || null,
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
- const jsonlInfo = cwd ? await jsonlContextForCwd(cwd) : null
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
- let newest = null // { fp, mtimeMs, size, uuid }
576
- await Promise.all(
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
- const prevUuid = _jsonlSessionUuidByCwd.get(cwd)
641
- _jsonlSessionUuidByCwd.set(cwd, newest.uuid)
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
- export function scanTurnActive(text) {
686
- if (!text) return false
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 (!isToolResult && lastTurnEnded) {
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
- return active
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 → 最新 mtime*.jsonl を選ぶ
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(cwd) {
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
- let newest = null
751
- await Promise.all(
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 active = false
932
+ let state = { active: false, activeBy: null, activeSinceTs: null }
774
933
  if (tail != null) {
775
- active = scanTurnActive(tail)
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) active = scanTurnActive(full)
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,