@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 +1 -1
- package/src/main.mjs +157 -5
- package/src/proc-introspect.mjs +336 -0
- package/src/state.mjs +72 -4
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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))
|
|
206
|
+
if ((isSpinner || isTokenFooter) && _belowIsOnlyChrome(lines, i)) {
|
|
207
|
+
return line.trim()
|
|
208
|
+
}
|
|
196
209
|
}
|
|
197
|
-
return
|
|
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
|
-
|
|
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
|
|