@cocorograph/hub-agent 0.6.66 → 0.6.67

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.66",
3
+ "version": "0.6.67",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -23,7 +23,7 @@
23
23
  * プロセス)、サイドバー行 (= tmux セッション) とは cwd で対応するため。
24
24
  */
25
25
 
26
- // cwd → { status, context_pct, turnAt, updatedAtMs }
26
+ // cwd → { status, context_pct, statusAt, inputPending, turnAt, updatedAtMs }
27
27
  const _byCwd = new Map()
28
28
 
29
29
  // チャット信号を「生きている」と見なす最大経過時間 (ms)。これを過ぎたら tmux
@@ -48,23 +48,32 @@ function _now() {
48
48
  *
49
49
  * @param {string} cwd セッションの作業ディレクトリ
50
50
  * @param {{ status?: string, contextPct?: number|null, inputPending?: boolean }} patch
51
+ * @param {number} [now] テスト用の時刻注入 (省略時は Date.now())
51
52
  */
52
- export function recordChatActivity(cwd, patch = {}) {
53
+ export function recordChatActivity(cwd, patch = {}, now = _now()) {
53
54
  if (!cwd || typeof cwd !== "string") return
54
- const now = _now()
55
55
  const prev = _byCwd.get(cwd) || {
56
56
  status: null,
57
57
  context_pct: null,
58
+ statusAt: 0,
58
59
  inputPending: false,
59
60
  turnAt: 0,
60
61
  updatedAtMs: 0,
61
62
  }
62
63
  const next = { ...prev, turnAt: now, updatedAtMs: now }
64
+ // statusAt: status / context_pct を「実際に SDK イベントが更新した」時刻。
65
+ // inputPending だけの記録 (TUI 権限ブリッジ) では更新しない。これを分けないと、
66
+ // TUI モードの承認カードが届くたびに「過去の SDK チャットが残した status (多くは
67
+ // waiting)」の鮮度だけが蘇生され、state loop が capture-pane の実 processing を
68
+ // 15 分間 waiting で上書きし続ける (TUI 生成中なのにステータスドット/三点リーダーが
69
+ // 消える事故の真因, 2026-06-12)。
63
70
  if (typeof patch.status === "string" && VALID_STATUS.has(patch.status)) {
64
71
  next.status = patch.status
72
+ next.statusAt = now
65
73
  }
66
74
  if (typeof patch.contextPct === "number" && Number.isFinite(patch.contextPct)) {
67
75
  next.context_pct = Math.max(0, Math.min(100, patch.contextPct))
76
+ next.statusAt = now
68
77
  }
69
78
  if (typeof patch.inputPending === "boolean") {
70
79
  next.inputPending = patch.inputPending
@@ -84,9 +93,26 @@ export function getChatSignal(cwd, now = _now()) {
84
93
  const sig = _byCwd.get(cwd)
85
94
  if (!sig) return null
86
95
  if (now - sig.updatedAtMs > CHAT_SIGNAL_STALE_MS) return null
96
+ // status / context_pct 自体の鮮度は statusAt で判定する。inputPending だけが
97
+ // 信号を延命している間 (= TUI モードで承認カードだけが届く状況) は、stale な
98
+ // SDK 由来の status / context_pct を返さず capture-pane の実値に委ねる。
99
+ if (now - (sig.statusAt || 0) > CHAT_SIGNAL_STALE_MS) {
100
+ return { ...sig, status: null, context_pct: null }
101
+ }
87
102
  return sig
88
103
  }
89
104
 
105
+ /**
106
+ * cwd のチャット信号を破棄する。claude.tui.bind (= SDK 停止 → TUI へ載せ替え) の
107
+ * 時点で呼び、以降の status / context% はペイン実状態 (capture-pane) に一本化する。
108
+ *
109
+ * @param {string} cwd
110
+ */
111
+ export function clearChatSignal(cwd) {
112
+ if (!cwd || typeof cwd !== "string") return
113
+ _byCwd.delete(cwd)
114
+ }
115
+
90
116
  /** テスト用: ストアを空にする。 */
91
117
  export function _resetChatSignals() {
92
118
  _byCwd.clear()
package/src/main.mjs CHANGED
@@ -81,7 +81,7 @@ import {
81
81
  getUsage,
82
82
  recordChatRateLimit,
83
83
  } from "./usage.mjs"
84
- import { getChatSignal, recordChatActivity } from "./chat-signals.mjs"
84
+ import { clearChatSignal, getChatSignal, recordChatActivity } from "./chat-signals.mjs"
85
85
  import { hydrateProcessEnvFromShell } from "./shell-env.mjs"
86
86
 
87
87
  const logger = pino({ name: "hub-agent" })
@@ -901,8 +901,15 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge }) {
901
901
  // 上書きする (Hub ホスト型 Cockpit のステータスドット/各行ドーナツ用)。
902
902
  const chat = s.cwd ? getChatSignal(s.cwd) : null
903
903
  if (chat) {
904
- if (chat.status) status = chat.status
905
- if (typeof chat.context_pct === "number") contextPct = chat.context_pct
904
+ // capture-pane が processing (= ペインに "esc to interrupt" が実在 = 本物の
905
+ // TUI が生成中) のときはチャット信号で上書きしない。素のシェル (SDK チャット
906
+ // のペイン) は processing を返せないため、SDK チャットの補完は従来どおり効く。
907
+ // TUI 生成中に stale な SDK status (waiting) が勝ってステータスドット/
908
+ // 三点リーダーが消える事故の防止 (2026-06-12)。
909
+ if (chat.status && status !== "processing") status = chat.status
910
+ if (typeof chat.context_pct === "number" && status !== "processing") {
911
+ contextPct = chat.context_pct
912
+ }
906
913
  // チャットのターン境界 (turnAt 前進) を sort 用 session-event に橋渡し。
907
914
  // tmux ペインが動かず bundle hook が発火しないため、ここで代替発火する。
908
915
  const prevTurnAt = lastTurnAtByName.get(s.session_name) || 0
@@ -1658,6 +1665,11 @@ async function dispatch(msg, ctx) {
1658
1665
  session_id: targetId || undefined,
1659
1666
  })
1660
1667
  }
1668
+ // SDK を止めて TUI へ載せ替えた時点で、cwd のチャット信号 (SDK 用の
1669
+ // status/context% 補完) は役目を終える。残すと TUI 権限カードの
1670
+ // inputPending 記録が stale status の鮮度を延命し、state loop が
1671
+ // ペイン実状態を waiting で上書きし続ける (2026-06-12 真因修正)。
1672
+ if (cwd) clearChatSignal(cwd)
1661
1673
  // 3) tmux claude を起動し直す (resume or fresh)。
1662
1674
  // 冪等性: 同じ session を同じキーへ既に載せ替え済みなら respawn しない
1663
1675
  // (TUI ビューは remount のたびに bind を送るため、毎回 respawn すると
package/src/state.mjs CHANGED
@@ -45,10 +45,65 @@ export function invalidateSessionCache(sessionName) {
45
45
  if (sessionName == null) {
46
46
  _captureCache.clear()
47
47
  _cwdCache.clear()
48
+ _statusGate.clear()
48
49
  return
49
50
  }
50
51
  _captureCache.delete(sessionName)
51
52
  _cwdCache.delete(sessionName)
53
+ _statusGate.delete(sessionName)
54
+ }
55
+
56
+ /**
57
+ * processing → waiting/idle の「降格」だけ独立 2 サンプルで確定するヒステリシス。
58
+ *
59
+ * capture-pane は claude TUI の再描画途中 (フッター消去〜再描画の間) を拾うことが
60
+ * あり、生成中なのに一瞬 waiting/idle と誤読する。この単発の誤読が frontend の
61
+ * ターン表示 (三点リーダー) やステータスドットを消す方向に直撃するため、降格は
62
+ * 「同じ非 processing 値が STATUS_DOWNGRADE_CONFIRM_MS 以上あけて 2 回読めた」
63
+ * ときだけ確定し、それまでは processing を維持する。
64
+ * - 昇格 (→ processing) は即時 (誤読で点く分には次サンプルで自然回復し、実害が小さい)
65
+ * - 確定窓は capture キャッシュ TTL (CAPTURE_TTL_MS=2.5s) より長く取り、同一
66
+ * キャッシュサンプルの 2 回読みで誤確定しないようにする
67
+ * - 実ターン終了の体感は frontend の hook 由来 session.event 'stop' が即時に担う
68
+ * ため、ここで数秒遅れてもサイドバードットが ~5s 遅れるだけで UX 影響は無い
69
+ */
70
+ const STATUS_DOWNGRADE_CONFIRM_MS = Number(
71
+ process.env.HUB_AGENT_STATUS_DOWNGRADE_MS ?? 3000,
72
+ )
73
+ /** @type {Map<string, {lastConfirmed: string|null, pending: {status: string, at: number}|null}>} */
74
+ const _statusGate = new Map()
75
+
76
+ export function debounceStatusDowngrade(sessionName, rawStatus, now = Date.now()) {
77
+ if (!sessionName) return rawStatus
78
+ const g = _statusGate.get(sessionName) || { lastConfirmed: null, pending: null }
79
+ // processing への昇格 / processing 以外からの遷移は即時確定。
80
+ if (rawStatus === "processing" || g.lastConfirmed !== "processing") {
81
+ g.lastConfirmed = rawStatus
82
+ g.pending = null
83
+ _statusGate.set(sessionName, g)
84
+ return rawStatus
85
+ }
86
+ // lastConfirmed=processing からの降格要求: 同値の 2 サンプル目 (確定窓経過後) で確定。
87
+ if (
88
+ g.pending &&
89
+ g.pending.status === rawStatus &&
90
+ now - g.pending.at >= STATUS_DOWNGRADE_CONFIRM_MS
91
+ ) {
92
+ g.lastConfirmed = rawStatus
93
+ g.pending = null
94
+ _statusGate.set(sessionName, g)
95
+ return rawStatus
96
+ }
97
+ if (!g.pending || g.pending.status !== rawStatus) {
98
+ g.pending = { status: rawStatus, at: now }
99
+ }
100
+ _statusGate.set(sessionName, g)
101
+ return "processing"
102
+ }
103
+
104
+ /** テスト用: 降格ヒステリシスの状態を空にする。 */
105
+ export function _resetStatusGate() {
106
+ _statusGate.clear()
52
107
  }
53
108
 
54
109
  const CONTEXT_PATTERNS = [
@@ -288,7 +343,10 @@ export async function detectSessionState(sessionName, opts = {}) {
288
343
  })
289
344
  if (hookResult?.result) {
290
345
  return {
291
- status: hookResult.result.status || defaultStatus,
346
+ status: debounceStatusDowngrade(
347
+ sessionName,
348
+ hookResult.result.status || defaultStatus,
349
+ ),
292
350
  context_pct: hookResult.result.context_pct ?? defaultContextPct,
293
351
  permission_mode:
294
352
  hookResult.result.permission_mode ?? defaultPermissionMode,
@@ -297,7 +355,7 @@ export async function detectSessionState(sessionName, opts = {}) {
297
355
  }
298
356
 
299
357
  return {
300
- status: defaultStatus,
358
+ status: debounceStatusDowngrade(sessionName, defaultStatus),
301
359
  context_pct: defaultContextPct,
302
360
  permission_mode: defaultPermissionMode,
303
361
  }