@cocorograph/hub-agent 0.6.93 → 0.6.95

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.6.93",
3
+ "version": "0.6.95",
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
@@ -85,6 +85,12 @@ import {
85
85
  recordChatRateLimit,
86
86
  } from "./usage.mjs"
87
87
  import { clearChatSignal, getChatSignal, recordChatActivity } from "./chat-signals.mjs"
88
+ import {
89
+ ReadinessTracker,
90
+ busyChildCount,
91
+ getPanePid,
92
+ snapshotProcs,
93
+ } from "./proc-introspect.mjs"
88
94
  import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
89
95
 
90
96
  const logger = pino({ name: "hub-agent" })
@@ -582,17 +588,34 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
582
588
 
583
589
  // 5s 周期で全 tmux session の状態を捕捉し、変化したものだけ session.state を push。
584
590
  // browser がフォーカスしていない session でも常時更新するためのバックグラウンド職人。
591
+ // ready-for-input 判定: claude プロセスの子プロセスを内省し「Stop フック/ツール実行が
592
+ // 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
593
+ // Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
594
+ const readinessTracker = new ReadinessTracker()
585
595
  const stateLoop = startStateLoop({
586
596
  client,
587
597
  plugins,
588
598
  logger,
589
599
  intervalMs: 5_000,
590
600
  claudeBridge,
601
+ readinessTracker,
591
602
  })
592
603
  // bundle hook (cockpit_session_event_hook.py) が /tmp/cockpit_session_events/<name>.json
593
- // に書き出す UserPromptSubmit / Stop の event を fs.watch で拾って WS push する。
604
+ // に書き出す UserPromptSubmit / Stop / ready の event を fs.watch で拾って WS push する。
594
605
  // text マーカー判定 (detectStatusFromText) より精度が高い「ターン境界」の signal。
595
- const sessionEventLoop = await startSessionEventWatcher({ client, logger })
606
+ // prompt_submit / stop readinessTracker arm し、ready の settle 検出に繋ぐ。
607
+ const sessionEventLoop = await startSessionEventWatcher({
608
+ client,
609
+ logger,
610
+ readinessTracker,
611
+ })
612
+ // arm 済みセッションの子プロセスを高速 poll し、baseline 復帰で event ファイルに 'ready'
613
+ // を書く (既存 watcher が session.event 'ready' を push)。アイドル時は ps を spawn しない。
614
+ const readinessLoop = startReadinessLoop({
615
+ logger,
616
+ tracker: readinessTracker,
617
+ intervalMs: 700,
618
+ })
596
619
 
597
620
  // TUI 権限ブリッジ: 対話 TUI の PreToolUse フックが書く権限要求を fs.watch で
598
621
  // 拾い、既存 SDK 経路と同じ `claude.permission.request` として push する。
@@ -660,6 +683,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
660
683
  await runHookBroadcast(plugins, "onAgentStop", ctx)
661
684
  stateLoop.stop()
662
685
  sessionEventLoop?.stop?.()
686
+ readinessLoop?.stop?.()
663
687
  tuiPermissionBridge.stop()
664
688
  tuiViewerRegistry.stop()
665
689
  jsonlLiveWatchers.stop()
