@cocorograph/hub-agent 0.6.99 → 0.7.1

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.99",
3
+ "version": "0.7.1",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -321,13 +321,22 @@ export function decideSessionRotation({
321
321
  newestSessionId,
322
322
  lastNotifiedNewId,
323
323
  lastNotifiedAt,
324
+ lastNotifiedCount = 0,
324
325
  now,
325
326
  reNotifyMs = 4000,
327
+ maxReNotify = 5,
326
328
  } = {}) {
327
329
  if (!newestSessionId || !viewingSessionId) return { rotated: false }
328
330
  if (newestSessionId === viewingSessionId) return { rotated: false }
329
331
  if (newestSessionId === lastNotifiedNewId) {
330
332
  // 同一 new。時刻情報が揃っていて再通知間隔を超えていれば、固着救済のため再通知する。
333
+ // ただし安全弁: 同一 (viewing→newest) ペアを maxReNotify 回再通知してもフロントが
334
+ // 採用しない場合は打ち切る (capped)。フロントが viewing を更新しない=外来 id 汚染や
335
+ // 別 cwd の rotated 誤採用の兆候で、これを無制限に再通知すると rotated 無限ピンポンに
336
+ // なる (perf監査: cockpit↔cocomiru で rotated 244件/h を実測)。
337
+ if (lastNotifiedCount >= maxReNotify) {
338
+ return { rotated: false, capped: true }
339
+ }
331
340
  if (
332
341
  typeof now === "number" &&
333
342
  typeof lastNotifiedAt === "number" &&
package/src/main.mjs CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  capturePane,
38
38
  detectPermissionModeFromText,
39
39
  detectSessionState,
40
+ invalidateSessionCache,
40
41
  listSessionStates,
41
42
  StallTracker,
42
43
  } from "./state.mjs"
@@ -68,6 +69,7 @@ import {
68
69
  listWorktreeNameHistory,
69
70
  listWorktreeStubs,
70
71
  rebindClaudeSession,
72
+ shouldSkipRebindRespawn,
71
73
  recoverTuiInput,
72
74
  removeWorktree as removeWorktreeDir,
73
75
  resumeWithMessage,
@@ -593,6 +595,9 @@ export async function startDaemon({ version, ptyModule, claudeSdk } = {}) {
593
595
  // 終わって真に入力可能になった」エッジを検出する (proc-introspect.mjs)。capture-pane は
594
596
  // Stop フック実行中を観測できないため、これが「stop の早期消灯/誤フラッシュ」の根治。
595
597
  const readinessTracker = new ReadinessTracker()
598
+ // claude.tui.bind ハンドラが「生成中か (isArmed)」を参照して、生成中 claude を rebind の
599
+ // respawn-pane -k で kill する「謎停止」を防ぐ (生成中ガード)。
600
+ ctx.readinessTracker = readinessTracker
596
601
  const stateLoop = startStateLoop({
597
602
  client,
598
603
  plugins,
@@ -891,17 +896,18 @@ async function startSessionEventWatcher({ client, logger, readinessTracker }) {
891
896
  readinessTracker.arm(sessionName)
892
897
  }
893
898
  }
894
- // 計装 (2026-06-19): ターン境界イベントと arm を成功送信時に記録する。従来は session.event
895
- // WS 未接続スキップ時しかログに残らず、arm→ターン進行→ready の追跡が事後にできなかった
896
- // (生成中/停止判定の障害が「ログから盲目」だった主因)。armed は prompt_submit/stop/idle_hint で立つ。
897
- logger?.info(
898
- {
899
- session_name: sessionName,
900
- event: data.event,
901
- armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
902
- },
903
- "session.event push",
904
- )
899
+ // 計装 (2026-06-19, 既定 OFF=2026-06-20): ターン境界イベントと arm を記録する。常時 info は
900
+ // ログ肥大の主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す(障害解析時に有効化)。
901
+ if (STATE_TRACE) {
902
+ logger?.info(
903
+ {
904
+ session_name: sessionName,
905
+ event: data.event,
906
+ armed: readinessTracker ? readinessTracker.isArmed(sessionName) : undefined,
907
+ },
908
+ "session.event push",
909
+ )
910
+ }
905
911
  client.send({
906
912
  type: "session.event",
907
913
  session_name: sessionName,
@@ -1037,7 +1043,15 @@ function startReadinessLoop({ tracker, logger, intervalMs = 700 }) {
1037
1043
  // いれば「出力ストリーミング中」とみなし proc_busy に OR する。state loop は 5s 周期なので、
1038
1044
  // 連続生成中は毎 tick 署名が変わり常時 true、出力が止まれば次 tick (≤5s) で署名不変になり
1039
1045
  // 窓経過後に false へ落ちる (ターン終了後のスピナー固着=症状A を悪化させない短さ)。
1040
- const OUTPUT_ACTIVE_MS = Number(process.env.HUB_AGENT_OUTPUT_ACTIVE_MS ?? 8000)
1046
+ // 出力フロー検知窓。state loop tick(5000ms)の整数倍にして、出力が止まった後の outputActive
1047
+ // 5s tick 境界で true↔false フラッピングするのを防ぐ(8000 は非整数倍で proc_busy 単独反転 push が
1048
+ // 多発していた=perf監査。10000=2tick で安定側、影響は『出力停止とみなす猶予 8s→10s』のみ)。
1049
+ const OUTPUT_ACTIVE_MS = Number(process.env.HUB_AGENT_OUTPUT_ACTIVE_MS ?? 10000)
1050
+
1051
+ // 状態信号の計装ログ(session.state/event push)を出すか。既定 OFF。障害解析時に
1052
+ // HUB_AGENT_STATE_TRACE=1 で一時的に有効化する(常時 info 出力はログ肥大の主因=perf監査で
1053
+ // 直近1h ログの約半分を占めると実測。挙動には一切影響しない純ログ)。
1054
+ const STATE_TRACE = process.env.HUB_AGENT_STATE_TRACE === "1"
1041
1055
 
1042
1056
  // 権威的な「応答停止 (ハング)」判定の無進捗しきい値。ターン進行中 (armed) なのに、実際の
1043
1057
  // 進捗 (子プロセス実行中 / 出力フロー / ペインのライブ processing) がこの時間継続して観測
@@ -1175,10 +1189,10 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1175
1189
  proc_busy: procBusy,
1176
1190
  stalled,
1177
1191
  })
1178
- // 計装 (2026-06-19): 状態の「変化時」(差分送信なので低頻度) に値を記録する。従来は
1179
- // session.state WS 未接続スキップ時しかログに残らず、proc_busy/stalled/status の実値が
1180
- // 事後追跡できなかった (生成中/停止判定の障害がログから検証不能だった主因)。childBusy と
1181
- // outputActive を分けて出し、proc 内省と出力フローのどちらが busy を立てたか切り分け可能にする。
1192
+ // 計装 (2026-06-19, 既定 OFF=2026-06-20): 状態変化時に値を記録する。常時 info はログ肥大の
1193
+ // 主因のため HUB_AGENT_STATE_TRACE=1 のときだけ出す。childBusy/outputActive を分けて出し、
1194
+ // proc 内省と出力フローのどちらが busy を立てたか切り分け可能にする(障害解析用)。
1195
+ if (STATE_TRACE) {
1182
1196
  logger?.info(
1183
1197
  {
1184
1198
  session_name: s.session_name,
@@ -1193,6 +1207,7 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1193
1207
  },
1194
1208
  "session.state push",
1195
1209
  )
1210
+ }
1196
1211
  client.send({
1197
1212
  type: "session.state",
1198
1213
  session_name: s.session_name,
@@ -1204,6 +1219,22 @@ function startStateLoop({ client, plugins, logger, intervalMs, claudeBridge, rea
1204
1219
  stalled,
1205
1220
  })
1206
1221
  }
1222
+ // 消滅セッションの GC (perf監査/メモリリーク対策): tmux から消えたセッションの状態 Map を
1223
+ // まとめて解放する。clear() は再接続時(onReopen)のみのため、worktree 案件の生成/破棄で
1224
+ // 各 per-session Map が線形増加し RSS が無入力時も成長していた。tmux が一時的に空応答
1225
+ // (states=[]) のときは全消去しないよう states.length>0 をガードにする。
1226
+ if (states.length > 0) {
1227
+ const liveNames = new Set(states.map((s) => s.session_name))
1228
+ for (const name of [...lastByName.keys()]) {
1229
+ if (liveNames.has(name)) continue
1230
+ invalidateSessionCache(name) // capture/cwd/statusGate/spinnerFreeze/stability (state.mjs)
1231
+ stallTracker.forget(name) // StallTracker.byName
1232
+ readinessTracker?.forget?.(name) // ReadinessTracker.byName
1233
+ lastByName.delete(name)
1234
+ outputFlowByName.delete(name)
1235
+ lastTurnAtByName.delete(name)
1236
+ }
1237
+ }
1207
1238
  }
1208
1239
  } catch (err) {
1209
1240
  logger?.warn({ err: err.message }, "state loop tick failed")
@@ -1471,13 +1502,26 @@ async function dispatch(msg, ctx) {
1471
1502
  }
1472
1503
  handleUntrackedPtyData(msg, ctx)
1473
1504
  return
1474
- case "pty.resize":
1475
- ctx.ptyBridge.resize({
1505
+ case "pty.resize": {
1506
+ const resized = ctx.ptyBridge.resize({
1476
1507
  stream_id: msg.stream_id,
1477
1508
  cols: msg.cols,
1478
1509
  rows: msg.rows,
1479
1510
  })
1511
+ // 自己修復 (perf監査 Phase3): stream 不在で resize できなかった (reap 済み等) ときは
1512
+ // pty.error(stream_missing) を返し browser を再 attach させる。keepalive resize が dead
1513
+ // stream を即検知して 4 分単位の「pty.resize but stream missing」churn を自己修復する
1514
+ // (handleTrackedPtyData/handleUntrackedPtyData と同じパターン)。resize 例外 (pty 存在) も
1515
+ // false だが、その場合の再 attach は既存 stream への無害な再接続。
1516
+ if (!resized && msg.stream_id) {
1517
+ ctx.client.send({
1518
+ type: "pty.error",
1519
+ stream_id: msg.stream_id,
1520
+ error: "stream_missing",
1521
+ })
1522
+ }
1480
1523
  return
1524
+ }
1481
1525
  case "pty.detach":
1482
1526
  ctx.ptyBridge.detach({ stream_id: msg.stream_id })
1483
1527
  return
@@ -1688,20 +1732,37 @@ async function dispatch(msg, ctx) {
1688
1732
  const key = viewName || viewCwd
1689
1733
  const prev = ctx.tuiRotationNotified.get(key)
1690
1734
  const now = Date.now()
1691
- const { rotated, newSessionId } = decideSessionRotation({
1735
+ const prevNewId =
1736
+ prev && typeof prev === "object" ? prev.newId : prev
1737
+ const prevCount =
1738
+ prev && typeof prev === "object" ? (prev.count ?? 0) : 0
1739
+ const { rotated, newSessionId, capped } = decideSessionRotation({
1692
1740
  viewingSessionId: viewSid,
1693
1741
  newestSessionId: newestId,
1694
- // 旧形式 (文字列) と新形式 ({newId, ts}) の両対応。
1695
- lastNotifiedNewId:
1696
- prev && typeof prev === "object" ? prev.newId : prev,
1742
+ // 旧形式 (文字列) と新形式 ({newId, ts, count}) の両対応。
1743
+ lastNotifiedNewId: prevNewId,
1697
1744
  lastNotifiedAt:
1698
1745
  prev && typeof prev === "object" ? prev.ts : null,
1746
+ lastNotifiedCount: prevCount,
1699
1747
  now,
1700
1748
  })
1749
+ if (capped) {
1750
+ // 安全弁発火: フロントが採用しないまま maxReNotify 回再通知済み。これ以上の
1751
+ // rotated 連発を止め、無限ピンポンを断つ (フロント側 cwd 必須化で通常は到達しない)。
1752
+ logger.warn(
1753
+ { session: viewName, cwd: viewCwd, view: viewSid, newest: newestId },
1754
+ "tui session rotation: capped (frontend not adopting; stopped re-notify)",
1755
+ )
1756
+ return
1757
+ }
1701
1758
  if (!rotated) return
1702
- // {newId, ts} で保存し、同一 new への再通知をスロットリングする
1703
- // (固着した viewer throttle 間隔ごとに 1 回ずつ救済通知を受ける)。
1704
- ctx.tuiRotationNotified.set(key, { newId: newSessionId, ts: now })
1759
+ // {newId, ts, count} で保存。同一 new への再通知をスロットリング+回数キャップする。
1760
+ // newId が変われば count 1 にリセット、同一なら加算する。
1761
+ ctx.tuiRotationNotified.set(key, {
1762
+ newId: newSessionId,
1763
+ ts: now,
1764
+ count: newSessionId === prevNewId ? prevCount + 1 : 1,
1765
+ })
1705
1766
  // 冪等マップも新 id へ更新し、将来の remount bind が旧 id へ
1706
1767
  // respawn (claude 再起動) してしまうのを防ぐ。
1707
1768
  if (viewName) ctx.tuiReboundSessions.set(viewName, newSessionId)
@@ -1962,12 +2023,16 @@ async function dispatch(msg, ctx) {
1962
2023
  // resume せず resume 無しの claude を起動する。session_id は起動後の初回送信で
1963
2024
  // 生成され、frontend は回転検知 / sessions 応答で拾う。
1964
2025
  const fresh = msg.fresh === true
1965
- // 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)
2026
+ // 1) 載せ替え先 session_id を確定 (fresh は解決しない = 新規起動)。あわせて
2027
+ // 現在動いているセッション (= cwd の最新 jsonl = newestId) も解決する。生成中ガードで
2028
+ // 「再接続/remount による同一セッションへの再 bind」か「別 session への明示 switch」かを
2029
+ // 区別するのに使う (前者は respawn を抑止して生成中 claude を温存する)。
1966
2030
  let targetId =
1967
2031
  typeof msg.session_id === "string" && msg.session_id
1968
2032
  ? msg.session_id
1969
2033
  : null
1970
- if (!fresh && !targetId && cwd) {
2034
+ let newestId = null
2035
+ if (!fresh && cwd) {
1971
2036
  const projectsRoot = await getActiveProjectsRoot()
1972
2037
  const { sessions } = await listSessions({
1973
2038
  cwd,
@@ -1975,7 +2040,20 @@ async function dispatch(msg, ctx) {
1975
2040
  limit: 1,
1976
2041
  logger,
1977
2042
  })
1978
- targetId = sessions?.[0]?.session_id || null
2043
+ newestId = sessions?.[0]?.session_id || null
2044
+ if (!targetId) targetId = newestId
2045
+ }
2046
+ // 生成中ガード (謎停止対策): respawn-pane -k は生成中 claude を強制 kill し in-flight 応答を失う。
2047
+ // hub-agent 再起動直後は tuiReboundSessions が空で冪等ガードが効かず、ブラウザ再接続の bind が
2048
+ // 生成中 claude を kill する。armed (ターン進行中) か pane=processing なら生成中とみなす。
2049
+ let generating = ctx.readinessTracker?.isArmed?.(sessionName) === true
2050
+ if (!generating && sessionName) {
2051
+ try {
2052
+ const snap = await detectSessionState(sessionName, {})
2053
+ if (snap?.status === "processing") generating = true
2054
+ } catch {
2055
+ // capture 失敗時は据え置き (生成中とみなさない = 従来挙動)
2056
+ }
1979
2057
  }
1980
2058
  // 2) SDK を停止 (cwd 全体 + 対象 id)。
1981
2059
  let stoppedSdk = 0
@@ -2002,6 +2080,18 @@ async function dispatch(msg, ctx) {
2002
2080
  if (bindKey && sessionName && (fresh || targetId)) {
2003
2081
  if (ctx.tuiReboundSessions.get(sessionName) === bindKey) {
2004
2082
  rebind = { ok: true, skipped: true }
2083
+ } else if (
2084
+ shouldSkipRebindRespawn({ generating, fresh, targetId, newestId })
2085
+ ) {
2086
+ // 生成中 claude を再接続/remount の bind で kill しない (謎停止対策)。respawn を抑止して
2087
+ // 既存の生成中 claude を温存する。bindKey は記録して以降の remount を冪等化する
2088
+ // (targetId === newestId = 動いているセッション本人なので記録は正しい)。
2089
+ rebind = { ok: true, skipped: true }
2090
+ ctx.tuiReboundSessions.set(sessionName, bindKey)
2091
+ logger?.info(
2092
+ { session: sessionName, session_id: targetId },
2093
+ "tui rebind: skipped respawn (turn in progress on the running session)",
2094
+ )
2005
2095
  } else {
2006
2096
  rebind = await rebindClaudeSession(sessionName, targetId, {
2007
2097
  cwd,
package/src/state.mjs CHANGED
@@ -47,12 +47,15 @@ export function invalidateSessionCache(sessionName) {
47
47
  _cwdCache.clear()
48
48
  _statusGate.clear()
49
49
  _spinnerFreezeByName.clear()
50
+ _stabilityByName.clear()
50
51
  return
51
52
  }
52
53
  _captureCache.delete(sessionName)
53
54
  _cwdCache.delete(sessionName)
54
55
  _statusGate.delete(sessionName)
55
56
  _spinnerFreezeByName.delete(sessionName)
57
+ // _stabilityByName も per-session で消す (消滅セッションの GC 漏れ=メモリリーク対策, perf監査)。
58
+ _stabilityByName.delete(sessionName)
56
59
  }
57
60
 
58
61
  /**
package/src/tmux.mjs CHANGED
@@ -1220,6 +1220,29 @@ export async function recoverTuiInput(name, expectText, opts = {}) {
1220
1220
  * @param {{cwd?:string,model?:string,permissionMode?:string,fresh?:boolean,logger?:object,tmuxBin?:string}} [opts]
1221
1221
  * @returns {Promise<{ok:boolean, cmd?:string, error?:string}>}
1222
1222
  */
1223
+ /**
1224
+ * rebind (respawn-pane -k + claude --resume) を抑止すべきか判定する純ロジック。
1225
+ *
1226
+ * 背景 (謎停止対策): claude.tui.bind は TUI ビューの mount / 再接続のたびに来る。respawn-pane -k は
1227
+ * 現 claude を強制 kill するため、生成中に走ると in-flight の応答が失われる (jsonl 未確定)。冪等ガード
1228
+ * (tuiReboundSessions) は hub-agent 再起動直後は空で効かず、ブラウザ再接続の bind が生成中 claude を
1229
+ * kill する事故 (謎停止) を起こす。
1230
+ *
1231
+ * 抑止条件: 生成中 (generating) かつ「今動いているセッション (= 最新 jsonl = newestId) への再 bind」。
1232
+ * これは再接続/remount であり respawn は純粋に破壊的。逆に:
1233
+ * - fresh (+新規セッション要求) は明示操作なので respawn する。
1234
+ * - targetId !== newestId (別 session への明示 switch) は respawn する (生成中セッションは別ペイン文脈で温存)。
1235
+ * - 非生成中は従来どおり respawn する (idle claude の --resume 載せ替えは非破壊)。
1236
+ *
1237
+ * @param {{generating?: boolean, fresh?: boolean, targetId?: string|null, newestId?: string|null}} a
1238
+ * @returns {boolean}
1239
+ */
1240
+ export function shouldSkipRebindRespawn({ generating, fresh, targetId, newestId } = {}) {
1241
+ if (fresh) return false
1242
+ if (!generating) return false
1243
+ return !!targetId && targetId === newestId
1244
+ }
1245
+
1223
1246
  export async function rebindClaudeSession(name, sessionId, opts = {}) {
1224
1247
  let cmd
1225
1248
  try {