@cocorograph/hub-agent 0.7.5 → 0.7.7

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.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
package/src/main.mjs CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  detectSessionState,
40
40
  invalidateSessionCache,
41
41
  listSessionStates,
42
+ resolveBindSnapshotStatus,
42
43
  StallTracker,
43
44
  } from "./state.mjs"
44
45
  import {
@@ -600,6 +601,41 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
600
601
  // claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
601
602
  // respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
602
603
  ctx.readinessTracker = readinessTracker
604
+ // 【+新規 defer — 構造版 root 2026-06-22】busy (ターン進行中) のペインへ来た fresh (+新規) は
605
+ // respawn-pane -k で殺さず保留し、ready (ターン終了) で 1 回だけ実行する。
606
+ // session_name → {cwd, request_id, at, model, permissionMode}。
607
+ const pendingFreshRespawn = new Map()
608
+ ctx.pendingFreshRespawn = pendingFreshRespawn
609
+ // fresh respawn のスロットル: idle ペインへの remount storm (再マウント毎に新 request_id) で
610
+ // fresh が連打され new セッションを乱造するのを防ぐ。session_name → epoch ms。
611
+ const lastFreshRespawnAt = new Map()
612
+ ctx.lastFreshRespawnAt = lastFreshRespawnAt
613
+ // ready エッジで呼ばれ、保留中の +新規 を実行する (busy が解けたので非破壊)。
614
+ const flushDeferredFreshRespawn = async (name) => {
615
+ const pending = pendingFreshRespawn.get(name)
616
+ if (!pending) return
617
+ pendingFreshRespawn.delete(name)
618
+ if (Date.now() - pending.at > PENDING_FRESH_TTL_MS) {
619
+ logger.info({ session: name }, "tui rebind: deferred +new expired before ready, dropped")
620
+ return
621
+ }
622
+ try {
623
+ const rebind = await rebindClaudeSession(name, null, {
624
+ cwd: pending.cwd,
625
+ model: pending.model || "",
626
+ permissionMode: pending.permissionMode || "",
627
+ fresh: true,
628
+ busy: false,
629
+ logger,
630
+ })
631
+ if (rebind.ok) {
632
+ lastFreshRespawnAt.set(name, Date.now())
633
+ logger.info({ session: name }, "tui rebind: executed deferred +new after ready")
634
+ }
635
+ } catch (err) {
636
+ logger.warn({ session: name, err: err?.message }, "deferred +new respawn failed")
637
+ }
638
+ }
603
639
  const stateLoop = startStateLoop({
604
640
  client,
605
641
  plugins,
@@ -623,6 +659,7 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
623
659
  logger,
624
660
  tracker: readinessTracker,
625
661
  intervalMs: 700,
662
+ onReady: flushDeferredFreshRespawn,
626
663
  })
627
664
 
628
665
  // TUI 権限ブリッジ: 対話 TUI の PreToolUse フックが書く権限要求を fs.watch で
@@ -951,16 +988,51 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
951
988
  }
952
989
  }
953
990
 
