@cocorograph/hub-agent 0.6.82 → 0.6.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/claude-history-watch.mjs +35 -8
- package/src/main.mjs +33 -1
- package/src/state.mjs +136 -13
package/package.json
CHANGED
|
@@ -13,11 +13,18 @@
|
|
|
13
13
|
* - ファイル不在時は出現を待つ (ディレクトリ監視はせず、ポーリングで存在チェック)
|
|
14
14
|
*/
|
|
15
15
|
import { watch as fsWatch } from "node:fs"
|
|
16
|
-
import { open, stat } from "node:fs/promises"
|
|
16
|
+
import { open, realpath, stat } from "node:fs/promises"
|
|
17
17
|
|
|
18
18
|
// 履歴 hydrate と live watch で jsonl→SDK message 整形を共通化 (フィールド取りこぼし防止)。
|
|
19
19
|
import { DISPLAY_TYPES, normalizeHistoryEvent } from "./claude-history.mjs"
|
|
20
|
-
|
|
20
|
+
|
|
21
|
+
// アダプティブ・ポーリング: 最後の追記から FAST_WINDOW_MS 以内は fast、以降は idle 周期。
|
|
22
|
+
// ~/.claude が iCloud Drive への symlink のため fs.watch (FSEvents) が遅延/不発しやすく、
|
|
23
|
+
// その場合この poll がバブル反映の律速になる。ターン進行中 (= 直近に追記がある間) だけ
|
|
24
|
+
// 細かく見て体感ラグを詰め、アイドル時は従来の 1.5s に戻して stat 負荷を抑える。
|
|
25
|
+
const POLL_FAST_MS = Number(process.env.HUB_AGENT_JSONL_POLL_FAST_MS ?? 400)
|
|
26
|
+
const POLL_IDLE_MS = Number(process.env.HUB_AGENT_JSONL_POLL_IDLE_MS ?? 1500)
|
|
27
|
+
const FAST_WINDOW_MS = Number(process.env.HUB_AGENT_JSONL_FAST_WINDOW_MS ?? 5000)
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
30
|
* 1 つの jsonl ファイルを tail する watcher を生成する。
|
|
@@ -38,6 +45,7 @@ export function watchSessionFile({ filePath, onEvent, fromEnd = true, logger })
|
|
|
38
45
|
let fsWatcher = null
|
|
39
46
|
let pollTimer = null
|
|
40
47
|
let initialized = false
|
|
48
|
+
let lastChangeAt = 0 // 最後に増分を読んだ時刻 (アダプティブ poll 周期の判定に使う)
|
|
41
49
|
|
|
42
50
|
async function initOffset() {
|
|
43
51
|
try {
|
|
@@ -78,6 +86,7 @@ export function watchSessionFile({ filePath, onEvent, fromEnd = true, logger })
|
|
|
78
86
|
const buf = Buffer.alloc(len)
|
|
79
87
|
await fh.read(buf, 0, len, offset)
|
|
80
88
|
offset = st.size
|
|
89
|
+
lastChangeAt = Date.now() // 追記検知 → 次回 poll を fast 側へ寄せる
|
|
81
90
|
const text = leftover + buf.toString("utf-8")
|
|
82
91
|
const lines = text.split("\n")
|
|
83
92
|
leftover = lines.pop() ?? "" // 最終要素は未完行として保持
|
|
@@ -107,21 +116,39 @@ export function watchSessionFile({ filePath, onEvent, fromEnd = true, logger })
|
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
function scheduleNextPoll() {
|
|
120
|
+
if (stopped) return
|
|
121
|
+
const interval =
|
|
122
|
+
Date.now() - lastChangeAt < FAST_WINDOW_MS ? POLL_FAST_MS : POLL_IDLE_MS
|
|
123
|
+
pollTimer = setTimeout(async () => {
|
|
124
|
+
await readIncrement().catch(() => {})
|
|
125
|
+
scheduleNextPoll()
|
|
126
|
+
}, interval)
|
|
127
|
+
pollTimer.unref?.()
|
|
128
|
+
}
|
|
129
|
+
|
|
110
130
|
;(async () => {
|
|
111
131
|
await initOffset()
|
|
112
132
|
if (stopped) return
|
|
113
|
-
// fs.watch (change で増分読み取り)
|
|
133
|
+
// fs.watch (change で増分読み取り)。~/.claude は iCloud Drive への symlink で、symlink
|
|
134
|
+
// 越しだと FSEvents が不安定なため realpath を解決して実体を watch する。失敗しても
|
|
135
|
+
// 下のアダプティブ・ポーリングが取りこぼしを拾う。
|
|
136
|
+
let watchTarget = filePath
|
|
114
137
|
try {
|
|
115
|
-
|
|
138
|
+
watchTarget = await realpath(filePath)
|
|
139
|
+
} catch {
|
|
140
|
+
/* 未存在等: そのまま filePath を試す */
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
fsWatcher = fsWatch(watchTarget, { persistent: false }, () => {
|
|
116
144
|
readIncrement().catch(() => {})
|
|
117
145
|
})
|
|
118
146
|
fsWatcher.on?.("error", () => {})
|
|
119
147
|
} catch {
|
|
120
148
|
// ファイル未存在等で watch 不可 → ポーリングに委ねる
|
|
121
149
|
}
|
|
122
|
-
// 取りこぼし /
|
|
123
|
-
|
|
124
|
-
pollTimer.unref?.()
|
|
150
|
+
// 取りこぼし / ファイル出現待ち / iCloud fs.watch 不発のバックストップ (アダプティブ周期)。
|
|
151
|
+
scheduleNextPoll()
|
|
125
152
|
})()
|
|
126
153
|
|
|
127
154
|
return {
|
|
@@ -132,7 +159,7 @@ export function watchSessionFile({ filePath, onEvent, fromEnd = true, logger })
|
|
|
132
159
|
} catch {
|
|
133
160
|
/* ignore */
|
|
134
161
|
}
|
|
135
|
-
if (pollTimer)
|
|
162
|
+
if (pollTimer) clearTimeout(pollTimer)
|
|
136
163
|
},
|
|
137
164
|
/**
|
|
138
165
|
* offset を現在のファイル末尾に進める (= 未読分を push せず捨てる)。
|
package/src/main.mjs
CHANGED
|
@@ -36,6 +36,7 @@ import { listSkills } from "./skills.mjs"
|
|
|
36
36
|
import {
|
|
37
37
|
capturePane,
|
|
38
38
|
detectPermissionModeFromText,
|
|
39
|
+
detectSessionState,
|
|
39
40
|
listSessionStates,
|
|
40
41
|
} from "./state.mjs"
|
|
41
42
|
import {
|
|
@@ -952,17 +953,22 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
952
953
|
writeSessionEventFile(s.session_name, ev, chat.turnAt).catch(() => {})
|
|
953
954
|
}
|
|
954
955
|
}
|
|
956
|
+
// 内容安定で「確実に停止」と判定できたか (frontend が 2 サンプル消灯確認を省いて
|
|
957
|
+
// 即消灯するためのフラグ)。chat 信号で processing へ上書きされた場合は false。
|
|
958
|
+
const stable = status !== "processing" && s.stable === true
|
|
955
959
|
const prev = lastByName.get(s.session_name)
|
|
956
960
|
if (
|
|
957
961
|
!prev ||
|
|
958
962
|
prev.status !== status ||
|
|
959
963
|
prev.context_pct !== contextPct ||
|
|
960
|
-
prev.permission_mode !== permissionMode
|
|
964
|
+
prev.permission_mode !== permissionMode ||
|
|
965
|
+
prev.stable !== stable
|
|
961
966
|
) {
|
|
962
967
|
lastByName.set(s.session_name, {
|
|
963
968
|
status,
|
|
964
969
|
context_pct: contextPct,
|
|
965
970
|
permission_mode: permissionMode,
|
|
971
|
+
stable,
|
|
966
972
|
})
|
|
967
973
|
client.send({
|
|
968
974
|
type: "session.state",
|
|
@@ -970,6 +976,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
|
|
|
970
976
|
status,
|
|
971
977
|
context_pct: contextPct,
|
|
972
978
|
permission_mode: permissionMode,
|
|
979
|
+
stable,
|
|
973
980
|
})
|
|
974
981
|
}
|
|
975
982
|
}
|
|
@@ -1761,6 +1768,31 @@ async function dispatch(msg, ctx) {
|
|
|
1761
1768
|
})
|
|
1762
1769
|
}
|
|
1763
1770
|
}
|
|
1771
|
+
// 即時ステータススナップショット (#2): session.state は差分送信なので、アイドル
|
|
1772
|
+
// セッションへ切替えても現在値が届かず、ペイン由来の三点リーダー補正が次の poll
|
|
1773
|
+
// (~8-10s) まで遅れる。bind は TUI ビューのマウントごとに来るので、ここで現在状態
|
|
1774
|
+
// を 1 回 push して初回サンプルを即届け、偽点灯/消灯確定を早める (noCache で実測)。
|
|
1775
|
+
if (sessionName) {
|
|
1776
|
+
try {
|
|
1777
|
+
const snap = await detectSessionState(sessionName, {
|
|
1778
|
+
noCache: true,
|
|
1779
|
+
logger,
|
|
1780
|
+
})
|
|
1781
|
+
ctx.client.send({
|
|
1782
|
+
type: "session.state",
|
|
1783
|
+
session_name: sessionName,
|
|
1784
|
+
status: snap.status,
|
|
1785
|
+
context_pct: snap.context_pct,
|
|
1786
|
+
permission_mode: snap.permission_mode,
|
|
1787
|
+
stable: snap.status !== "processing" && snap.stable === true,
|
|
1788
|
+
})
|
|
1789
|
+
} catch (err) {
|
|
1790
|
+
logger?.warn(
|
|
1791
|
+
{ err: err?.message, sessionName },
|
|
1792
|
+
"claude.tui.bind status snapshot failed",
|
|
1793
|
+
)
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1764
1796
|
} catch (err) {
|
|
1765
1797
|
logger?.warn(
|
|
1766
1798
|
{ err: err?.message, sessionName, cwd },
|
package/src/state.mjs
CHANGED
|
@@ -132,16 +132,33 @@ function footerRegion(text, lines = 8) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
135
|
+
* 作業スピナーのフッター行を検出する (生成中=processing の補助シグナル)。
|
|
136
|
+
*
|
|
137
|
+
* 現行 claude TUI の生成中フッターは語が毎ターン変わる (実機採取 2026-06-15):
|
|
138
|
+
* "· Blanching… (2m 4s · ↓ 7.5k tokens)" (出力中)
|
|
139
|
+
* "✽ Scurrying… (16m 55s · ↓ 66.8k tokens)" (出力中)
|
|
140
|
+
* "· Puzzling… (11m 12s · thinking)" (思考中・tokens 語が無い)
|
|
141
|
+
* 語 (Blanching/Puzzling/Processing/Orbiting/Scurrying…) は無数にあり版で増減するため
|
|
142
|
+
* 語リストでは判定しない。また現行版は "esc to interrupt" をフッターに出さない (主シグナル
|
|
143
|
+
* が実機で死んでいる)。版に依存せず安定する構造は「<語>… (<ライブ経過タイマー> …)」=
|
|
144
|
+
* 任意語 + 省略記号 + 括弧で囲まれた経過時間。完了サマリー "✻ Brewed for 2m 52s" は省略
|
|
145
|
+
* 記号も括弧タイマーも持たないので拾わない。誤検出を抑えるため判定はフッター領域に限定する。
|
|
146
|
+
*
|
|
147
|
+
* 2 系統で判定する:
|
|
148
|
+
* (a) 構造シグナル: "<語>… (<m? s>" のライブタイマー。tokens の有無に依存しないので
|
|
149
|
+
* thinking フッターも拾う (旧実装の偽陰性の根治)。tips 行 (⎿ Tip:…) でスピナー行が
|
|
150
|
+
* 押し上げられる分を見込み少し広い領域 (12 行) を走査する。構造が限定的なので
|
|
151
|
+
* スクロールバックの古フッター "(99s · …) old footer" 等の誤検出に強い。
|
|
152
|
+
* (b) 旧シグナル (回帰防止): "tokens" 語 + 経過秒の同一行同居。タイマーが括弧直後でない
|
|
153
|
+
* 語順 "(↑ 3.4k tokens · 7s)" も拾う。誤検出しやすいのでフッター 8 行に限定する。
|
|
141
154
|
*/
|
|
142
155
|
function detectWorkingSpinner(text) {
|
|
143
|
-
|
|
144
|
-
for (const line of
|
|
156
|
+
// (a) 構造シグナル: 任意語 + 省略記号 + 括弧ライブタイマー (tokens 非依存・thinking も拾う)。
|
|
157
|
+
for (const line of footerRegion(text, 12).split("\n")) {
|
|
158
|
+
if (/[A-Za-z]+(?:…|\.\.\.)\s*\(\s*(?:\d+\s*m\s*)?\d+\s*s\b/.test(line)) return true
|
|
159
|
+
}
|
|
160
|
+
// (b) 旧シグナル: tokens カウンタ + 経過秒の同一行同居 (フッター 8 行限定)。
|
|
161
|
+
for (const line of footerRegion(text, 8).split("\n")) {
|
|
145
162
|
if (/\btokens\b/i.test(line) && /(?:^|[\s(])\d+\s*s\b/.test(line)) return true
|
|
146
163
|
}
|
|
147
164
|
return false
|
|
@@ -224,7 +241,66 @@ export function detectPermissionModeFromText(text) {
|
|
|
224
241
|
* @param {string} text capturePane の結果 (ANSI 除去済み)
|
|
225
242
|
* @returns {string|null} 入力欄内の本文 (入力欄が見つからない / 空なら null)
|
|
226
243
|
*/
|
|
244
|
+
/**
|
|
245
|
+
* 現行形式 (ルール区切り) の入力ボックス本文を「上下ボーダー間」から抽出する。
|
|
246
|
+
*
|
|
247
|
+
* 末尾から `❯` を逆走する旧方式は、ユーザー評価プロンプト ("How is Claude doing?") や
|
|
248
|
+
* 選択ダイアログの選択肢行 (`❯ Very good` 等) の ❯ に誤マッチし得た。評価プロンプトは
|
|
249
|
+
* 入力欄の「上」に出るため入力欄自体の上下ボーダーはズレない、という知見に基づき、
|
|
250
|
+
* 下ボーダー (ペイン末尾近傍の ────) → 直上の上ボーダー → 間の ❯ プロンプト行、という
|
|
251
|
+
* 範囲ベースで確定する。誤認防止ガード: 下ボーダーはペイン末尾から MAX_FOOTER_LINES 行
|
|
252
|
+
* 以内、上下ボーダー間は 2〜MAX_BOX_LINES 行、間に列 0 の ❯ が在ること。
|
|
253
|
+
* 見つからなければ null を返し、呼び出し側は旧アンカー方式へフォールバックする。
|
|
254
|
+
*
|
|
255
|
+
* @param {string} text capturePane の結果 (ANSI 除去済み)
|
|
256
|
+
* @returns {string|null}
|
|
257
|
+
*/
|
|
258
|
+
function _inputTextFromRuleBox(text) {
|
|
259
|
+
if (!text) return null
|
|
260
|
+
const lines = text.split("\n")
|
|
261
|
+
const isRule = (s) => /^\s*─{4,}\s*$/.test(s)
|
|
262
|
+
const MAX_FOOTER_LINES = 6 // 入力欄の下に出るヒント/ナビ行の想定上限
|
|
263
|
+
const MAX_BOX_LINES = 50 // 上下ボーダー間の許容行数 (折り返し長文入力の上限)
|
|
264
|
+
// 下ボーダー: ペイン末尾近傍 (下に出るヒント行のすぐ上) の ──── を末尾から探す。
|
|
265
|
+
let bottom = -1
|
|
266
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
267
|
+
if (lines.length - 1 - i > MAX_FOOTER_LINES) break
|
|
268
|
+
if (isRule(lines[i])) {
|
|
269
|
+
bottom = i
|
|
270
|
+
break
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (bottom < 0) return null
|
|
274
|
+
// 上ボーダー: 下ボーダーの直上にある ──── 。
|
|
275
|
+
let top = -1
|
|
276
|
+
for (let i = bottom - 1; i >= 0 && bottom - i <= MAX_BOX_LINES; i--) {
|
|
277
|
+
if (isRule(lines[i])) {
|
|
278
|
+
top = i
|
|
279
|
+
break
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (top < 0 || bottom - top < 2) return null
|
|
283
|
+
// ボーダー間に列 0 の ❯ プロンプト行が在ること (選択ダイアログの字下げ ❯ は対象外)。
|
|
284
|
+
const inner = lines.slice(top + 1, bottom)
|
|
285
|
+
const promptIdx = inner.findIndex((ln) => /^❯/.test(ln))
|
|
286
|
+
if (promptIdx < 0) return null
|
|
287
|
+
const parts = [inner[promptIdx].replace(/^❯\s?/, "").replace(/\s+$/, "")]
|
|
288
|
+
for (let i = promptIdx + 1; i < inner.length; i++) {
|
|
289
|
+
parts.push(inner[i].replace(/\s+$/, ""))
|
|
290
|
+
}
|
|
291
|
+
const joined = parts.join("\n").trim()
|
|
292
|
+
return joined || null
|
|
293
|
+
}
|
|
294
|
+
|
|
227
295
|
export function detectInputBoxText(text) {
|
|
296
|
+
// 第一候補: 上下ボーダー間抽出 (評価プロンプト/選択ダイアログの ❯ 誤マッチに強い)。
|
|
297
|
+
const ruled = _inputTextFromRuleBox(text)
|
|
298
|
+
if (ruled !== null) return ruled
|
|
299
|
+
// フォールバック: 旧 ❯ アンカー / 罫線ボックス方式 (ボーダー未描画版・レイアウト差用)。
|
|
300
|
+
return _inputTextFromPromptAnchor(text)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function _inputTextFromPromptAnchor(text) {
|
|
228
304
|
if (!text) return null
|
|
229
305
|
const lines = text.split("\n")
|
|
230
306
|
|
|
@@ -361,6 +437,49 @@ export async function getSessionCwd(sessionName, opts = {}) {
|
|
|
361
437
|
*
|
|
362
438
|
* @returns {Promise<{status: string, context_pct: number | null}>}
|
|
363
439
|
*/
|
|
440
|
+
/** ペイン内容が一定時間不変なら「確実に停止 (idle/waiting)」とみなす確定窓。frontend は
|
|
441
|
+
* この stable=true を受けると 2 サンプル消灯確認 (paneOffConfirmed) を省いて即消灯できる。
|
|
442
|
+
* ローダー無しでも本文がストリーミング中 (= 出力領域が変化) なら stable にならないので、
|
|
443
|
+
* 「文字出力中はローダーが出ない」ケースでも誤って停止扱いしない。 */
|
|
444
|
+
const STABLE_CONFIRM_MS = Number(process.env.HUB_AGENT_STABLE_CONFIRM_MS ?? 3000)
|
|
445
|
+
const _stabilityByName = new Map() // session_name → { sig, since }
|
|
446
|
+
|
|
447
|
+
/** ペインの出力領域 (入力欄・揮発フッターを除いた上側) の軽量シグネチャ (FNV-1a 32bit)。
|
|
448
|
+
* 末尾 8 行 (入力欄 + スピナー経過秒/ヒント等) を除くので、idle 中は連続 capture が一致する。 */
|
|
449
|
+
function _outputSignature(text) {
|
|
450
|
+
const lines = text.split("\n")
|
|
451
|
+
const body = lines.slice(0, Math.max(0, lines.length - 8)).join("\n")
|
|
452
|
+
let h = 0x811c9dc5
|
|
453
|
+
for (let i = 0; i < body.length; i++) {
|
|
454
|
+
h ^= body.charCodeAt(i)
|
|
455
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
|
|
456
|
+
}
|
|
457
|
+
return `${body.length}:${(h >>> 0).toString(16)}`
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** 内容安定による「確実に停止」判定。processing (ローダー在中) は常に false で、
|
|
461
|
+
* 停止後に出力領域が STABLE_CONFIRM_MS 不変であれば true。
|
|
462
|
+
*
|
|
463
|
+
* 多重防御 (2026-06-15): status の読み違いがあっても、ライブスピナー (経過タイマー) が
|
|
464
|
+
* ペインに在る間は「確実に停止」を絶対に返さない。frontend は stable=true を受けると
|
|
465
|
+
* 2 サンプル消灯確認 (paneOffConfirmed) をバイパスして即消灯するため、将来 claude が
|
|
466
|
+
* 未知のフッター形式を出して status を取りこぼしても、タイマーが画面に出ている限り
|
|
467
|
+
* 即消灯の暴発を防ぐ保険にする (生成中ローダー消失の再発防止)。 */
|
|
468
|
+
function _computeStable(sessionName, status, text) {
|
|
469
|
+
const now = Date.now()
|
|
470
|
+
if (status === "processing" || detectWorkingSpinner(text)) {
|
|
471
|
+
_stabilityByName.set(sessionName, { sig: _outputSignature(text), since: now })
|
|
472
|
+
return false
|
|
473
|
+
}
|
|
474
|
+
const sig = _outputSignature(text)
|
|
475
|
+
const prev = _stabilityByName.get(sessionName)
|
|
476
|
+
if (!prev || prev.sig !== sig) {
|
|
477
|
+
_stabilityByName.set(sessionName, { sig, since: now })
|
|
478
|
+
return false
|
|
479
|
+
}
|
|
480
|
+
return now - prev.since >= STABLE_CONFIRM_MS
|
|
481
|
+
}
|
|
482
|
+
|
|
364
483
|
export async function detectSessionState(sessionName, opts = {}) {
|
|
365
484
|
const text = await capturePane(sessionName, opts)
|
|
366
485
|
const defaultStatus = detectStatusFromText(text)
|
|
@@ -375,22 +494,26 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
375
494
|
defaultStatus,
|
|
376
495
|
})
|
|
377
496
|
if (hookResult?.result) {
|
|
497
|
+
const status = debounceStatusDowngrade(
|
|
498
|
+
sessionName,
|
|
499
|
+
hookResult.result.status || defaultStatus,
|
|
500
|
+
)
|
|
378
501
|
return {
|
|
379
|
-
status
|
|
380
|
-
sessionName,
|
|
381
|
-
hookResult.result.status || defaultStatus,
|
|
382
|
-
),
|
|
502
|
+
status,
|
|
383
503
|
context_pct: hookResult.result.context_pct ?? defaultContextPct,
|
|
384
504
|
permission_mode:
|
|
385
505
|
hookResult.result.permission_mode ?? defaultPermissionMode,
|
|
506
|
+
stable: _computeStable(sessionName, status, text),
|
|
386
507
|
}
|
|
387
508
|
}
|
|
388
509
|
}
|
|
389
510
|
|
|
511
|
+
const status = debounceStatusDowngrade(sessionName, defaultStatus)
|
|
390
512
|
return {
|
|
391
|
-
status
|
|
513
|
+
status,
|
|
392
514
|
context_pct: defaultContextPct,
|
|
393
515
|
permission_mode: defaultPermissionMode,
|
|
516
|
+
stable: _computeStable(sessionName, status, text),
|
|
394
517
|
}
|
|
395
518
|
}
|
|
396
519
|
|