@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.6.82",
3
+ "version": "0.6.84",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
- const POLL_INTERVAL_MS = 1500
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
- fsWatcher = fsWatch(filePath, { persistent: false }, () => {
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
- pollTimer = setInterval(() => readIncrement().catch(() => {}), POLL_INTERVAL_MS)
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) clearInterval(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
- * 作業スピナーのフッター行を検出する。claude TUI の生成中フッターは
136
- * "✻ Cogitating… (12s · ↑ 1.2k tokens · esc to interrupt)" のように
137
- * 「経過秒」と「トークンカウンタ」が同一行に同居する。idle/waiting の権限バナーや
138
- * 通常本文には出ないシグネチャなので、中断フッター文言 ("esc to interrupt")
139
- * 未描画 (ターン序盤) / locale / ツール実行中などで取りこぼされる区間でも
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
- const footer = footerRegion(text)
144
- for (const line of footer.split("\n")) {
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: debounceStatusDowngrade(
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: debounceStatusDowngrade(sessionName, defaultStatus),
513
+ status,
392
514
  context_pct: defaultContextPct,
393
515
  permission_mode: defaultPermissionMode,
516
+ stable: _computeStable(sessionName, status, text),
394
517
  }
395
518
  }
396
519