991
+ /**
992
+ * 【構造版 root 2026-06-22】idle ペインへの fresh remount storm を 1 回へ collapse する閾値。
993
+ * 直近 fresh respawn からこの時間内の同一セッションへの fresh は idempotent な再送とみなす。
994
+ */
995
+ const FRESH_RESPAWN_THROTTLE_MS = 8_000
996
+ /** busy で defer した +新規 の保留 TTL。ready が来ないまま席を立った等で破棄する。 */
997
+ const PENDING_FRESH_TTL_MS = 600_000
998
+
999
+ /**
1000
+ * 【構造版 root 2026-06-22】bind 時の即時 busy プローブ (プロセス実体)。
1001
+ * 「ターン進行中の claude を kill しない」判定の唯一の根拠にする。capture スクレイプや armed の
1002
+ * 140s cap の脆さに依存しない:
1003
+ * - isArmed: prompt_submit→ready の間 true (生成中 + Stop フック中を含む)。一次シグナル。
1004
+ * - 補強: pane_pid 子孫数 > 学習 baseline を ps スナップショットで直接確認 (armed cap / 未観測の
1005
+ * 隙間でも取りこぼさない)。baseline 未学習 (= ターン未観測の純 idle) は false (idle の +新規を妨げない)。
1006
+ * 判定不能時は安全側 false (= kill 許可) で idle の +新規 を妨げない。armed が一次防御なので、
1007
+ * 真にターン中なら isArmed=true でここに来る前に true を返す。
1008
+ */
1009
+ async function isPaneBusyNow(readinessTracker, name) {
1010
+ if (!name || !readinessTracker) return false
1011
+ if (readinessTracker.isArmed(name) === true) return true
1012
+ try {
1013
+ const panePid = await getPanePid(name)
1014
+ if (!panePid) return false
1015
+ const procMap = await snapshotProcs()
1016
+ if (procMap.size === 0) return false
1017
+ const baseline = readinessTracker.getBaseline(name)
1018
+ if (baseline === null) return false
1019
+ return busyChildCount(panePid, procMap) > baseline
1020
+ } catch {
1021
+ return false
1022
+ }
1023
+ }
1024
+
954
1025
  /**
955
1026
  * arm 済みセッション (prompt_submit / stop を受けた or busy 観測中) の claude プロセスを
956
1027
  * 高速 poll し、子プロセスが baseline へ復帰したエッジで event ファイルに 'ready' を書く。
957
1028
  * 既存の startSessionEventWatcher がそれを `session.event` 'ready' として push し、frontend は
958
1029
  * 「真に入力可能」になった瞬間として消灯 + キューフラッシュする (Stop フック中の誤フラッシュ
959
1030
  * 根治)。tracker.getBusy() は state loop が proc_busy レベルとして相乗せする。
1031
+ * onReady(name): ready エッジで呼ぶコールバック (busy で defer した +新規 の実行に使う)。
960
1032
  *
961
1033
  * 負荷: アイドル (armed/busy なし) のときは ps を spawn しない。claude pid は TTL キャッシュ。
962
1034
  */
963
- function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
1035
+ function startReadinessLoop({ tracker, logger, intervalMs = 700, onReady }) {
964
1036
  let stopped = false
965
1037
  const PANE_PID_TTL_MS = 30_000
966
1038
  const panePidByName = new Map() // name → {pid, at}
@@ -1016,6 +1088,12 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
1016
1088
  const { ready } = tracker.observe(name, count)
1017
1089
  if (ready) {
1018
1090
  await writeSessionEventFile(name, "ready", Date.now())
1091
+ // busy で defer した +新規 をターン終了 (ready) のここで 1 回だけ実行する。
1092
+ try {
1093
+ await onReady?.(name)
1094
+ } catch (err) {
1095
+ logger?.warn({ err: err?.message }, "readiness onReady callback failed")
1096
+ }
1019
1097
  }
1020
1098
  }
