@cocorograph/hub-agent 0.7.18 → 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.18",
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,
@@ -667,6 +668,11 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
667
668
  // セッションの jsonl」で判定するために渡す (cwd dir 内の別 jsonl 取り違え=メタ欠陥#1 の根治)。
668
669
  boundSessions: ctx.tuiReboundSessions,
669
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
670
676
  // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
671
677
  // に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
672
678
  // text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
@@ -1192,7 +1198,47 @@ const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
1192
1198
  // TURN_STALL_WARN_MS と揃える。
1193
1199
  const STALL_WARN_MS = Number(process.env.HUB_AGENT_STALL_WARN_MS ?? 90000)
1194
1200
 
1195
- function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker, boundSessions }) {
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 }) {
1196
1242
  const lastByName = new Map() // session_name → {status, context_pct}
1197
1243
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
1198
1244
  // (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
@@ -1312,13 +1358,21 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1312
1358
  // bound session_id を渡すことで、同一 cwd dir 内の別セッション / サブエージェント /
1313
1359
  // headless `claude -p` / `/clear` ローテの jsonl を「最新」と取り違えて turn_active を
1314
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 維持(偽陰性ゼロ)。
1315
1364
  let turnActive = null
1316
1365
  if (chat?.status === "processing") {
1317
1366
  turnActive = true
1318
1367
  } else if (chat?.status === "waiting" || chat?.status === "idle") {
1319
1368
  turnActive = false
1320
1369
  } else if (s.cwd) {
1321
- turnActive = await turnActiveForCwd(s.cwd, boundSessionId(boundSessions, s.session_name))
1370
+ turnActive = await turnActiveForCwd(
1371
+ s.cwd,
1372
+ boundSessionId(boundSessions, s.session_name),
1373
+ s.session_name,
1374
+ { paneAliveProbe: paneClaudeAliveOrUnknown },
1375
+ )
1322
1376
  }
1323
1377
 
1324
1378
  const prev = lastByName.get(s.session_name)
@@ -1417,6 +1471,23 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1417
1471
  clearTimeout(t0)
1418
1472
  client.off?.("open", onReopen)
1419
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
+ },
1420
1491
  }
1421
1492
  }
1422
1493
 
@@ -2922,10 +2993,11 @@ async function dispatch(msg, ctx) {
2922
2993
  // fs.watch 由来の session.event push は揮発性のため、frontend が新規
2923
2994
  // マウントすると過去 event を取れず全グレー表示になる事象を解消する。
2924
2995
  const lastEventByName = await readAllSessionEvents()
2925
- const enriched = sessions.map((s) => ({
2926
- ...s,
2927
- last_event: lastEventByName.get(s.name) || null,
2928
- }))
2996
+ const enriched = enrichTmuxSessionsForListResponse(
2997
+ sessions,
2998
+ lastEventByName,
2999
+ ctx.getSessionStateSnapshot,
3000
+ )
2929
3001
  // cockpit (PR 1719) で未起動 worktree をサイドバーに可視化するために
2930
3002
  // filesystem 上は存在するが tmux session が無い worktree dir のリストを
2931
3003
  // 同梱する。古い cockpit は worktree_stubs を無視するので互換 OK。
package/src/tmux.mjs CHANGED
@@ -1438,6 +1438,51 @@ export async function isPaneRunningClaude(name, opts = {}) {
1438
1438
  }
1439
1439
  }
