@cocorograph/hub-agent 0.6.82 → 0.6.83
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 +105 -5
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
|
@@ -224,7 +224,66 @@ export function detectPermissionModeFromText(text) {
|
|
|
224
224
|
* @param {string} text capturePane の結果 (ANSI 除去済み)
|
|
225
225
|
* @returns {string|null} 入力欄内の本文 (入力欄が見つからない / 空なら null)
|
|
226
226
|
*/
|
|
227
|
+
/**
|
|
228
|
+
* 現行形式 (ルール区切り) の入力ボックス本文を「上下ボーダー間」から抽出する。
|
|
229
|
+
*
|
|
230
|
+
* 末尾から `❯` を逆走する旧方式は、ユーザー評価プロンプト ("How is Claude doing?") や
|
|
231
|
+
* 選択ダイアログの選択肢行 (`❯ Very good` 等) の ❯ に誤マッチし得た。評価プロンプトは
|
|
232
|
+
* 入力欄の「上」に出るため入力欄自体の上下ボーダーはズレない、という知見に基づき、
|
|
233
|
+
* 下ボーダー (ペイン末尾近傍の ────) → 直上の上ボーダー → 間の ❯ プロンプト行、という
|
|
234
|
+
* 範囲ベースで確定する。誤認防止ガード: 下ボーダーはペイン末尾から MAX_FOOTER_LINES 行
|
|
235
|
+
* 以内、上下ボーダー間は 2〜MAX_BOX_LINES 行、間に列 0 の ❯ が在ること。
|
|
236
|
+
* 見つからなければ null を返し、呼び出し側は旧アンカー方式へフォールバックする。
|
|
237
|
+
*
|
|
238
|
+
* @param {string} text capturePane の結果 (ANSI 除去済み)
|
|
239
|
+
* @returns {string|null}
|
|
240
|
+
*/
|
|
241
|
+
function _inputTextFromRuleBox(text) {
|
|
242
|
+
if (!text) return null
|
|
243
|
+
const lines = text.split("\n")
|
|
244
|
+
const isRule = (s) => /^\s*─{4,}\s*$/.test(s)
|
|
245
|
+
const MAX_FOOTER_LINES = 6 // 入力欄の下に出るヒント/ナビ行の想定上限
|
|
246
|
+
const MAX_BOX_LINES = 50 // 上下ボーダー間の許容行数 (折り返し長文入力の上限)
|
|
247
|
+
// 下ボーダー: ペイン末尾近傍 (下に出るヒント行のすぐ上) の ──── を末尾から探す。
|
|
248
|
+
let bottom = -1
|
|
249
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
250
|
+
if (lines.length - 1 - i > MAX_FOOTER_LINES) break
|
|
251
|
+
if (isRule(lines[i])) {
|
|
252
|
+
bottom = i
|
|
253
|
+
break
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (bottom < 0) return null
|
|
257
|
+
// 上ボーダー: 下ボーダーの直上にある ──── 。
|
|
258
|
+
let top = -1
|
|
259
|
+
for (let i = bottom - 1; i >= 0 && bottom - i <= MAX_BOX_LINES; i--) {
|
|
260
|
+
if (isRule(lines[i])) {
|
|
261
|
+
top = i
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (top < 0 || bottom - top < 2) return null
|
|
266
|
+
// ボーダー間に列 0 の ❯ プロンプト行が在ること (選択ダイアログの字下げ ❯ は対象外)。
|
|
267
|
+
const inner = lines.slice(top + 1, bottom)
|
|
268
|
+
const promptIdx = inner.findIndex((ln) => /^❯/.test(ln))
|
|
269
|
+
if (promptIdx < 0) return null
|
|
270
|
+
const parts = [inner[promptIdx].replace(/^❯\s?/, "").replace(/\s+$/, "")]
|
|
271
|
+
for (let i = promptIdx + 1; i < inner.length; i++) {
|
|
272
|
+
parts.push(inner[i].replace(/\s+$/, ""))
|
|
273
|
+
}
|
|
274
|
+
const joined = parts.join("\n").trim()
|
|
275
|
+
return joined || null
|
|
276
|
+
}
|
|
277
|
+
|
|
227
278
|
export function detectInputBoxText(text) {
|
|
279
|
+
// 第一候補: 上下ボーダー間抽出 (評価プロンプト/選択ダイアログの ❯ 誤マッチに強い)。
|
|
280
|
+
const ruled = _inputTextFromRuleBox(text)
|
|
281
|
+
if (ruled !== null) return ruled
|
|
282
|
+
// フォールバック: 旧 ❯ アンカー / 罫線ボックス方式 (ボーダー未描画版・レイアウト差用)。
|
|
283
|
+
return _inputTextFromPromptAnchor(text)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function _inputTextFromPromptAnchor(text) {
|
|
228
287
|
if (!text) return null
|
|
229
288
|
const lines = text.split("\n")
|
|
230
289
|
|
|
@@ -361,6 +420,43 @@ export async function getSessionCwd(sessionName, opts = {}) {
|
|
|
361
420
|
*
|
|
362
421
|
* @returns {Promise<{status: string, context_pct: number | null}>}
|
|
363
422
|
*/
|
|
423
|
+
/** ペイン内容が一定時間不変なら「確実に停止 (idle/waiting)」とみなす確定窓。frontend は
|
|
424
|
+
* この stable=true を受けると 2 サンプル消灯確認 (paneOffConfirmed) を省いて即消灯できる。
|
|
425
|
+
* ローダー無しでも本文がストリーミング中 (= 出力領域が変化) なら stable にならないので、
|
|
426
|
+
* 「文字出力中はローダーが出ない」ケースでも誤って停止扱いしない。 */
|
|
427
|
+
const STABLE_CONFIRM_MS = Number(process.env.HUB_AGENT_STABLE_CONFIRM_MS ?? 3000)
|
|
428
|
+
const _stabilityByName = new Map() // session_name → { sig, since }
|
|
429
|
+
|
|
430
|
+
/** ペインの出力領域 (入力欄・揮発フッターを除いた上側) の軽量シグネチャ (FNV-1a 32bit)。
|
|
431
|
+
* 末尾 8 行 (入力欄 + スピナー経過秒/ヒント等) を除くので、idle 中は連続 capture が一致する。 */
|
|
432
|
+
function _outputSignature(text) {
|
|
433
|
+
const lines = text.split("\n")
|
|
434
|
+
const body = lines.slice(0, Math.max(0, lines.length - 8)).join("\n")
|
|
435
|
+
let h = 0x811c9dc5
|
|
436
|
+
for (let i = 0; i < body.length; i++) {
|
|
437
|
+
h ^= body.charCodeAt(i)
|
|
438
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
|
|
439
|
+
}
|
|
440
|
+
return `${body.length}:${(h >>> 0).toString(16)}`
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 内容安定による「確実に停止」判定。processing (ローダー在中) は常に false で、
|
|
444
|
+
* 停止後に出力領域が STABLE_CONFIRM_MS 不変であれば true。 */
|
|
445
|
+
function _computeStable(sessionName, status, text) {
|
|
446
|
+
const now = Date.now()
|
|
447
|
+
if (status === "processing") {
|
|
448
|
+
_stabilityByName.set(sessionName, { sig: _outputSignature(text), since: now })
|
|
449
|
+
return false
|
|
450
|
+
}
|
|
451
|
+
const sig = _outputSignature(text)
|
|
452
|
+
const prev = _stabilityByName.get(sessionName)
|
|
453
|
+
if (!prev || prev.sig !== sig) {
|
|
454
|
+
_stabilityByName.set(sessionName, { sig, since: now })
|
|
455
|
+
return false
|
|
456
|
+
}
|
|
457
|
+
return now - prev.since >= STABLE_CONFIRM_MS
|
|
458
|
+
}
|
|
459
|
+
|
|
364
460
|
export async function detectSessionState(sessionName, opts = {}) {
|
|
365
461
|
const text = await capturePane(sessionName, opts)
|
|
366
462
|
const defaultStatus = detectStatusFromText(text)
|
|
@@ -375,22 +471,26 @@ export async function detectSessionState(sessionName, opts = {}) {
|
|
|
375
471
|
defaultStatus,
|
|
376
472
|
})
|
|
377
473
|
if (hookResult?.result) {
|
|
474
|
+
const status = debounceStatusDowngrade(
|
|
475
|
+
sessionName,
|
|
476
|
+
hookResult.result.status || defaultStatus,
|
|
477
|
+
)
|
|
378
478
|
return {
|
|
379
|
-
status
|
|
380
|
-
sessionName,
|
|
381
|
-
hookResult.result.status || defaultStatus,
|
|
382
|
-
),
|
|
479
|
+
status,
|
|
383
480
|
context_pct: hookResult.result.context_pct ?? defaultContextPct,
|
|
384
481
|
permission_mode:
|
|
385
482
|
hookResult.result.permission_mode ?? defaultPermissionMode,
|
|
483
|
+
stable: _computeStable(sessionName, status, text),
|
|
386
484
|
}
|
|
387
485
|
}
|
|
388
486
|
}
|
|
389
487
|
|
|
488
|
+
const status = debounceStatusDowngrade(sessionName, defaultStatus)
|
|
390
489
|
return {
|
|
391
|
-
status
|
|
490
|
+
status,
|
|
392
491
|
context_pct: defaultContextPct,
|
|
393
492
|
permission_mode: defaultPermissionMode,
|
|
493
|
+
stable: _computeStable(sessionName, status, text),
|
|
394
494
|
}
|
|
395
495
|
}
|
|
396
496
|
|