1021
1099
  } catch (err) {
@@ -2145,8 +2223,22 @@ async function dispatch(msg, ctx) {
2145
2223
  // (症状D 再発 D2: armed 140s cap / capture gap で generating=false に倒れ、--resume bind
2146
2224
  // でも実行中 claude を kill していた)を、capture/armed に依存しない pane_current_command で防ぐ。
2147
2225
  const paneRunningClaude = await isPaneRunningClaude(sessionName, { logger })
2226
+ // 【構造版 root 2026-06-22】プロセス実体の busy (ターン進行中) を bind 時に確定する。
2227
+ // 「動いている claude を kill しない」の唯一の判定基準。frontend が fresh:true を誤送しても
2228
+ // 生成中 claude を殺さない (frontend 単独依存からの脱却)。
2229
+ const busy = await isPaneBusyNow(ctx.readinessTracker, sessionName)
2230
+ // 非 fresh (resume/auto) の bind = ユーザーが既存会話を選んだ → 保留中の +新規 defer は破棄。
2231
+ if (!fresh) ctx.pendingFreshRespawn.delete(sessionName)
2148
2232
  let rebind = { ok: false, skipped: false }
2149
2233
  const bindKey = fresh ? `fresh:${request_id || ""}` : targetId
2234
+ const recordDeferredFresh = () =>
2235
+ ctx.pendingFreshRespawn.set(sessionName, {
2236
+ cwd,
2237
+ request_id,
2238
+ at: Date.now(),
2239
+ model: ctx.config?.claude_model || "",
2240
+ permissionMode: ctx.config?.claude_permission_mode || "",
2241
+ })
2150
2242
  if (bindKey && sessionName && (fresh || targetId)) {
2151
2243
  if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
2152
2244
  rebind = { ok: true, skipped: true }
@@ -2157,16 +2249,37 @@ async function dispatch(msg, ctx) {
2157
2249
  targetId,
2158
2250
  newestId,
2159
2251
  paneRunningClaude,
2252
+ busy,
2160
2253
  })
2161
2254
  ) {
2162
- // 生成中 claude を再接続/remount bind kill しない (謎停止対策)。respawn を抑止して
2163
- // 既存の生成中 claude を温存する。bindKey は記録して以降の remount を冪等化する
2164
- // (targetId === newestId = 動いているセッション本人なので記録は正しい)。
2255
+ // busy (ターン進行中) または最新セッションへの再 bind respawn を抑止し生成中 claude を温存。
2256
+ rebind = { ok: true, skipped: true }
2257
+ if (fresh && busy) {
2258
+ // ユーザーの +新規 を ready (ターン終了) まで defer する (in-flight ターンを殺さない)。
2259
+ recordDeferredFresh()
2260
+ logger?.info(
2261
+ { session: sessionName },
2262
+ "tui rebind: deferred +new (pane busy) until turn ends",
2263
+ )
2264
+ } else {
2265
+ // 非 fresh の冪等記録 (動いているセッション本人への再 bind)。
2266
+ ctx.tuiReboundSessions.set(sessionName, bindKey)
2267
+ logger?.info(
2268
+ { session: sessionName, session_id: targetId },
2269
+ "tui rebind: skipped respawn (turn in progress on the running session)",
2270
+ )
2271
+ }
2272
+ } else if (
2273
+ fresh &&
2274
+ Date.now() - (ctx.lastFreshRespawnAt.get(sessionName) || 0) <
2275
+ FRESH_RESPAWN_THROTTLE_MS
2276
+ ) {
2277
+ // idle ペインへの fresh remount storm (再マウント毎に新 request_id) を 1 回へ collapse。
2278
+ // 直近 fresh respawn から閾値内の fresh は再送とみなし skip (new セッション乱造を防ぐ)。
2165
2279
  rebind = { ok: true, skipped: true }
2166
- ctx.tuiReboundSessions.set(sessionName, bindKey)
2167
2280
  logger?.info(
2168
- { session: sessionName, session_id: targetId },
2169
- "tui rebind: skipped respawn (turn in progress on the running session)",
2281
+ { session: sessionName },
2282
+ "tui rebind: throttled fresh respawn (idempotent remount within window)",
2170
2283
  )
2171
2284
  } else {
2172
2285
  rebind = await rebindClaudeSession(sessionName, targetId, {
@@ -2174,9 +2287,17 @@ async function dispatch(msg, ctx) {
2174
2287
  model: ctx.config?.claude_model || "",
2175
2288
  permissionMode: ctx.config?.claude_permission_mode || "",
2176
2289
  fresh,
2290
+ busy,
2177
2291
  logger,
2178
2292
  })
2179
- if (rebind.ok) ctx.tuiReboundSessions.set(sessionName, bindKey)
2293
+ if (rebind.ok) {
2294
+ ctx.tuiReboundSessions.set(sessionName, bindKey)
2295
+ if (fresh) ctx.lastFreshRespawnAt.set(sessionName, Date.now())
2296
+ } else if (rebind.skipped && rebind.reason === "busy" && fresh) {
2297
+ // チョークポイントが busy で弾いた (probe が false でも実行直前にターン開始した競合)。
2298
+ // defer に載せて +新規 を取りこぼさない。
2299
+ recordDeferredFresh()
2300
+ }
2180
2301
  }
2181
2302
  }
2182
2303
  reply({
@@ -2215,25 +2336,25 @@ async function dispatch(msg, ctx) {
2215
2336
  noCache: true,
2216
2337
  logger,
2217
2338
  })
2218
- // 症状C対策: 生成中(armed / pane=processing で判定済みの generating)に TUIビューを
2219
- // 再マウントすると、frontend は全 ref/state がリセットされ、capture 再描画ギャップで
2220
- // この noCache スナップショットの status が一瞬 'waiting' に化けると「生成中なのに
2221
- // 三点リーダーが消える」偽陰性になり、shouldQueue=false でメッセージが PTY(TUI
2222
- // ネイティブキュー)へ流入する。generating を真として status='processing' を配ることで
2223
- // capture 誤読を masking し、frontend の再点灯(turnActive)と shouldQueue 第1ゲートを
2224
- // 即座に効かせる。proc_busy は配らない: outputActive(出力残り火)を含むため、ターン
2225
- // 終了直後の真 idle セッションでも proc_busy=true となり高速消灯(症状B)を再発させる。
2226
- // 終了後(非 generating)は snap.status(waiting)で従来どおり高速消灯する。
2339
+ // 【問題1 根治 2026-06-22】配る status はプロセス実体の busy を権威に解決する
2340
+ // (resolveBindSnapshotStatus)。capture(SPINNER_LINE_RE)の偽陽性に勝たせる。
2341
+ // - busy=true (ターン進行中): capture gap で snap.status waiting に化けても
2342
+ // processing を強制 = 症状C(再マウントで生成中の三点リーダーが消える偽陰性)の masking。
2343
+ // - busy=false (プロセス実体 idle): snap.status processing でも、それは静的完了行の
2344
+ // 偽陽性(無生成時の誤点灯=問題1)なので processing を配らず waiting へ降格する。bind
2345
+ // noCache 単発読みで freeze 履歴が無く偽陽性を弾けないため、ここで busy を権威にする。
2346
+ // generating(capture 由来)でなく busy(isArmed + 子孫数>baseline)を使うのが肝。
2347
+ // proc_busy は配らない: outputActive(出力残り火)を含み真 idle でも true になり高速消灯
2348
+ // (症状B)を再発させるため。
2349
+ const snapStatus = resolveBindSnapshotStatus(busy, snap.status)
2227
2350
  ctx.client.send({
2228
2351
  type: "session.state",
2229
2352
  session_name: sessionName,
2230
- status: generating ? "processing" : snap.status,
2353
+ status: snapStatus,
2231
2354
  context_pct: snap.context_pct,
2232
2355
  permission_mode: snap.permission_mode,
2233
2356
  stable:
2234
- !generating &&
2235
- snap.status !== "processing" &&
2236
- snap.stable === true,
2357
+ !busy && snapStatus !== "processing" && snap.stable === true,
2237
2358
  })
2238
2359
  } catch (err) {
2239
2360
  logger?.warn(
@@ -324,6 +324,12 @@ export class ReadinessTracker {
324
324
  return this.byName.get(name)?.busy ?? false
325
325
  }
326
326
 
327
+ /** 学習済み idle baseline (子孫プロセス数の床)。null=未学習。bind ハンドラの即時 busy
328
+ * プローブ (count > baseline で「ターン進行中」を実プロセスから判定) に使う。 */
329
+ getBaseline(name) {
330
+ return this.byName.get(name)?.baseline ?? null
331
+ }
332
+
327
333
  /** ターン進行中か (prompt_submit で arm 後、ready/cap で disarm するまで true)。
328
334
  * 生成中・Stop フック中の両方を含む。state loop が権威的な stall 判定に使う。 */
329
335
  isArmed(name) {
package/src/state.mjs CHANGED
@@ -243,8 +243,13 @@ function workingSpinnerLine(text) {
243
243
  * 不変」なら凍結とみなす。閾値は capture キャッシュ TTL (2.5s) と state loop 周期 (5s) を跨ぐ
244
244
  * 6s に取り、ライブタイマーの 1 サンプル取りこぼしでは誤判定しないようにする。
245
245
  */
246
+ // 静的な完了行 (● Updated…/⏺ Ran tool (2s) 等、ターン終了後もアイドル画面に残る) を
247
+ // SPINNER_LINE_RE が誤検出して processing に張り付く「無生成時の三点リーダー誤点灯」(問題1) の
248
+ // 滞留時間を短縮するため 6000→3500 に下げた (2026-06-22)。実スピナーは経過秒 (Ns) かグリフ
249
+ // アニメで毎秒変化するため frozen には絶対ならず、閾値短縮で偽陰性(生成中の消失)は再発しない
250
+ // (3500ms ≫ 1s アニメ周期で十分な余裕)。env で上書き可。
246
251
  const SPINNER_FREEZE_CONFIRM_MS = Number(
247
- process.env.HUB_AGENT_SPINNER_FREEZE_MS ?? 6000,
252
+ process.env.HUB_AGENT_SPINNER_FREEZE_MS ?? 3500,
248
253
  )
249
254
  /** @type {Map<string, {line: string, at: number}>} session名 → 最初にその行を見た時刻 */
250
255
  const _spinnerFreezeByName = new Map()
@@ -277,6 +282,29 @@ export function detectStatusFromText(text) {
277
282
  return "idle"
278
283
  }
279
284
 
285
+ /**
286
+ * 【問題1 根治 2026-06-22】bind 時の即時ステータススナップショットで配る status を、
287
+ * プロセス実体の busy を権威として解決する。capture (SPINNER_LINE_RE) の偽陽性に勝たせる。
288
+ *
289
+ * - busy=true (isArmed or pane_pid 子孫数 > baseline = ターン進行中): capture gap で
290
+ * capturedStatus が waiting に化けても processing を強制 (症状C=再マウントで生成中の三点
291
+ * リーダーが消える偽陰性の masking)。
292
+ * - busy=false (プロセス実体 idle): capturedStatus が processing でも、それは静的完了行の
293
+ * SPINNER_LINE_RE 偽陽性であって生成中ではないので processing を配らない (= waiting へ降格)。
294
+ * bind は noCache 単発読みで freeze 履歴が無く偽陽性を弾けないため、ここで busy を権威にする。
295
+ * 万一 no-hook セッションの無音生成中だった場合も、次の state loop tick(≤5s)が outputActive で
296
+ * processing に復帰させるので実害は限定的。
297
+ *
298
+ * @param {boolean} busy プロセス実体の busy (isArmed or 子孫数>baseline)
299
+ * @param {string} capturedStatus detectSessionState の status
300
+ * @returns {string}
301
+ */
302
+ export function resolveBindSnapshotStatus(busy, capturedStatus) {
303
+ if (busy) return "processing"
304
+ if (capturedStatus === "processing") return "waiting"
305
+ return capturedStatus
306
+ }
307
+
280
308
  export function detectContextPctFromText(text) {
281
309
  for (const re of CONTEXT_PATTERNS) {
282
310
  const m = text.match(re)
package/src/tmux.mjs CHANGED
@@ -1331,20 +1331,40 @@ export function shouldSkipRebindRespawn({
1331
1331
  targetId,
1332
1332
  newestId,
1333
1333
  paneRunningClaude,
1334
+ busy = false,
1334
1335
  } = {}) {
1336
+ // 【最優先・無条件の安全弁 — 構造版 根治 2026-06-22】ペインが現にターン進行中 (busy) なら、
1337
+ // fresh / resume / switch のいずれであっても respawn-pane -k で実行中 claude を kill しない。
1338
+ // busy は呼び出し側がプロセス実体 (ReadinessTracker.isArmed = prompt_submit→ready の間、
1339
+ // または pane_pid 子孫数 > 学習 baseline) で算出する堅い信号で、capture スクレイプや
1340
+ // armed 140s cap の脆さに依存しない。これにより「動いている claude は誰の要求でも殺さない」を
1341
+ // 唯一の破壊点 (rebindClaudeSession) の手前で構造的に保証する。
1342
+ // 旧バグ: `if (fresh) return false` が fresh:true を無条件に respawn(=kill) へ通し、防御を完全に
1343
+ // frontend (fresh を送らない前提) 単独依存にしていた。frontend の 1 経路でも fresh が漏れると
1344
+ // 生成中 claude が死ぬ事故 (症状D) が再発した。busy ゲートで前提依存を断つ。
1345
+ // fresh の +新規 要求が busy で抑止された場合、呼び出し側は busy 解消 (ready) まで defer する。
1346
+ if (busy) return true
1335
1347
  if (fresh) return false
1336
- // 実行中 claude を kill しない最優先ガード(症状D / 再発 D2 の根治): ペインが現に claude
1337
- // 実行中で、最新セッション(= 今動いている会話)への再 bind(remount/reconnect)なら、respawn
1338
- // (respawn-pane -k) は破壊的なだけで不要。generating の脆い検出(isArmed 140s cap / capture gap)
1339
- // で false に倒れても、実プロセス由来の paneRunningClaude が守る。targetId 未指定(= newest 解決前)
1340
- // も「最新への再 bind」とみなす。明示的な別会話への切替(targetId !== newestId)と +新規(fresh)は
1341
- // 従来どおり respawn する。
1348
+ // 実行中 claude を kill しない補助ガード: ペインが現に claude を実行中で、最新セッション
1349
+ // (= 今動いている会話)への再 bind(remount/reconnect)なら respawn は破壊的なだけで不要。
1350
+ // busy false に倒れても (cap/未観測)、pane_current_command 由来の paneRunningClaude が守る。
1342
1351
  if (paneRunningClaude && (!targetId || targetId === newestId)) return true
1343
1352
  if (!generating) return false
1344
1353
  return !!targetId && targetId === newestId
1345
1354
  }
1346
1355
 
1347
1356
  export async function rebindClaudeSession(name, sessionId, opts = {}) {
1357
+ // 【破壊点チョークポイント — 最終安全弁 2026-06-22】respawn-pane -k を実行する唯一の関数。
1358
+ // busy=true (ターン進行中) なら shouldSkipRebindRespawn を経由せず直接呼ばれても respawn を
1359
+ // 絶対に発行しない。これで「動いている claude を kill しない」不変条件を実行地点 1 箇所で
1360
+ // 保証する (呼び出し側の判定漏れに対する多層防御)。呼び出し側が ready まで defer する。
1361
+ if (opts.busy === true) {
1362
+ opts.logger?.info(
1363
+ { session: name, fresh: !!opts.fresh },
1364
+ "tui rebind: skipped respawn at chokepoint (pane busy, deferred)",
1365
+ )
1366
+ return { ok: false, skipped: true, reason: "busy" }
1367
+ }
1348
1368
  let cmd
1349
1369
  try {
1350
1370
  // fresh=新規起動 (--resume 無し)。それ以外は session_id 検証込みの resume。