1440
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
+
1441
1486
  export function shouldSkipRebindRespawn({
1442
1487
  generating,
1443
1488
  fresh,
package/src/usage.mjs CHANGED
@@ -725,9 +725,18 @@ const TURN_END_STOP_REASONS = new Set([
725
725
  // 移植漏れた鮮度フロアで、Esc 中断後に放置した jsonl 等が永久に turn_active=true (青) に
726
726
  // 居座り 0.7.17 の「赤=閉じろナッジ」を相殺していた問題を根治する。
727
727
  // ※ tool_use 末尾 (assistant が active を立てたケース) は floor しない (frontend と同じ。
728
- // 長時間ツール実行を誤って畳まない)。ツール中クラッシュは PID 生存判定が別途必要。
728
+ // 長時間ツール実行を誤って畳まない)。ツール中クラッシュは下記 STALE_TOOL_USE_MS + PID 生存判定。
729
729
  const STALE_USER_TAIL_MS = 60_000
730
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
+
731
740
  /** jsonl の user 行から表示テキスト (string content / text ブロック連結) を取り出す。
732
741
  * コマンド echo (`<command-name>`) 判定に使う。 */
733
742
  function userEventText(d) {
@@ -775,12 +784,21 @@ function parseEntryTs(ts) {
775
784
  * @param {number} [now] テスト用の現在時刻 (ms)。省略時は Date.now()
776
785
  * @returns {boolean}
777
786
  */
778
- export function scanTurnActive(text, now = Date.now()) {
779
- 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
780
798
  let active = false
781
799
  let lastTurnEnded = true
782
800
  // 最終 active=true を立てた要因 ("user"=実入力 / "assistant"=tool_use) と、その入力の時刻。
783
- // 鮮度フロアは "user" 由来かつ古いときだけ適用する。
801
+ // 鮮度フロアは "user" 由来かつ古いときだけ適用する (T2 は assistant 由来でも別フロアで probe)。
784
802
  let activeBy = null
785
803
  let activeSinceTs = null
786
804
  const lines = text.split("\n")
@@ -836,16 +854,22 @@ export function scanTurnActive(text, now = Date.now()) {
836
854
  }
837
855
  }
838
856
  }
839
- // 鮮度フロア: 実ユーザー入力で active になったまま応答が来ず、その入力が古ければ idle に倒す。
857
+ // 鮮度フロア (user 側): 実ユーザー入力で active になったまま応答が来ず、その入力が古ければ
858
+ // idle に倒す。assistant 側 (tool_use 末尾) はここでは floor しない (長時間ツールの偽陰性を防ぐ)。
859
+ // assistant 側のクラッシュ判定は turnActiveForCwd が PID 生存 probe で別途行う (T2)。
840
860
  if (
841
861
  active &&
842
862
  activeBy === "user" &&
843
863
  activeSinceTs !== null &&
844
864
  now - activeSinceTs > STALE_USER_TAIL_MS
845
865
  ) {
846
- return false
866
+ return { active: false, activeBy: null, activeSinceTs: null }
847
867
  }
848
- return active
868
+ return { active, activeBy, activeSinceTs }
869
+ }
870
+
871
+ export function scanTurnActive(text, now = Date.now()) {
872
+ return _scanTurnState(text, now).active
849
873
  }
850
874
 
851
875
  /** P5: turnActiveForCwd のメモ (newest fp + mtime + size をキー)。 */
@@ -862,13 +886,27 @@ const _turnActiveMemo = new Map()
862
886
  * 1. cwd → encoded dir → bound `sessionId` の jsonl (あれば最優先) / 無ければ mtime 最新を選ぶ
863
887
  * 2. 末尾 256KB を tail 読みし `scanTurnActive` でターン状態を判定
864
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 をスキップ。
865
893
  *
866
894
  * @param {string} cwd
867
895
  * @param {string|null} [sessionId] その tmux ペインが bind しているセッション id。指定時はその
868
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 で渡す。テストではモックを渡す。
869
902
  * @returns {Promise<boolean | null>} true=生成中 / false=待機中 / null=jsonl 不在
870
903
  */
871
- export async function turnActiveForCwd(cwd, sessionId = null) {
904
+ export async function turnActiveForCwd(
905
+ cwd,
906
+ sessionId = null,
907
+ sessionName = null,
908
+ opts = {},
909
+ ) {
872
910
  if (!cwd) return null
873
911
  const dirName = encodeCwdToDirName(cwd)
874
912
  if (!dirName) return null
@@ -885,20 +923,43 @@ export async function turnActiveForCwd(cwd, sessionId = null) {
885
923
  return memo.result
886
924
  }
887
925
 
926
+ const now = typeof opts.now === "number" ? opts.now : Date.now()
927
+
888
928
  // 256KB tail で多くの場合は決着がつく (1 ターン分の assistant + tool_use/result サイクル) 。
889
929
  // tail 内に assistant 行が 0 件のみのケース (= 巨大 tool_result で先頭を tail から外れた)
890
930
  // に備え、結果が「全行で active 状態が変化しない」場合は全文読みにフォールバックする。
891
931
  const tail = await readTail(newest.fp, 256 * 1024)
892
- let active = false
932
+ let state = { active: false, activeBy: null, activeSinceTs: null }
893
933
  if (tail != null) {
894
- active = scanTurnActive(tail)
934
+ state = _scanTurnState(tail, now)
895
935
  }
896
936
  // tail で active=false の場合のみ、巨大 tool_result で先頭の assistant 行が
897
937
  // 落ちて誤って完了扱いになっていないか全文で確認する (高コストだが必要時のみ)。
898
- if (active === false) {
938
+ if (state.active === false) {
899
939
  const full = await readOrNull(newest.fp)
900
- if (full != null) active = scanTurnActive(full)
940
+ if (full != null) state = _scanTurnState(full, now)
901
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
+
902
963
  _turnActiveMemo.set(newest.fp, {
903
964
  mtimeMs: newest.mtimeMs,
904
965
  size: newest.size,