@cocorograph/hub-agent 0.8.83 → 0.8.84
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 +21 -1051
- package/src/proc-introspect.mjs +0 -20
- package/src/sibling-inject.mjs +31 -0
- package/src/tmux.mjs +7 -723
- package/src/pane-cli-flag.mjs +0 -30
- package/src/state.mjs +0 -595
package/src/pane-cli-flag.mjs
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 「tmux pane に CLI (claude / codex) の TUI を起動しているか」の単一判定。
|
|
3
|
-
*
|
|
4
|
-
* この 1 フラグが tmux.mjs (pane で TUI を起動するか) と state.mjs (pane を
|
|
5
|
-
* capture-pane で読む意味があるか) の両方の分岐条件になるため、どちらからも
|
|
6
|
-
* import できる依存の無い最小モジュールに切り出してある。tmux.mjs 側に置いたまま
|
|
7
|
-
* state.mjs から import すると tmux.mjs → state.mjs → tmux.mjs の循環になる。
|
|
8
|
-
*
|
|
9
|
-
* 後方互換のため `tmux.mjs` からも re-export している (既存の import 先を壊さない)。
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* tmux pane で CLI (claude / codex) の TUI を起動してよいかを判定する。
|
|
14
|
-
*
|
|
15
|
-
* **既定は false**(pane は素のシェルのまま)。Cockpit のターミナルモードは
|
|
16
|
-
* 2026-07-03 に封印済みで、通常運用は常にチャットモード (Agent SDK / codex
|
|
17
|
-
* app-server) になった。それでも createSession は pane で TUI を起動し続けていたため、
|
|
18
|
-
* **誰も見ていない CLI が常駐**する。実測 (2026-08-01 中村さん端末): tmux セッション
|
|
19
|
-
* 33 個に対して pane の `claude` プロセス 31 個・合計 RSS 1.7 GB。
|
|
20
|
-
*
|
|
21
|
-
* `HUB_AGENT_TMUX_LAUNCH_CLI=1` のときだけ従来どおり起動する(デバッグ・TUI を
|
|
22
|
-
* 意図的に使いたいときの逃げ道)。呼び出し元ごとに挙動を変えないため、判定は
|
|
23
|
-
* この関数 1 箇所に集約し、opts では上書きさせない。
|
|
24
|
-
*
|
|
25
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
26
|
-
* @returns {boolean}
|
|
27
|
-
*/
|
|
28
|
-
export function shouldLaunchPaneCli(env = process.env) {
|
|
29
|
-
return env?.HUB_AGENT_TMUX_LAUNCH_CLI === "1"
|
|
30
|
-
}
|
package/src/state.mjs
DELETED
|
@@ -1,595 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* tmux session の Claude Code TUI 状態を判定する (Sprint H part 2)。
|
|
3
|
-
*
|
|
4
|
-
* - listSessionNames(): `tmux list-sessions -F '#S'` で session 名一覧
|
|
5
|
-
* - detectStatus(sessionName): `tmux capture-pane -p -t name -S -30 -E -` で末尾を取得
|
|
6
|
-
* → "esc to interrupt" → processing / "❯ " or "> " → waiting / それ以外 → idle
|
|
7
|
-
* - detectContextPct(text): pane 末尾から "X% context left" / "X% until auto-compact"
|
|
8
|
-
* 等のパターンを拾う (見つからなければ null)
|
|
9
|
-
* - plugin `transformStatusDetection` hook を chain して結果差し替え可
|
|
10
|
-
*
|
|
11
|
-
* ⚠️ **既定では pane を読まない** (2026-08-01)。Cockpit のターミナルモード封印に伴い
|
|
12
|
-
* pane に CLI の TUI を起動しなくなった (`shouldLaunchPaneCli()` 既定 false) ため、
|
|
13
|
-
* capture-pane は「素のシェルの画面」を読むだけで、判定結果は退化した定値にしかならない。
|
|
14
|
-
* detectSessionState はその設定を見て capture-pane 自体をスキップする。詳細は同関数の JSDoc。
|
|
15
|
-
*
|
|
16
|
-
* 移植元: D00000_cockpit/webapp/lib/tmux.ts (getSessionStatus 部分)
|
|
17
|
-
* 仕様書: ナレッジ/インフラ/cockpit-hub-hosted-integration-spec (id=6080)
|
|
18
|
-
*/
|
|
19
|
-
import { execFile } from "node:child_process"
|
|
20
|
-
import { promisify } from "node:util"
|
|
21
|
-
|
|
22
|
-
import { runHookChain } from "./plugin-loader.mjs"
|
|
23
|
-
import { shouldLaunchPaneCli } from "./pane-cli-flag.mjs"
|
|
24
|
-
|
|
25
|
-
const execFileP = promisify(execFile)
|
|
26
|
-
|
|
27
|
-
const STATUSES = Object.freeze(["processing", "waiting", "idle"])
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* P1: tmux プロセス fork 削減用の短期/長期キャッシュ。
|
|
31
|
-
*
|
|
32
|
-
* 5s 周期の state loop が N セッション毎に capture-pane (pane scrape) +
|
|
33
|
-
* display-message (cwd) を spawn し、list_sessions も同様に二重取得していた。
|
|
34
|
-
* N セッションで毎 5s に 2N+1 プロセスを fork する負荷を、以下のキャッシュで抑える:
|
|
35
|
-
* - capture-pane: 短期キャッシュ (CAPTURE_TTL_MS < state loop 周期)。state loop と
|
|
36
|
-
* 近接時刻の list_sessions が同じ pane scrape を共有する (状態検出の鮮度は維持)。
|
|
37
|
-
* - getSessionCwd: cwd は変化が稀なので長期キャッシュ (CWD_TTL_MS)。毎 tick の
|
|
38
|
-
* display-message spawn を排除し fork 数を半減させる。
|
|
39
|
-
* TTL は env で上書き可能 (テスト/チューニング用)。0 を渡すとキャッシュ無効。
|
|
40
|
-
*/
|
|
41
|
-
const CAPTURE_TTL_MS = Number(process.env.HUB_AGENT_CAPTURE_TTL_MS ?? 2500)
|
|
42
|
-
const CWD_TTL_MS = Number(process.env.HUB_AGENT_CWD_TTL_MS ?? 60000)
|
|
43
|
-
|
|
44
|
-
/** @type {Map<string, {at: number, value: string}>} session名 → capture-pane 結果 */
|
|
45
|
-
const _captureCache = new Map()
|
|
46
|
-
/** @type {Map<string, {at: number, value: string|null}>} session名 → cwd */
|
|
47
|
-
const _cwdCache = new Map()
|
|
48
|
-
|
|
49
|
-
/** キャッシュから無効化する (session 終了時等に呼べるよう export)。 */
|
|
50
|
-
export function invalidateSessionCache(sessionName) {
|
|
51
|
-
if (sessionName == null) {
|
|
52
|
-
_captureCache.clear()
|
|
53
|
-
_cwdCache.clear()
|
|
54
|
-
_statusGate.clear()
|
|
55
|
-
_spinnerFreezeByName.clear()
|
|
56
|
-
_stabilityByName.clear()
|
|
57
|
-
return
|
|
58
|
-
}
|
|
59
|
-
_captureCache.delete(sessionName)
|
|
60
|
-
_cwdCache.delete(sessionName)
|
|
61
|
-
_statusGate.delete(sessionName)
|
|
62
|
-
_spinnerFreezeByName.delete(sessionName)
|
|
63
|
-
// _stabilityByName も per-session で消す (消滅セッションの GC 漏れ=メモリリーク対策, perf監査)。
|
|
64
|
-
_stabilityByName.delete(sessionName)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* processing → waiting/idle の「降格」だけ独立 2 サンプルで確定するヒステリシス。
|
|
69
|
-
*
|
|
70
|
-
* capture-pane は claude TUI の再描画途中 (フッター消去〜再描画の間) を拾うことが
|
|
71
|
-
* あり、生成中なのに一瞬 waiting/idle と誤読する。この単発の誤読が frontend の
|
|
72
|
-
* ターン表示 (三点リーダー) やステータスドットを消す方向に直撃するため、降格は
|
|
73
|
-
* 「同じ非 processing 値が STATUS_DOWNGRADE_CONFIRM_MS 以上あけて 2 回読めた」
|
|
74
|
-
* ときだけ確定し、それまでは processing を維持する。
|
|
75
|
-
* - 昇格 (→ processing) は即時 (誤読で点く分には次サンプルで自然回復し、実害が小さい)
|
|
76
|
-
* - 確定窓は capture キャッシュ TTL (CAPTURE_TTL_MS=2.5s) より長く取り、同一
|
|
77
|
-
* キャッシュサンプルの 2 回読みで誤確定しないようにする
|
|
78
|
-
* - 実ターン終了の体感は frontend の hook 由来 session.event 'stop' が即時に担う
|
|
79
|
-
* ため、ここで数秒遅れてもサイドバードットが ~5s 遅れるだけで UX 影響は無い
|
|
80
|
-
*/
|
|
81
|
-
const STATUS_DOWNGRADE_CONFIRM_MS = Number(
|
|
82
|
-
process.env.HUB_AGENT_STATUS_DOWNGRADE_MS ?? 3000,
|
|
83
|
-
)
|
|
84
|
-
/** @type {Map<string, {lastConfirmed: string|null, pending: {status: string, at: number}|null}>} */
|
|
85
|
-
const _statusGate = new Map()
|
|
86
|
-
|
|
87
|
-
export function debounceStatusDowngrade(sessionName, rawStatus, now = Date.now()) {
|
|
88
|
-
if (!sessionName) return rawStatus
|
|
89
|
-
const g = _statusGate.get(sessionName) || { lastConfirmed: null, pending: null }
|
|
90
|
-
// processing への昇格 / processing 以外からの遷移は即時確定。
|
|
91
|
-
if (rawStatus === "processing" || g.lastConfirmed !== "processing") {
|
|
92
|
-
g.lastConfirmed = rawStatus
|
|
93
|
-
g.pending = null
|
|
94
|
-
_statusGate.set(sessionName, g)
|
|
95
|
-
return rawStatus
|
|
96
|
-
}
|
|
97
|
-
// lastConfirmed=processing からの降格要求: 同値の 2 サンプル目 (確定窓経過後) で確定。
|
|
98
|
-
if (
|
|
99
|
-
g.pending &&
|
|
100
|
-
g.pending.status === rawStatus &&
|
|
101
|
-
now - g.pending.at >= STATUS_DOWNGRADE_CONFIRM_MS
|
|
102
|
-
) {
|
|
103
|
-
g.lastConfirmed = rawStatus
|
|
104
|
-
g.pending = null
|
|
105
|
-
_statusGate.set(sessionName, g)
|
|
106
|
-
return rawStatus
|
|
107
|
-
}
|
|
108
|
-
if (!g.pending || g.pending.status !== rawStatus) {
|
|
109
|
-
g.pending = { status: rawStatus, at: now }
|
|
110
|
-
}
|
|
111
|
-
_statusGate.set(sessionName, g)
|
|
112
|
-
return "processing"
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** テスト用: 降格ヒステリシスの状態を空にする。 */
|
|
116
|
-
export function _resetStatusGate() {
|
|
117
|
-
_statusGate.clear()
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const CONTEXT_PATTERNS = [
|
|
121
|
-
/(\d{1,3})\s*%\s*context\s*left/i,
|
|
122
|
-
/context\s*[:\-]?\s*(\d{1,3})\s*%/i,
|
|
123
|
-
/(\d{1,3})\s*%\s*until\s*auto[-\s]?compact/i,
|
|
124
|
-
]
|
|
125
|
-
|
|
126
|
-
function stripAnsi(s) {
|
|
127
|
-
// ANSI CSI / OSC / charset switch を削る
|
|
128
|
-
return s
|
|
129
|
-
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "")
|
|
130
|
-
.replace(/\x1b\][^\x07]*\x07/g, "")
|
|
131
|
-
.replace(/\x1b[()][AB012]/g, "")
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* 作業スピナー行 (ライブステータス行) の構造パターン。実機採取 (2026-06-15〜):
|
|
136
|
-
* "· Blanching… (2m 4s · ↓ 7.5k tokens)" (出力中)
|
|
137
|
-
* "✽ Scurrying… (16m 55s · ↓ 66.8k tokens)" (出力中)
|
|
138
|
-
* "· Puzzling… (11m 12s · thinking)" (思考中・tokens 語が無い)
|
|
139
|
-
* "✻ Thinking (3s)" (省略記号なし・タイマーのみ)
|
|
140
|
-
* "· Puzzling (2m · thinking)" (省略記号なし・分のみ秒省略)
|
|
141
|
-
* = 行頭グリフ (·/✻/✽/✶… 1 文字) + 語 + 〔省略記号 または 括弧ライブタイマー〕の少なくとも一方。
|
|
142
|
-
*
|
|
143
|
-
* 緩和の経緯 (2026-06-19): 旧版は「省略記号 **かつ** 括弧タイマー **かつ** 秒必須」を全要求して
|
|
144
|
-
* いたため、claude TUI の版/フェーズで書式が少しでも変わると processing を取りこぼし、生成中なのに
|
|
145
|
-
* 三点リーダーが消える主因になっていた (実機相当の "✻ Thinking…"(タイマー無) / "✻ Thinking (3s)"
|
|
146
|
-
* (省略記号無) / "(2m · thinking)"(分のみ秒無) / 深いインデント が全て NOMATCH と実証)。そこで:
|
|
147
|
-
* - 省略記号 (…/...) **または** 括弧タイマーのどちらか一方で可 (両方必須をやめる)
|
|
148
|
-
* - タイマーは「分のみ」「秒のみ」「分秒」いずれも可 (秒必須をやめる)
|
|
149
|
-
* - 行頭インデント制限 (\s{0,4}) を撤廃 (背の高いペイン/折返しで字下げが深い版に備える)
|
|
150
|
-
* 緩めても安全な理由: 検出は必ず capture 末尾 12 行への限定 (workingSpinnerLine) と
|
|
151
|
-
* _belowIsOnlyChrome (下が入力欄チロムのみ) を通すため、本文プローズ・表セル・箇条書きへの
|
|
152
|
-
* 誤点灯は下チロム判定が弾く。行頭は英数字・空白・本文記号 (-*>#|) を除く 1 文字に限定し、
|
|
153
|
-
* 語は ASCII 英字に限る (日本語プローズは非マッチ)。
|
|
154
|
-
* 完了サマリー "✻ Brewed for 2m 52s" は省略記号も**括弧**タイマーも持たない (タイマーが括弧外) ため
|
|
155
|
-
* 引き続き一致しない。
|
|
156
|
-
*/
|
|
157
|
-
const SPINNER_LINE_RE =
|
|
158
|
-
/^\s*[^\sA-Za-z0-9\-*>#|]\s*[A-Za-z]+(?:\s+[A-Za-z]+)*\s*(?:(?:…|\.\.\.)|\(\s*(?:\d+\s*m(?:\s*\d+\s*s)?|\d+\s*s)\b)/
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* スピナー行 i の「下」が入力欄チロム (空行/罫線/❯ プロンプト/tips/字下げ継続/権限バナー)
|
|
162
|
-
* のみかを判定する。ライブのスピナーはペイン最下部の入力欄直上に描かれ、下には本文行が
|
|
163
|
-
* 来ない。一方、本文中に書かれた「スピナー行の引用」(解説テキスト・表セル・コードブロック等)
|
|
164
|
-
* は下に列 0 の本文行が続く。これを使って「未生成なのにローダーが点灯する」誤検出を防ぐ。
|
|
165
|
-
* 列 0 の本文行が下に在れば引用とみなし false。
|
|
166
|
-
*/
|
|
167
|
-
function _belowIsOnlyChrome(lines, i) {
|
|
168
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
169
|
-
const ln = lines[j]
|
|
170
|
-
if (ln.trim() === "") continue // 空行
|
|
171
|
-
// 入力欄の罫線。純粋なダッシュ列 (────) に加え、ラベル付き罫線 (────── ultracode ─ /
|
|
172
|
-
// ──── plan mode ─ 等、effort/モード表示が入る入力欄上端) も chrome 扱いにする。末尾 $ を
|
|
173
|
-
// 外し「行頭が 3+ ボックス罫線文字」で判定する。実機 (2026-06-19): ultracode 表示で入力欄上端が
|
|
174
|
-
// "────── ultracode ─" になり純ダッシュ判定を外れ、これを本文行と誤認 → ライブスピナーを
|
|
175
|
-
// 「本文の引用」と誤判定 → 生成中なのに status=waiting に落ちる事象を確認 (三点リーダー消失の実主因)。
|
|
176
|
-
if (/^\s*─{3,}/.test(ln)) continue
|
|
177
|
-
if (/^\s*[❯>]/.test(ln)) continue // 入力プロンプト
|
|
178
|
-
if (/^\s*⎿/.test(ln)) continue // tips / ツリー装飾行
|
|
179
|
-
if (/^\s{4,}\S/.test(ln)) continue // tips 折り返し等の字下げ継続
|
|
180
|
-
if (
|
|
181
|
-
/shift\+tab|← for|for shortcuts|accept edits|plan mode|auto mode|bypass permissions|new task\?|to save|auto-compact|context left/i.test(
|
|
182
|
-
ln,
|
|
183
|
-
)
|
|
184
|
-
)
|
|
185
|
-
continue // 権限バナー / フッターのヒント
|
|
186
|
-
return false // 列 0 の本文行 = スピナー行の引用とみなす
|
|
187
|
-
}
|
|
188
|
-
return true
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* 作業スピナーのフッター行を検出する (生成中=processing の補助シグナル)。
|
|
193
|
-
*
|
|
194
|
-
* 現行版は "esc to interrupt" をフッターに出さない (主シグナルが実機で死んでいる) ため、
|
|
195
|
-
* これが生成中検出の実効シグナルになる。フッター領域 (末尾 12 行) を下から走査し、次の
|
|
196
|
-
* いずれかに一致しかつ「下が入力欄チロムのみ (本文行の引用でない)」行があれば生成中とみなす:
|
|
197
|
-
* (a) スピナー構造行 (SPINNER_LINE_RE)。tokens の有無に依存しないので thinking も拾う。
|
|
198
|
-
* (b) "tokens" 語 + 経過秒の同一行同居 (回帰防止。"(↑ 3.4k tokens · 7s)" の語順も拾う)。
|
|
199
|
-
*
|
|
200
|
-
* 下チロム判定 (_belowIsOnlyChrome) が肝: スピナー行の構造は、本文に「生成中フッターは
|
|
201
|
-
* … (Ns) です」と書いた引用や表セルにも現れ得る。それらは下に本文行が続くため除外し、
|
|
202
|
-
* ペイン最下部の入力欄直上にあるライブのスピナーだけを生成中と判定する (誤点灯防止)。
|
|
203
|
-
*/
|
|
204
|
-
function detectWorkingSpinner(text) {
|
|
205
|
-
return workingSpinnerLine(text) !== null
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* 作業スピナー行そのもの (経過タイマー込み) を返す。検出ロジックは detectWorkingSpinner と
|
|
210
|
-
* 同一だが、frozen 判定 (下記) でライブタイマーの変化を見るために行文字列を取り出す。
|
|
211
|
-
* 見つからなければ null。
|
|
212
|
-
*/
|
|
213
|
-
function workingSpinnerLine(text) {
|
|
214
|
-
const lines = text.split("\n")
|
|
215
|
-
const start = Math.max(0, lines.length - 12)
|
|
216
|
-
for (let i = lines.length - 1; i >= start; i--) {
|
|
217
|
-
const line = lines[i]
|
|
218
|
-
const isSpinner = SPINNER_LINE_RE.test(line)
|
|
219
|
-
const isTokenFooter =
|
|
220
|
-
/\btokens\b/i.test(line) && /(?:^|[\s(])\d+\s*s\b/.test(line)
|
|
221
|
-
if ((isSpinner || isTokenFooter) && _belowIsOnlyChrome(lines, i)) {
|
|
222
|
-
return line.trim()
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
return null
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* 「スピナーは画面に在るが、アニメ (経過タイマー) が止まっている = 凍結」を検出する。
|
|
230
|
-
*
|
|
231
|
-
* claude が異常終了 / ハングするとスピナー行が経過タイマー付きで画面に残るが、タイマーは
|
|
232
|
-
* 進まない。capture-pane の単フレーム判定 (detectWorkingSpinner) はこれを「生成中」と誤読し、
|
|
233
|
-
* 三点リーダーが永久に消えない (ユーザー報告の主症状の一つ)。プロセス内省は claude 自体が
|
|
234
|
-
* 死んでいると ready を出せない (子プロセスも無いが arm もされない) ため、この frozen 判定が
|
|
235
|
-
* 画面側の最後の保険になる。
|
|
236
|
-
*
|
|
237
|
-
* ライブのスピナーはタイマーが毎秒進むため、state loop の各 tick (5s 間隔・capture は都度
|
|
238
|
-
* 新規) では必ず行文字列が変化する。よって「同一スピナー行が SPINNER_FREEZE_CONFIRM_MS 以上
|
|
239
|
-
* 不変」なら凍結とみなす。閾値は capture キャッシュ TTL (2.5s) と state loop 周期 (5s) を跨ぐ
|
|
240
|
-
* 6s に取り、ライブタイマーの 1 サンプル取りこぼしでは誤判定しないようにする。
|
|
241
|
-
*/
|
|
242
|
-
// 静的な完了行 (● Updated…/⏺ Ran tool (2s) 等、ターン終了後もアイドル画面に残る) を
|
|
243
|
-
// SPINNER_LINE_RE が誤検出して processing に張り付く「無生成時の三点リーダー誤点灯」(問題1) の
|
|
244
|
-
// 滞留時間を短縮するため 6000→3500 に下げた (2026-06-22)。実スピナーは経過秒 (Ns) かグリフ
|
|
245
|
-
// アニメで毎秒変化するため frozen には絶対ならず、閾値短縮で偽陰性(生成中の消失)は再発しない
|
|
246
|
-
// (3500ms ≫ 1s アニメ周期で十分な余裕)。env で上書き可。
|
|
247
|
-
const SPINNER_FREEZE_CONFIRM_MS = Number(
|
|
248
|
-
process.env.HUB_AGENT_SPINNER_FREEZE_MS ?? 3500,
|
|
249
|
-
)
|
|
250
|
-
/** @type {Map<string, {line: string, at: number}>} session名 → 最初にその行を見た時刻 */
|
|
251
|
-
const _spinnerFreezeByName = new Map()
|
|
252
|
-
|
|
253
|
-
export function isSpinnerFrozen(sessionName, text, now = Date.now()) {
|
|
254
|
-
const line = workingSpinnerLine(text)
|
|
255
|
-
if (!line) {
|
|
256
|
-
_spinnerFreezeByName.delete(sessionName)
|
|
257
|
-
return false
|
|
258
|
-
}
|
|
259
|
-
const prev = _spinnerFreezeByName.get(sessionName)
|
|
260
|
-
if (!prev || prev.line !== line) {
|
|
261
|
-
_spinnerFreezeByName.set(sessionName, { line, at: now })
|
|
262
|
-
return false
|
|
263
|
-
}
|
|
264
|
-
return now - prev.at >= SPINNER_FREEZE_CONFIRM_MS
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** テスト用: 凍結判定の状態を空にする。 */
|
|
268
|
-
export function _resetSpinnerFreeze() {
|
|
269
|
-
_spinnerFreezeByName.clear()
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export function detectStatusFromText(text) {
|
|
273
|
-
// 主シグナル: 中断フッター (従来どおり全体一致。回帰防止のため範囲を狭めない)。
|
|
274
|
-
if (/esc to interrupt/i.test(text)) return "processing"
|
|
275
|
-
// 補助シグナル: 作業スピナーの「経過秒 + トークンカウンタ」(フッター領域・同一行限定)。
|
|
276
|
-
if (detectWorkingSpinner(text)) return "processing"
|
|
277
|
-
if (/❯\s/.test(text) || /^>\s/m.test(text)) return "waiting"
|
|
278
|
-
return "idle"
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
export function detectContextPctFromText(text) {
|
|
282
|
-
for (const re of CONTEXT_PATTERNS) {
|
|
283
|
-
const m = text.match(re)
|
|
284
|
-
if (m) {
|
|
285
|
-
const n = Number(m[1])
|
|
286
|
-
if (Number.isFinite(n) && n >= 0 && n <= 100) return n
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
// Claude の初回 Chrome 連携確認は、TUI セッションがまだ会話を開始していないことを
|
|
290
|
-
// 明示する画面。context 行は未描画だが使用量は実際に 0% なので、同一 cwd に残った
|
|
291
|
-
// 過去 jsonl の高使用率を採用せず 0% を返す。文言の一部だけで誤認しないよう、製品名と
|
|
292
|
-
// 選択肢を対にして判定する。
|
|
293
|
-
if (
|
|
294
|
-
/Claude in Chrome extension detected/i.test(text) &&
|
|
295
|
-
/Yes,? use my browser/i.test(text)
|
|
296
|
-
) {
|
|
297
|
-
return 0
|
|
298
|
-
}
|
|
299
|
-
return null
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
export async function listSessionNames(opts = {}) {
|
|
303
|
-
const tmuxBin = opts.tmuxBin || "tmux"
|
|
304
|
-
try {
|
|
305
|
-
const { stdout } = await execFileP(tmuxBin, ["list-sessions", "-F", "#S"])
|
|
306
|
-
return stdout
|
|
307
|
-
.split("\n")
|
|
308
|
-
.map((s) => s.trim())
|
|
309
|
-
.filter(Boolean)
|
|
310
|
-
} catch {
|
|
311
|
-
return []
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export async function capturePane(sessionName, opts = {}) {
|
|
316
|
-
const tmuxBin = opts.tmuxBin || "tmux"
|
|
317
|
-
// P1: 短期キャッシュ。state loop と近接時刻の list_sessions の二重 capture-pane を
|
|
318
|
-
// 同一結果で共有する。opts.noCache か TTL=0 でバイパス可能。
|
|
319
|
-
if (!opts.noCache && CAPTURE_TTL_MS > 0) {
|
|
320
|
-
const hit = _captureCache.get(sessionName)
|
|
321
|
-
if (hit && Date.now() - hit.at < CAPTURE_TTL_MS) return hit.value
|
|
322
|
-
}
|
|
323
|
-
try {
|
|
324
|
-
const { stdout } = await execFileP(tmuxBin, [
|
|
325
|
-
"capture-pane",
|
|
326
|
-
"-p",
|
|
327
|
-
"-t",
|
|
328
|
-
sessionName,
|
|
329
|
-
// 末尾 50 行を取得 (旧 30 行)。copy-mode スクロールや背の高いペインで作業/中断
|
|
330
|
-
// フッターが取得窓外に出て生成中を取りこぼす偽陰性を減らす。status 判定の作業
|
|
331
|
-
// スピナー検出はこのうち末尾 12 行 (workingSpinnerLine) に限定する。
|
|
332
|
-
"-S",
|
|
333
|
-
"-50",
|
|
334
|
-
"-E",
|
|
335
|
-
"-",
|
|
336
|
-
])
|
|
337
|
-
const value = stripAnsi(stdout)
|
|
338
|
-
if (!opts.noCache && CAPTURE_TTL_MS > 0) {
|
|
339
|
-
_captureCache.set(sessionName, { at: Date.now(), value })
|
|
340
|
-
}
|
|
341
|
-
return value
|
|
342
|
-
} catch {
|
|
343
|
-
return ""
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* tmux session の active pane の current_path を取得する (chat 信号との cwd 照合用)。
|
|
349
|
-
* 取れなければ null。
|
|
350
|
-
*/
|
|
351
|
-
export async function getSessionCwd(sessionName, opts = {}) {
|
|
352
|
-
const tmuxBin = opts.tmuxBin || "tmux"
|
|
353
|
-
// P1: cwd は変化が稀なので長期キャッシュ。state loop の毎 tick spawn を排除する。
|
|
354
|
-
// opts.noCache か TTL=0 でバイパス可能。cwd 変化検知は CWD_TTL_MS 経過後の再取得で吸収。
|
|
355
|
-
if (!opts.noCache && CWD_TTL_MS > 0) {
|
|
356
|
-
const hit = _cwdCache.get(sessionName)
|
|
357
|
-
if (hit && Date.now() - hit.at < CWD_TTL_MS) return hit.value
|
|
358
|
-
}
|
|
359
|
-
try {
|
|
360
|
-
const { stdout } = await execFileP(tmuxBin, [
|
|
361
|
-
"display-message",
|
|
362
|
-
"-p",
|
|
363
|
-
"-t",
|
|
364
|
-
`${sessionName}:`,
|
|
365
|
-
"-F",
|
|
366
|
-
"#{pane_current_path}",
|
|
367
|
-
])
|
|
368
|
-
const s = stdout.trim()
|
|
369
|
-
const value = s || null
|
|
370
|
-
if (!opts.noCache && CWD_TTL_MS > 0) {
|
|
371
|
-
_cwdCache.set(sessionName, { at: Date.now(), value })
|
|
372
|
-
}
|
|
373
|
-
return value
|
|
374
|
-
} catch {
|
|
375
|
-
return null
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* 1 session の現在状態を取得する。plugin hook で上書き可。
|
|
381
|
-
*
|
|
382
|
-
* @returns {Promise<{status: string, context_pct: number | null}>}
|
|
383
|
-
*/
|
|
384
|
-
/** ペイン内容が一定時間不変なら「確実に停止 (idle/waiting)」とみなす確定窓。frontend は
|
|
385
|
-
* この stable=true を受けると 2 サンプル消灯確認 (paneOffConfirmed) を省いて即消灯できる。
|
|
386
|
-
* ローダー無しでも本文がストリーミング中 (= 出力領域が変化) なら stable にならないので、
|
|
387
|
-
* 「文字出力中はローダーが出ない」ケースでも誤って停止扱いしない。 */
|
|
388
|
-
const STABLE_CONFIRM_MS = Number(process.env.HUB_AGENT_STABLE_CONFIRM_MS ?? 3000)
|
|
389
|
-
const _stabilityByName = new Map() // session_name → { sig, since }
|
|
390
|
-
|
|
391
|
-
/** ペインの出力領域 (入力欄・揮発フッターを除いた上側) の軽量シグネチャ (FNV-1a 32bit)。
|
|
392
|
-
* 末尾 8 行 (入力欄 + スピナー経過秒/ヒント等) を除くので、idle 中は連続 capture が一致する。 */
|
|
393
|
-
export function _outputSignature(text) {
|
|
394
|
-
const lines = text.split("\n")
|
|
395
|
-
const body = lines.slice(0, Math.max(0, lines.length - 8)).join("\n")
|
|
396
|
-
let h = 0x811c9dc5
|
|
397
|
-
for (let i = 0; i < body.length; i++) {
|
|
398
|
-
h ^= body.charCodeAt(i)
|
|
399
|
-
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
|
|
400
|
-
}
|
|
401
|
-
return `${body.length}:${(h >>> 0).toString(16)}`
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** 内容安定による「確実に停止」判定。processing (ローダー在中) は常に false で、
|
|
405
|
-
* 停止後に出力領域が STABLE_CONFIRM_MS 不変であれば true。
|
|
406
|
-
*
|
|
407
|
-
* 多重防御 (2026-06-15): status の読み違いがあっても、ライブスピナー (経過タイマー) が
|
|
408
|
-
* ペインに在る間は「確実に停止」を絶対に返さない。frontend は stable=true を受けると
|
|
409
|
-
* 2 サンプル消灯確認 (paneOffConfirmed) をバイパスして即消灯するため、将来 claude が
|
|
410
|
-
* 未知のフッター形式を出して status を取りこぼしても、タイマーが画面に出ている限り
|
|
411
|
-
* 即消灯の暴発を防ぐ保険にする (生成中ローダー消失の再発防止)。 */
|
|
412
|
-
function _computeStable(sessionName, status, text) {
|
|
413
|
-
const now = Date.now()
|
|
414
|
-
if (status === "processing" || detectWorkingSpinner(text)) {
|
|
415
|
-
_stabilityByName.set(sessionName, { sig: _outputSignature(text), since: now })
|
|
416
|
-
return false
|
|
417
|
-
}
|
|
418
|
-
const sig = _outputSignature(text)
|
|
419
|
-
const prev = _stabilityByName.get(sessionName)
|
|
420
|
-
if (!prev || prev.sig !== sig) {
|
|
421
|
-
_stabilityByName.set(sessionName, { sig, since: now })
|
|
422
|
-
return false
|
|
423
|
-
}
|
|
424
|
-
return now - prev.since >= STABLE_CONFIRM_MS
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* pane に CLI を起動していないときの退化後の定値。
|
|
429
|
-
*
|
|
430
|
-
* pane が素のシェルなら capture-pane を読んでも:
|
|
431
|
-
* status … スピナーも "esc to interrupt" も ❯ プロンプトも出ないので常に 'idle'
|
|
432
|
-
* context_pct … "X% context left" は TUI のフッターにしか出ないので常に null
|
|
433
|
-
* stable … 画面が動かないので常に true
|
|
434
|
-
* spinner_present … 常に false
|
|
435
|
-
* output_sig … 不変なので出力フロー検知の情報量がゼロ
|
|
436
|
-
* にしかならない。読む価値がゼロの subprocess を全セッション × 5s 周期で fork し続ける
|
|
437
|
-
* ことになるため、この場合は capture 自体を行わずこの定値を返す。
|
|
438
|
-
*
|
|
439
|
-
* ⚠️ output_sig は「読んでいない」を表す null。空文字の署名 (`_outputSignature("")`) を
|
|
440
|
-
* 返すと main.mjs の出力フロー検知が「不変な署名を観測し続けている」と誤読するため、
|
|
441
|
-
* 型で区別できる null を返して当該経路をスキップさせる。
|
|
442
|
-
*/
|
|
443
|
-
const PANE_UNREAD_STATE = Object.freeze({
|
|
444
|
-
status: "idle",
|
|
445
|
-
context_pct: null,
|
|
446
|
-
permission_mode: null,
|
|
447
|
-
stable: true,
|
|
448
|
-
spinner_present: false,
|
|
449
|
-
output_sig: null,
|
|
450
|
-
})
|
|
451
|
-
|
|
452
|
-
export async function detectSessionState(sessionName, opts = {}) {
|
|
453
|
-
// ペインに CLI の TUI を起動していない構成 (既定) では、capture-pane は素のシェルの
|
|
454
|
-
// 画面を読むだけで何も供給しない。subprocess を焚く前にここで打ち切る。
|
|
455
|
-
// `HUB_AGENT_TMUX_LAUNCH_CLI=1` の逃げ道 (pane に TUI を起動する) では従来どおり読む。
|
|
456
|
-
if (!shouldLaunchPaneCli()) return { ...PANE_UNREAD_STATE }
|
|
457
|
-
const text = await capturePane(sessionName, opts)
|
|
458
|
-
let defaultStatus = detectStatusFromText(text)
|
|
459
|
-
// 凍結スピナー対策: スピナーが画面に在るが経過タイマーが進んでいない (claude 異常終了/ハング)
|
|
460
|
-
// 場合、生成中ではないので processing を取り下げる。"esc to interrupt" がある間は別のライブ
|
|
461
|
-
// シグナルなので対象外。frozen と判定したらスピナーを無視した素の判定 (❯→waiting / else idle)。
|
|
462
|
-
if (
|
|
463
|
-
defaultStatus === "processing" &&
|
|
464
|
-
!/esc to interrupt/i.test(text) &&
|
|
465
|
-
isSpinnerFrozen(sessionName, text)
|
|
466
|
-
) {
|
|
467
|
-
defaultStatus =
|
|
468
|
-
/❯\s/.test(text) || /^>\s/m.test(text) ? "waiting" : "idle"
|
|
469
|
-
}
|
|
470
|
-
const defaultContextPct = detectContextPctFromText(text)
|
|
471
|
-
// permission_mode はペインのフッターバナーから読んでいたが、消費者がゼロ (frontend の
|
|
472
|
-
// SessionStateRow に該当フィールドが無い) なので判定ごと撤去した。返り値のキーだけは
|
|
473
|
-
// plugin hook (transformStatusDetection) が差し替えられる契約として残す。
|
|
474
|
-
const defaultPermissionMode = null
|
|
475
|
-
// スピナー行 (ライブ or 凍結) が画面に在るか。frozen 降格で status が waiting に落ちても、
|
|
476
|
-
// スピナーが画面に残っている = ターンはクリーンに終わっていない (ハング候補)。StallTracker が
|
|
477
|
-
// 「クリーン idle (スピナー無し) でターンを閉じる」判定に使い、frozen スピナーのハングを拾う。
|
|
478
|
-
const spinnerPresent = workingSpinnerLine(text) !== null
|
|
479
|
-
|
|
480
|
-
if (opts.plugins && opts.plugins.length) {
|
|
481
|
-
const hookResult = await runHookChain(opts.plugins, "transformStatusDetection", {
|
|
482
|
-
logger: opts.logger,
|
|
483
|
-
sessionName,
|
|
484
|
-
paneText: text,
|
|
485
|
-
defaultStatus,
|
|
486
|
-
})
|
|
487
|
-
if (hookResult?.result) {
|
|
488
|
-
const status = debounceStatusDowngrade(
|
|
489
|
-
sessionName,
|
|
490
|
-
hookResult.result.status || defaultStatus,
|
|
491
|
-
)
|
|
492
|
-
return {
|
|
493
|
-
status,
|
|
494
|
-
context_pct: hookResult.result.context_pct ?? defaultContextPct,
|
|
495
|
-
permission_mode:
|
|
496
|
-
hookResult.result.permission_mode ?? defaultPermissionMode,
|
|
497
|
-
stable: _computeStable(sessionName, status, text),
|
|
498
|
-
spinner_present: spinnerPresent,
|
|
499
|
-
// 出力領域 (揮発フッター除外) の署名。state loop が tick 間で比較し、純テキスト
|
|
500
|
-
// 生成 (子プロセスを spawn しないため proc 内省が busy=false と見るケース) を
|
|
501
|
-
// 「出力フロー中=busy」として検出するのに使う。
|
|
502
|
-
output_sig: _outputSignature(text),
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
const status = debounceStatusDowngrade(sessionName, defaultStatus)
|
|
508
|
-
return {
|
|
509
|
-
status,
|
|
510
|
-
context_pct: defaultContextPct,
|
|
511
|
-
permission_mode: defaultPermissionMode,
|
|
512
|
-
stable: _computeStable(sessionName, status, text),
|
|
513
|
-
spinner_present: spinnerPresent,
|
|
514
|
-
output_sig: _outputSignature(text),
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/** 全 session の現在状態を取得する。cwd も含める (chat 信号照合用)。 */
|
|
519
|
-
export async function listSessionStates(opts = {}) {
|
|
520
|
-
const names = await listSessionNames(opts)
|
|
521
|
-
return Promise.all(
|
|
522
|
-
names.map(async (name) => {
|
|
523
|
-
const [state, cwd] = await Promise.all([
|
|
524
|
-
detectSessionState(name, opts),
|
|
525
|
-
getSessionCwd(name, opts),
|
|
526
|
-
])
|
|
527
|
-
return { session_name: name, cwd, ...state }
|
|
528
|
-
}),
|
|
529
|
-
)
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/**
|
|
533
|
-
* arm 非依存・cap 非依存の「応答停止 (stall)」判定ステート。
|
|
534
|
-
*
|
|
535
|
-
* 背景: 旧実装の stalled は (a) proc 内省の armed (= バンドルフックの prompt_submit 由来) に全依存し、
|
|
536
|
-
* フック未発火/旧世代hookセッションでは永久に false、(b) readiness loop の cap(≈140s)で disarm されると
|
|
537
|
-
* stalled が消える (90〜140s だけ表示で以後恒久消失)、という二重の死角があった。本トラッカーは state loop が
|
|
538
|
-
* 観測する signal だけで turn の開閉と無進捗を判定し、arm にも cap にも依存しない:
|
|
539
|
-
* - turnOpen: processing/procBusy を観測したら true。クリーン idle (非progressing かつ スピナー行が画面に
|
|
540
|
-
* 無く stable) で false。armed (フックが効くセッション) は OR で常に開扱い (従来挙動の上位互換)。
|
|
541
|
-
* - 進捗 (processing or procBusy) があれば lastProgressAt を更新。turn が閉じている間も更新し、次ターンの
|
|
542
|
-
* クロックを毎 tick リセットする。
|
|
543
|
-
* - stalled = turnOpen かつ 無進捗が stallWarnMs 継続。frozen スピナー (画面に在るが status は frozen 降格で
|
|
544
|
-
* waiting) は spinnerPresent=true で turnOpen を維持するため、proc 子が無く出力も止まったハングを拾える。
|
|
545
|
-
* cap に依存しないため 140s 以後も消えない。
|
|
546
|
-
*/
|
|
547
|
-
export class StallTracker {
|
|
548
|
-
constructor({ stallWarnMs = 90_000 } = {}) {
|
|
549
|
-
this.stallWarnMs = Math.max(1000, stallWarnMs)
|
|
550
|
-
/** @type {Map<string, {turnOpen: boolean, lastProgressAt: number|null}>} */
|
|
551
|
-
this.byName = new Map()
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
_get(name) {
|
|
555
|
-
let s = this.byName.get(name)
|
|
556
|
-
if (!s) {
|
|
557
|
-
s = { turnOpen: false, lastProgressAt: null }
|
|
558
|
-
this.byName.set(name, s)
|
|
559
|
-
}
|
|
560
|
-
return s
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* @param {string} name
|
|
565
|
-
* @param {{armed?: boolean, processing?: boolean, procBusy?: boolean,
|
|
566
|
-
* spinnerPresent?: boolean, stable?: boolean, now: number}} sig
|
|
567
|
-
* @returns {{stalled: boolean, turnOpen: boolean}}
|
|
568
|
-
*/
|
|
569
|
-
observe(name, { armed = false, processing = false, procBusy = false, spinnerPresent = false, stable = false, now }) {
|
|
570
|
-
const s = this._get(name)
|
|
571
|
-
const progressing = processing || procBusy
|
|
572
|
-
if (progressing) {
|
|
573
|
-
s.turnOpen = true
|
|
574
|
-
} else if (!spinnerPresent && stable) {
|
|
575
|
-
// クリーン idle: 生成中でなく proc 非busy、スピナーも画面に無く出力安定 = ターン正常終了。
|
|
576
|
-
// frozen スピナー (spinnerPresent=true) や stable 未確定の間は閉じない (ハング候補を維持)。
|
|
577
|
-
s.turnOpen = false
|
|
578
|
-
}
|
|
579
|
-
const turnOpen = armed || s.turnOpen
|
|
580
|
-
if (progressing || !turnOpen) {
|
|
581
|
-
// 進捗あり、または turn が閉じている間はクロックを前進させ続ける (次ターンのクロックを毎 tick 0 に保つ)。
|
|
582
|
-
s.lastProgressAt = now
|
|
583
|
-
} else if (s.lastProgressAt == null) {
|
|
584
|
-
s.lastProgressAt = now
|
|
585
|
-
}
|
|
586
|
-
const stalled = turnOpen && now - s.lastProgressAt >= this.stallWarnMs
|
|
587
|
-
return { stalled, turnOpen }
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
forget(name) {
|
|
591
|
-
this.byName.delete(name)
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
export { STATUSES }
|