@@ -832,7 +856,7 @@ async function readSessionEventFile(sessionName) {
832
856
  * ディレクトリが無ければ作成し、起動直後に既存ファイルを 1 回読んで初期 push。
833
857
  * 以降は変更検知のたびに該当ファイルを読み直して push する。
834
858
  */
835
- async function startSessionEventWatcher({ client, logger }) {
859
+ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
836
860
  try {
837
861
  await mkdir(SESSION_EVENTS_DIR, { recursive: true })
838
862
  } catch (err) {
@@ -851,6 +875,21 @@ async function startSessionEventWatcher({ client, logger }) {
851
875
  const text = await readFile(full, "utf-8")
852
876
  const data = JSON.parse(text)
853
877
  if (!data || typeof data.event !== "string") return
878
+ // ターン境界 (prompt_submit / stop) で readiness tracker を arm し、子プロセスが
879
+ // baseline へ復帰したら 'ready' を出す土台にする。'ready' 自身では arm しない
880
+ // (readiness loop の書込み → 再 arm の無限ループを避ける)。prompt_submit は次ターンの
881
+ // ready を解禁するため emitted をリセットする。
882
+ if (readinessTracker) {
883
+ if (data.event === "prompt_submit") {
884
+ readinessTracker.arm(sessionName, { newTurn: true })
885
+ } else if (data.event === "stop" || data.event === "idle_hint") {
886
+ // stop: ターン論理終了。idle_hint: Notification(idle_prompt) フックの裏取り。
887
+ // どちらも arm するだけで、実際の 'ready' は proc 内省 (子プロセス baseline 復帰)
888
+ // が確定させる。これにより Notification が Stop フック完了前に発火しても、
889
+ // proc が clear するまで誤フラッシュしない (idle_hint→即 ready にはしない)。
890
+ readinessTracker.arm(sessionName)
891
+ }
892
+ }
854
893
  client.send({
855
894
  type: "session.event",
856
895
  session_name: sessionName,
@@ -892,6 +931,88 @@ async function startSessionEventWatcher({ client, logger }) {
892
931
  }
893
932
  }
894
933
 
934
+ /**
935
+ * arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
936
+ * 高速 poll し、子プロセスが baseline へ復帰したエッジで event ファイルに 'ready' を書く。
937
+ * 既存の startSessionEventWatcher がそれを `session.event` 'ready' として push し、frontend は
938
+ * 「真に入力可能」になった瞬間として消灯 + キューフラッシュする (Stop フック中の誤フラッシュ
939
+ * 根治)。tracker.getBusy() は state loop が proc_busy レベルとして相乗せする。
940
+ *
941
+ * 負荷: アイドル (armed/busy なし) のときは ps を spawn しない。claude pid は TTL キャッシュ。
942
+ */
943
+ function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
944
+ let stopped = false
945
+ const PANE_PID_TTL_MS = 30_000
946
+ const panePidByName = new Map() // name → {pid, at}
947
+ const unresolvedStreak = new Map() // name → 連続解決失敗回数 (SDK チャット等の掃除用)
948
+
949
+ // busy 子数のカウント基点は claude 本体 PID ではなく tmux の **pane_pid** にする。
950
+ // 理由: (1) クロスプラットフォーム安全 — claude を comm 名で同定する方式は Linux/WSL で
951
+ // claude が `node .../cli.js` として起動し comm が 'node' になり同定に失敗する。pane_pid
952
+ // 基点なら子孫を数えるだけで claude の同定が不要。(2) /clear 耐性 — claude PID は /clear で
953
+ // 変わるが pane_pid (ペインの shell) は不変なので再解決が要らない。baseline (rolling-min) が
954
+ // 「claude 本体 + 常駐 MCP」を吸収するので、基点が pane_pid でも busy=「一過性の子増加」で正しい。
955
+ const resolvePanePid = async (name, procMap) => {
956
+ const hit = panePidByName.get(name)
957
+ if (hit && procMap.has(hit.pid) && Date.now() - hit.at < PANE_PID_TTL_MS) {
958
+ return hit.pid
959
+ }
960
+ const panePid = await getPanePid(name)
961
+ if (panePid && procMap.has(panePid)) {
962
+ panePidByName.set(name, { pid: panePid, at: Date.now() })
963
+ return panePid
964
+ }
965
+ panePidByName.delete(name)
966
+ return null
967
+ }
968
+
969
+ const tick = async () => {
970
+ if (stopped) return
971
+ try {
972
+ const names = []
973
+ for (const n of tracker.byName.keys()) {
974
+ if (tracker.isActive(n)) names.push(n)
975
+ }
976
+ if (names.length === 0) return
977
+ const procMap = await snapshotProcs()
978
+ if (procMap.size === 0) return // ps 失敗時は据え置き (spurious ready 防止)
979
+ for (const name of names) {
980
+ const root = await resolvePanePid(name, procMap)
981
+ if (!root) {
982
+ // 解決失敗は observe しない (count=0 を注入すると誤って ready が出るため)。
983
+ // SDK チャット等で tmux セッションが無いものは連続失敗で tracker から忘れる。
984
+ const k = (unresolvedStreak.get(name) ?? 0) + 1
985
+ if (k >= 8) {
986
+ tracker.forget(name)
987
+ unresolvedStreak.delete(name)
988
+ panePidByName.delete(name)
989
+ } else {
990
+ unresolvedStreak.set(name, k)
991
+ }
992
+ continue
993
+ }
994
+ unresolvedStreak.delete(name)
995
+ const count = busyChildCount(root, procMap)
996
+ const { ready } = tracker.observe(name, count)
997
+ if (ready) {
998
+ await writeSessionEventFile(name, "ready", Date.now())
999
+ }
1000
+ }
1001
+ } catch (err) {
1002
+ logger?.warn({ err: err?.message }, "readiness loop tick failed")
1003
+ }
1004
+ }
1005
+
1006
+ const ti = setInterval(tick, intervalMs)
1007
+ ti.unref?.()
1008
+ return {
1009
+ stop() {
1010
+ stopped = true
1011
+ clearInterval(ti)
1012
+ },
1013
+ }
1014
+ }
1015
+
895
1016
  /**
896
1017
  * 全 tmux session の状態を定期 capture し、変化したものだけ Hub に push する。
897
1018
  *
@@ -900,9 +1021,17 @@ async function startSessionEventWatcher({ client, logger }) {
900
1021
  * pty.exit 受信時に処理する)
901
1022
  * - tmux 自体が動いてない場合 (listSessionStates → []) は何も push しない
902
1023
  */
903
- function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
1024
+ // (i) 出力フロー検知の有効窓。ペイン出力領域の署名 (output_sig) が直近この時間内に変化して
1025
+ // いれば「出力ストリーミング中」とみなし proc_busy に OR する。state loop は 5s 周期なので、
1026
+ // 連続生成中は毎 tick 署名が変わり常時 true、出力が止まれば次 tick (≤5s) で署名不変になり
1027
+ // 窓経過後に false へ落ちる (ターン終了後のスピナー固着=症状A を悪化させない短さ)。
1028
+ const OUTPUT_ACTIVE_MS = Number(process.env.HUB_AGENT_OUTPUT_ACTIVE_MS ?? 8000)
1029
+
1030
+ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, readinessTracker }) {
904
1031
  const lastByName = new Map() // session_name → {status, context_pct}
905
1032
  const lastTurnAtByName = new Map() // session_name → 最後に event ファイル化した turnAt
1033
+ // (i) session_name → {sig, changedAt}: 出力領域署名の最終変化時刻 (出力フロー検知用)。
1034
+ const outputFlowByName = new Map()
906
1035
  let stopped = false
907
1036
 
908
1037
  // RC-8: WS 再接続のたびに差分送信の基準 (lastByName) をクリアし、次 tick で全 session.state を
@@ -969,19 +1098,41 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
969
1098
  // 内容安定で「確実に停止」と判定できたか (frontend が 2 サンプル消灯確認を省いて
970
1099
  // 即消灯するためのフラグ)。chat 信号で processing へ上書きされた場合は false。
971
1100
  const stable = status !== "processing" && s.stable === true
1101
+ // プロセス内省由来の busy レベル (Stop フック/ツール実行中=true)。frontend は
1102
+ // これでペイン由来の早期消灯を抑止し、tool/Stop フック中もスピナーを保つ。
1103
+ // 高速 readiness loop が維持する値をそのまま相乗せする (この loop では計算しない)。
1104
+ const childBusy = readinessTracker ? readinessTracker.getBusy(s.session_name) : false
1105
+ // (i) 出力フロー検知: proc 内省は pane_pid 子孫の「プロセス数」で busy を見るため、
1106
+ // ツールを起動しない純テキスト生成 (モデルが出力を流すだけ) を busy として検出できない。
1107
+ // その間に capture-pane のスピナー検出が 1 フレーム取りこぼすと frontend がアイドルと
1108
+ // 誤読してスピナーを消す (生成中なのに消える=症状B)。出力領域の署名が直近変化していれば
1109
+ // 「出力中」とみなし proc_busy に OR する。childBusy=false の純テキスト生成中も
1110
+ // proc_busy=true となり frontend のペイン由来消灯が抑止される。出力が止まれば署名が
1111
+ // 不変になり窓経過後に false へ落ちるので、ターン終了後のスピナー固着は悪化させない。
1112
+ let outputActive = false
1113
+ if (typeof s.output_sig === "string") {
1114
+ const prevFlow = outputFlowByName.get(s.session_name)
1115
+ let changedAt = prevFlow ? prevFlow.changedAt : 0
1116
+ if (!prevFlow || prevFlow.sig !== s.output_sig) changedAt = Date.now()
1117
+ outputFlowByName.set(s.session_name, { sig: s.output_sig, changedAt })
1118
+ outputActive = Date.now() - changedAt < OUTPUT_ACTIVE_MS
1119
+ }
1120
+ const procBusy = childBusy || outputActive
972
1121
  const prev = lastByName.get(s.session_name)
973
1122
  if (
974
1123
  !prev ||
975
1124
  prev.status !== status ||
976
1125
  prev.context_pct !== contextPct ||
977
1126
  prev.permission_mode !== permissionMode ||
978
- prev.stable !== stable
1127
+ prev.stable !== stable ||
1128
+ prev.proc_busy !== procBusy
979
1129
  ) {
980
1130
  lastByName.set(s.session_name, {
981
1131
  status,
982
1132
  context_pct: contextPct,
983
1133
  permission_mode: permissionMode,
984
1134
  stable,
1135
+ proc_busy: procBusy,
985
1136
  })
986
1137
  client.send({
987
1138
  type: "session.state",
@@ -990,6 +1141,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
990
1141
  context_pct: contextPct,
991
1142
  permission_mode: permissionMode,
992
1143
  stable,
1144
+ proc_busy: procBusy,
993
1145
  })
994
1146
  }
995
1147
  }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * プロセス内省による「入力可能 (ready-for-input)」判定。
3
+ *
4
+ * 背景 / なぜ画面スクレイプでなくプロセスを見るのか:
5
+ * Cockpit の TUI チャットは tmux 上の対話 `claude` を hub-agent (= 同一ホスト) が
6
+ * 仲介する。生成中/アイドルの判定はこれまで capture-pane のスピナー文字列 (state.mjs)
7
+ * に一本依存していたが、これは「Stop フック実行中」を観測できない致命的な穴がある:
8
+ * モデルのターンが終わると claude TUI はスピナー行を消し、アイドルの `❯` プロンプトを
9
+ * 描く。しかし組織のグローバル Stop フック (memory_update.py 等) はその後ろでまだ
10
+ * 走っており、入力を受け付けられる状態ではない。capture-pane はアイドルに見えるため、
11
+ * frontend がキューを早期フラッシュ → ビジーな TUI に paste → 内部キュー行きで
12
+ * バブルと jsonl の位置がズレる事故になっていた。
13
+ *
14
+ * Claude Code は「Stop フックチェーン完了 = 入力可能」を知らせるフックイベントを
15
+ * 持たず、Stop フックは並列実行のため「最後のフック」で完了を知ることもできない
16
+ * (claude-code-guide で確認, 2026-06-18)。そこで、hub-agent が claude と同一ホストに
17
+ * 居る利点を使い、claude プロセスの子プロセスを直接観測する:
18
+ * - Stop フック実行中 → フックスクリプト (python3 等) が claude の子として生存
19
+ * - ツール実行中 (Bash) → ツールが claude の子として生存
20
+ * - 真のアイドル(入力待ち) → 一過性の子は無い (常駐 MCP stdio サーバーを除く)
21
+ *
22
+ * 常駐 MCP の除外 (実機観察 2026-06-18):
23
+ * アイドルの claude は安定して 2 個の stdio MCP 子 (chrome-devtools-mcp / pencil) を
24
+ * 持つ。一過性の tool/hook はこれに上乗せされる。よって busy = 「子孫数 > 直近の
25
+ * 最小子孫数 (rolling-min ベースライン)」で判定する。ベースラインは自然に常駐 MCP の
26
+ * 数に収束し、明示的な MCP 同定は不要。
27
+ * 例外: `caffeinate` は Claude Code がスリープ抑止に spawn する -t 300 の 5 分常駐で、
28
+ * ターン後も居残るため busy 判定の子数から除外する (denylist)。
29
+ */
30
+ import { execFile } from "node:child_process"
31
+ import { promisify } from "node:util"
32
+
33
+ const execFileP = promisify(execFile)
34
+
35
+ /**
36
+ * 「busy とみなさない」background helper の comm 基底名 denylist。
37
+ * - caffeinate: 生成中のスリープ抑止に spawn される 5 分常駐 (-t 300)。ターン後も残るため
38
+ * 除外しないと proc_busy がターン後 5 分間 true のままになり ready が遅延する。
39
+ */
40
+ const BUSY_EXCLUDE_COMM = new Set(["caffeinate"])
41
+
42
+ /** パス文字列の基底名 (最後の / 以降)。空なら空文字。 */
43
+ export function commBasename(comm) {
44
+ if (!comm) return ""
45
+ const s = String(comm).trim()
46
+ const i = s.lastIndexOf("/")
47
+ return i >= 0 ? s.slice(i + 1) : s
48
+ }
49
+
50
+ /**
51
+ * `ps -axo pid=,ppid=,comm=` を 1 回 spawn してプロセス表を返す。
52
+ * comm はパスにスペースを含み得る (例: ".../Claude Helper (Renderer)") ため、
53
+ * 行頭 2 トークンを pid/ppid、残り全部を comm として扱う。
54
+ *
55
+ * @returns {Promise<Map<number, {ppid: number, comm: string}>>}
56
+ */
57
+ export async function snapshotProcs({ psBin = "ps", execFileImpl = execFileP } = {}) {
58
+ const map = new Map()
59
+ try {
60
+ const { stdout } = await execFileImpl(psBin, ["-axo", "pid=,ppid=,comm="])
61
+ for (const line of stdout.split("\n")) {
62
+ const t = line.trim()
63
+ if (!t) continue
64
+ const m = t.match(/^(\d+)\s+(\d+)\s+(.*)$/)
65
+ if (!m) continue
66
+ const pid = Number(m[1])
67
+ const ppid = Number(m[2])
68
+ if (!Number.isFinite(pid)) continue
69
+ map.set(pid, { ppid, comm: m[3] || "" })
70
+ }
71
+ } catch {
72
+ // ps 失敗時は空 Map (呼び出し側は busy 判定を据え置く)。
73
+ }
74
+ return map
75
+ }
76
+
77
+ /** procMap から ppid → 子 pid[] の逆引きを作る。 */
78
+ function buildChildren(procMap) {
79
+ const children = new Map()
80
+ for (const [pid, info] of procMap) {
81
+ const arr = children.get(info.ppid)
82
+ if (arr) arr.push(pid)
83
+ else children.set(info.ppid, [pid])
84
+ }
85
+ return children
86
+ }
87
+
88
+ /**
89
+ * rootPid の子孫 pid を BFS で集める (root 自身は含めない)。循環は visited で防ぐ。
90
+ * @returns {number[]}
91
+ */
92
+ export function descendantsOf(rootPid, procMap, children = buildChildren(procMap)) {
93
+ const out = []
94
+ const visited = new Set([rootPid])
95
+ let frontier = children.get(rootPid) || []
96
+ while (frontier.length) {
97
+ const next = []
98
+ for (const pid of frontier) {
99
+ if (visited.has(pid)) continue
100
+ visited.add(pid)
101
+ out.push(pid)
102
+ const kids = children.get(pid)
103
+ if (kids) next.push(...kids)
104
+ }
105
+ frontier = next
106
+ }
107
+ return out
108
+ }
109
+
110
+ /**
111
+ * tmux session の pane_pid を取得する (失敗時 null)。
112
+ */
113
+ export async function getPanePid(sessionName, { tmuxBin = "tmux", execFileImpl = execFileP } = {}) {
114
+ if (!sessionName || /[/\\]/.test(sessionName)) return null
115
+ try {
116
+ const { stdout } = await execFileImpl(tmuxBin, [
117
+ "display-message",
118
+ "-p",
119
+ "-t",
120
+ `${sessionName}:`,
121
+ "-F",
122
+ "#{pane_pid}",
123
+ ])
124
+ const n = Number(stdout.trim())
125
+ return Number.isFinite(n) && n > 0 ? n : null
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ /**
132
+ * pane_pid から対話 claude 本体の pid を解決する。
133
+ * cockpit の tmux ペインは shell (pane_pid) の子として `claude` を起動するため、
134
+ * pane_pid 自身か、その子孫のうち comm 基底名が 'claude' のものを返す。
135
+ * @returns {number|null}
136
+ */
137
+ export function resolveClaudePid(panePid, procMap) {
138
+ if (!panePid) return null
139
+ const self = procMap.get(panePid)
140
+ if (self && commBasename(self.comm) === "claude") return panePid
141
+ const children = buildChildren(procMap)
142
+ for (const pid of descendantsOf(panePid, procMap, children)) {
143
+ const info = procMap.get(pid)
144
+ if (info && commBasename(info.comm) === "claude") return pid
145
+ }
146
+ return null
147
+ }
148
+
149
+ /**
150
+ * rootPid (通常は tmux の pane_pid。claude pid でも可) の「busy を示す」子孫数を数える
151
+ * (denylist の background helper を除外)。常駐 MCP / claude 本体は除外しない
152
+ * (rolling-min ベースラインが吸収する)。一過性の tool/hook 子だけがカウントを押し上げる。
153
+ */
154
+ export function busyChildCount(rootPid, procMap) {
155
+ if (!rootPid) return 0
156
+ const children = buildChildren(procMap)
157
+ let n = 0
158
+ for (const pid of descendantsOf(rootPid, procMap, children)) {
159
+ const info = procMap.get(pid)
160
+ if (!info) continue
161
+ if (BUSY_EXCLUDE_COMM.has(commBasename(info.comm))) continue
162
+ n += 1
163
+ }
164
+ return n
165
+ }
166
+
167
+ /**
168
+ * セッション毎の「resident (idle) ベースライン」で busy / ready エッジを判定する純粋ステート。
169
+ *
170
+ * ❗ 旧実装の rolling-min (直近 windowMs の最小 count) は致命的なドリフトがあった:
171
+ * - 長時間ツール (>windowMs) で idle サンプルが窓から押し出され baseline がツール水準まで
172
+ * 上昇 → busy=false → 走行中に誤 ready (= 誤フラッシュ)。
173
+ * - 遅延起動の常駐 MCP で真の idle が上がっても min は古い低値に張り付き busy 固着。
174
+ * これを避けるため baseline は「ターン中は凍結 (下げのみ許可)、ターン間 idle で学習」する:
175
+ * - 非 armed (ターン間 idle / ready 直後の grace) の observe: baseline = count を学習する。
176
+ * 遅延起動 MCP 等で resident 水準が上がっても次ターンの baseline に反映される。
177
+ * - armed (ターン中) の observe: baseline は **上げない** (一過性ツールが新 idle に化けるのを
178
+ * 防ぐ)。より低い idle 床を観測したときだけ下げる (bootstrap 汚染の自己補正)。
179
+ * busy = count > baseline。よって長時間ツールでも count は baseline を超え続け busy のまま。
180
+ * - ready = stopSeen かつ busy=false が debounceSamples 連続したエッジで 1 回だけ true。
181
+ * ❗ ready を **stopSeen ゲート** する (= stop/idle_hint を受けた後のみ)。prompt_submit 直後は
182
+ * UserPromptSubmit フックの子が走っており初回 observe が baseline を汚染し得るため、ターン中の
183
+ * 誤 ready (event ファイル上書き → サイドバードット誤反転 / 本物 ready の emitted ラッチ毒)
184
+ * を構造的に防ぐ。ready は「ターン終了後の入力可能」のみ意味を持つ。
185
+ * - 安全弁 maxActiveSamples: armed のまま settle しない (遅延常駐子で busy 永続 / stop 取り
186
+ * こぼし) と isActive=true が続き ps spawn リーク + proc_busy 固着になる。上限で baseline を
187
+ * 現値へ再学習し disarm する (frontend は 120s net が畳むので表示影響なし。次ターンで再 arm)。
188
+ *
189
+ * arm(name): prompt_submit / stop / idle_hint で呼ぶ。newTurn (prompt_submit) は emitted を
190
+ * リセットし stopSeen=false に戻す。stop/idle_hint は stopSeen=true で ready 発火を解禁する。
191
+ * busy が一度も観測されなくても (Stop フックが poll 間隔より速い場合)、stop 後なら baseline
192
+ * 復帰の debounce 後に ready を出す。
193
+ */
194
+ export class ReadinessTracker {
195
+ constructor({
196
+ debounceSamples = 2,
197
+ graceSamples = 4,
198
+ // armed のまま settle しない上限サンプル数 (≈ 200×700ms ≈ 140s)。遅延常駐子で busy が
199
+ // 解けない / stop 取りこぼしで idle のまま等で armed が永続すると、isActive=true が続いて
200
+ // readiness loop が ps を 700ms 毎に spawn し続けるリーク + proc_busy 固着になる。上限で
201
+ // baseline を現値へ再学習して disarm し、リーク/固着を解消する。frontend は 120s net が
202
+ // スピナーを既に畳んでいるため表示影響は無い (次ターンで再 arm)。
203
+ maxActiveSamples = 200,
204
+ now = () => Date.now(),
205
+ } = {}) {
206
+ this.debounceSamples = Math.max(1, debounceSamples)
207
+ // ready 後しばらく観測を続けて idle baseline を学習する tick 数 (遅延起動 MCP 反映用)。
208
+ this.graceSamples = Math.max(0, graceSamples)
209
+ this.maxActiveSamples = Math.max(1, maxActiveSamples)
210
+ this._now = now
211
+ /** @type {Map<string, {baseline:number|null, armed:boolean, busy:boolean, clearStreak:number, emitted:boolean, grace:number, stopSeen:boolean, activeStreak:number}>} */
212
+ this.byName = new Map()
213
+ }
214
+
215
+ _get(name) {
216
+ let s = this.byName.get(name)
217
+ if (!s) {
218
+ s = {
219
+ baseline: null,
220
+ armed: false,
221
+ busy: false,
222
+ clearStreak: 0,
223
+ emitted: false,
224
+ grace: 0,
225
+ stopSeen: false,
226
+ activeStreak: 0,
227
+ }
228
+ this.byName.set(name, s)
229
+ }
230
+ return s
231
+ }
232
+
233
+ /**
234
+ * prompt_submit / stop / idle_hint でターン進行を宣言する。
235
+ * clearStreak を 0 に戻すことで、arm 以降に debounceSamples 回連続でアイドルを観測して
236
+ * 初めて ready を出す = stop 直後に Stop フックの子が spawn し始めるレースを吸収する。
237
+ *
238
+ * newTurn (prompt_submit): 次ターン開始。emitted リセットで ready を解禁し、stopSeen=false に
239
+ * 戻す (モデルのターンはまだ終わっていない)。baseline はターン間 idle 学習値を引き継ぐ。
240
+ * それ以外 (stop / idle_hint): モデルのターンが終わった合図。stopSeen=true にして ready 発火を
241
+ * 解禁する。ready を **stopSeen ゲート**することで、prompt_submit 直後の bootstrap 汚染
242
+ * (UserPromptSubmit フックの子を初回 observe が拾い baseline が膨らむ) でターン中に誤 ready が
243
+ * 出て event ファイルを上書きする退行を防ぐ (ready は「ターン終了後の入力可能」のみ意味を持つ)。
244
+ */
245
+ arm(name, { newTurn = false } = {}) {
246
+ const s = this._get(name)
247
+ s.armed = true
248
+ s.clearStreak = 0
249
+ s.activeStreak = 0
250
+ s.grace = 0
251
+ if (newTurn) {
252
+ s.emitted = false
253
+ s.stopSeen = false
254
+ } else {
255
+ s.stopSeen = true
256
+ }
257
+ }
258
+
259
+ /** @returns {{busy: boolean, ready: boolean}} */
260
+ observe(name, count) {
261
+ const s = this._get(name)
262
+ if (s.baseline === null) s.baseline = count // bootstrap (初回 ≈ idle)
263
+ if (!s.armed) {
264
+ // ターン間 idle / ready 直後の grace: resident baseline を学習 (上下とも追従)。
265
+ s.busy = false
266
+ s.baseline = count
267
+ if (s.grace > 0) s.grace -= 1
268
+ return { busy: false, ready: false }
269
+ }
270
+ s.activeStreak += 1
271
+ // 安全弁: armed のまま settle しない上限 (≈140s)。baseline を現値へ再学習し disarm して
272
+ // ps spawn リーク / proc_busy 固着を解消する (遅延常駐子で busy が永続するケース等)。
273
+ // ⚠️ ここは baseline=count (現 busy 値への引き上げ) であって Math.min ではない。遅延常駐子で
274
+ // 永続的に上がった count を新 idle として吸収するため上げる必要がある (Math.min にすると
275
+ // 「遅延常駐子で busy 固着」HIGH バグが再発する)。代償として、cap が長時間ツールで発火した
276
+ // 場合は次ターン頭で baseline が高めに残り proc_busy を一時的に過少報告し得る (LOW: スピナーが
277
+ // ツール中に早めに消えるだけで、ready/フラッシュ経路は無傷。次の idle 観測で line の下げ学習が
278
+ // 自己補正する)。cap 自体が 140s 無 settle という稀な劣化状態の後始末なので許容する。
279
+ if (s.activeStreak >= this.maxActiveSamples) {
280
+ s.baseline = count
281
+ s.busy = false
282
+ s.armed = false
283
+ s.grace = this.graceSamples
284
+ s.clearStreak = 0
285
+ s.activeStreak = 0
286
+ // cap = 140s armed のまま settle しない異常状態の後始末。stopSeen 済 (= モデルのターンは
287
+ // 終わっている) なら、ここで ready を 1 回だけ出して frontend のスピナー固着とキュー詰まり
288
+ // (ready 不発で turnOverride=true が畳めず drain されない) を解く。ユーザーが Claude 経由で
289
+ // dev server 等を起動しっぱなしにし、その子プロセスで count>baseline が永続して busy が
290
+ // 解けないケース (症状A の主因) を救済する。stopSeen=false (stop 取りこぼし or 本当に 140s
291
+ // 走行中) のときは出さない (生成中への誤フラッシュ防止)。
292
+ let ready = false
293
+ if (s.stopSeen && !s.emitted) {
294
+ ready = true
295
+ s.emitted = true
296
+ }
297
+ return { busy: false, ready }
298
+ }
299
+ // ターン中: baseline は上げない (一過性ツールを新 idle に化けさせない)。より低い床は学習。
300
+ if (count < s.baseline) s.baseline = count
301
+ const busy = count > s.baseline
302
+ s.busy = busy
303
+ if (busy) {
304
+ s.emitted = false
305
+ s.clearStreak = 0
306
+ return { busy: true, ready: false }
307
+ }
308
+ s.clearStreak += 1
309
+ let ready = false
310
+ // ready は stopSeen (= stop/idle_hint 受信済み = モデルのターン終了) のときだけ発火する。
311
+ if (s.stopSeen && s.clearStreak >= this.debounceSamples) {
312
+ if (!s.emitted) {
313
+ ready = true
314
+ s.emitted = true
315
+ }
316
+ // ready 済み or 今出した → armed を畳んで grace へ (idle baseline 学習を続ける)。
317
+ s.armed = false
318
+ s.grace = this.graceSamples
319
+ }
320
+ return { busy, ready }
321
+ }
322
+
323
+ getBusy(name) {
324
+ return this.byName.get(name)?.busy ?? false
325
+ }
326
+
327
+ /** readiness loop が観測対象に含めるべきか (armed / busy / grace 中)。 */
328
+ isActive(name) {
329
+ const s = this.byName.get(name)
330
+ return !!s && (s.armed || s.busy || s.grace > 0)
331
+ }
332
+
333
+ forget(name) {
334
+ this.byName.delete(name)
335
+ }
336
+ }
package/src/state.mjs CHANGED
@@ -46,11 +46,13 @@ export function invalidateSessionCache(sessionName) {
46
46
  _captureCache.clear()
47
47
  _cwdCache.clear()
48
48
  _statusGate.clear()
49
+ _spinnerFreezeByName.clear()
49
50
  return
50
51
  }
51
52
  _captureCache.delete(sessionName)
52
53
  _cwdCache.delete(sessionName)
53
54
  _statusGate.delete(sessionName)
55
+ _spinnerFreezeByName.delete(sessionName)
54
56
  }
55
57
 
56
58
  /**
@@ -185,6 +187,15 @@ function _belowIsOnlyChrome(lines, i) {
185
187
  * ペイン最下部の入力欄直上にあるライブのスピナーだけを生成中と判定する (誤点灯防止)。
186
188
  */
187
189
  function detectWorkingSpinner(text) {
190
+ return workingSpinnerLine(text) !== null
191
+ }
192
+
193
+ /**
194
+ * 作業スピナー行そのもの (経過タイマー込み) を返す。検出ロジックは detectWorkingSpinner と
195
+ * 同一だが、frozen 判定 (下記) でライブタイマーの変化を見るために行文字列を取り出す。
196
+ * 見つからなければ null。
197
+ */
198
+ function workingSpinnerLine(text) {
188
199
  const lines = text.split("\n")
189
200
  const start = Math.max(0, lines.length - 12)
190
201
  for (let i = lines.length - 1; i >= start; i--) {
@@ -192,9 +203,50 @@ function detectWorkingSpinner(text) {
192
203
  const isSpinner = SPINNER_LINE_RE.test(line)
193
204
  const isTokenFooter =
194
205
  /\btokens\b/i.test(line) && /(?:^|[\s(])\d+\s*s\b/.test(line)
195
- if ((isSpinner || isTokenFooter) && _belowIsOnlyChrome(lines, i)) return true
206
+ if ((isSpinner || isTokenFooter) && _belowIsOnlyChrome(lines, i)) {
207
+ return line.trim()
208
+ }
196
209
  }
197
- return false
210
+ return null
211
+ }
212
+
213
+ /**
214
+ * 「スピナーは画面に在るが、アニメ (経過タイマー) が止まっている = 凍結」を検出する。
215
+ *
216
+ * claude が異常終了 / ハングするとスピナー行が経過タイマー付きで画面に残るが、タイマーは
217
+ * 進まない。capture-pane の単フレーム判定 (detectWorkingSpinner) はこれを「生成中」と誤読し、
218
+ * 三点リーダーが永久に消えない (ユーザー報告の主症状の一つ)。プロセス内省は claude 自体が
219
+ * 死んでいると ready を出せない (子プロセスも無いが arm もされない) ため、この frozen 判定が
220
+ * 画面側の最後の保険になる。
221
+ *
222
+ * ライブのスピナーはタイマーが毎秒進むため、state loop の各 tick (5s 間隔・capture は都度
223
+ * 新規) では必ず行文字列が変化する。よって「同一スピナー行が SPINNER_FREEZE_CONFIRM_MS 以上
224
+ * 不変」なら凍結とみなす。閾値は capture キャッシュ TTL (2.5s) と state loop 周期 (5s) を跨ぐ
225
+ * 6s に取り、ライブタイマーの 1 サンプル取りこぼしでは誤判定しないようにする。
226
+ */
227
+ const SPINNER_FREEZE_CONFIRM_MS = Number(
228
+ process.env.HUB_AGENT_SPINNER_FREEZE_MS ?? 6000,
229
+ )
230
+ /** @type {Map<string, {line: string, at: number}>} session名 → 最初にその行を見た時刻 */
231
+ const _spinnerFreezeByName = new Map()
232
+
233
+ export function isSpinnerFrozen(sessionName, text, now = Date.now()) {
234
+ const line = workingSpinnerLine(text)
235
+ if (!line) {
236
+ _spinnerFreezeByName.delete(sessionName)
237
+ return false
238
+ }
239
+ const prev = _spinnerFreezeByName.get(sessionName)
240
+ if (!prev || prev.line !== line) {
241
+ _spinnerFreezeByName.set(sessionName, { line, at: now })
242
+ return false
243
+ }
244
+ return now - prev.at >= SPINNER_FREEZE_CONFIRM_MS
245
+ }
246
+
247
+ /** テスト用: 凍結判定の状態を空にする。 */
248
+ export function _resetSpinnerFreeze() {
249
+ _spinnerFreezeByName.clear()
198
250
  }
199
251
 
200
252
  export function detectStatusFromText(text) {
@@ -479,7 +531,7 @@ const _stabilityByName = new Map() // session_name → { sig, since }
479
531
 
480
532
  /** ペインの出力領域 (入力欄・揮発フッターを除いた上側) の軽量シグネチャ (FNV-1a 32bit)。
481
533
  * 末尾 8 行 (入力欄 + スピナー経過秒/ヒント等) を除くので、idle 中は連続 capture が一致する。 */
482
- function _outputSignature(text) {
534
+ export function _outputSignature(text) {
483
535
  const lines = text.split("\n")
484
536
  const body = lines.slice(0, Math.max(0, lines.length - 8)).join("\n")
485
537
  let h = 0x811c9dc5
@@ -515,7 +567,18 @@ function _computeStable(sessionName, status, text) {
515
567
 
516
568
  export async function detectSessionState(sessionName, opts = {}) {
517
569
  const text = await capturePane(sessionName, opts)
518
- const defaultStatus = detectStatusFromText(text)
570
+ let defaultStatus = detectStatusFromText(text)
571
+ // 凍結スピナー対策: スピナーが画面に在るが経過タイマーが進んでいない (claude 異常終了/ハング)
572
+ // 場合、生成中ではないので processing を取り下げる。"esc to interrupt" がある間は別のライブ
573
+ // シグナルなので対象外。frozen と判定したらスピナーを無視した素の判定 (❯→waiting / else idle)。
574
+ if (
575
+ defaultStatus === "processing" &&
576
+ !/esc to interrupt/i.test(text) &&
577
+ isSpinnerFrozen(sessionName, text)
578
+ ) {
579
+ defaultStatus =
580
+ /❯\s/.test(text) || /^>\s/m.test(text) ? "waiting" : "idle"
581
+ }
519
582
  const defaultContextPct = detectContextPctFromText(text)
520
583
  const defaultPermissionMode = detectPermissionModeFromText(text)
521
584
 
@@ -537,6 +600,10 @@ export async function detectSessionState(sessionName, opts = {}) {
537
600
  permission_mode:
538
601
  hookResult.result.permission_mode ?? defaultPermissionMode,
539
602
  stable: _computeStable(sessionName, status, text),
603
+ // 出力領域 (揮発フッター除外) の署名。state loop が tick 間で比較し、純テキスト
604
+ // 生成 (子プロセスを spawn しないため proc 内省が busy=false と見るケース) を
605
+ // 「出力フロー中=busy」として検出するのに使う。
606
+ output_sig: _outputSignature(text),
540
607
  }
541
608
  }
542
609
  }
@@ -547,6 +614,7 @@ export async function detectSessionState(sessionName, opts = {}) {
547
614
  context_pct: defaultContextPct,
548
615
  permission_mode: defaultPermissionMode,
549
616
  stable: _computeStable(sessionName, status, text),
617
+ output_sig: _outputSignature(text),
550
618
  }
551
619
  }
552
620