@cocorograph/hub-agent 0.8.108 → 0.8.110

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.8.108",
3
+ "version": "0.8.110",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -66,10 +66,13 @@ function siblingEnvFor(cwd) {
66
66
  }
67
67
 
68
68
  /** browser 切断後、セッションを生かしたまま再接続を待つ idle TTL (ミリ秒)。これを過ぎ、
69
- * かつ走行中でなければ撤去する。端末スリープ/長期離席後の再接続も吸収できるよう 7 日に
70
- * 設定 (放置セッションのメモリ蓄積は idle 時のみで小さい)。走行中ターンは TTL を過ぎても
71
- * 完走するまで絶対に撤去しない。明示終了 / 新規セッションでは即時撤去される。 */
72
- const IDLE_DETACH_TTL_MS = 7 * 24 * 60 * 60 * 1000
69
+ * かつ実作業が無ければ撤去する。2026-10-06 の実測では使用済み chrome-devtools-mcp が
70
+ * 1 本 0.7〜2.5GB を保持し続け、swap は 26GB に達した。idle 中もメモリが小さいとは
71
+ * 限らないため、再接続の猶予を 3 時間に抑える。実作業中は TTL 後も再チェックする。 */
72
+ export const IDLE_DETACH_TTL_MS = readPositiveIntEnv("HUB_AGENT_CLAUDE_IDLE_DETACH_TTL_MS", 3 * 60 * 60 * 1000)
73
+
74
+ /** 無操作かつ実作業無しが 6 時間続いたら、セッションを残して常駐 query だけ畳む。 */
75
+ export const IDLE_ATTACHED_TTL_MS = readPositiveIntEnv("HUB_AGENT_CLAUDE_IDLE_ATTACHED_TTL_MS", 6 * 60 * 60 * 1000)
73
76
 
74
77
  /** 改修2: チャット常駐query化のフラグ。env HUB_AGENT_CHAT_RESIDENT="0" で無効化し
75
78
  * 従来の「1メッセージ=1query」へ即ロールバックできる。デフォルト有効。 */
@@ -201,7 +204,7 @@ const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
201
204
 
202
205
  /** 子⑤ (T06544): warm 保持を「各 cwd K 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
203
206
  * 上限(codex-stream-bridge.mjs の WARM_CWD_MAX と対)。claude は従来 per-session の softDetach
204
- * タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm セッション(= SDK
207
+ * タイマーだけで件数上限が無かったため、多数の cwd を渡り歩くと warm セッション(= SDK
205
208
  * 常駐 query プロセス)が N 個を超えて蓄積し得た。cwd 単位の LRU で最古 cwd から早期 reap する。
206
209
  * env HUB_AGENT_WARM_CWD_MAX で調整可能(codex と共有)。 */
207
210
  export const WARM_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_CWD_MAX", 20)
@@ -483,6 +486,8 @@ export class ClaudeStreamSession {
483
486
  * recency を失う。これは touch() で更新し detach でリセットしないので、warm 化しても「最後に
484
487
  * 使った時刻」が残り、最古 cwd から LRU reap できる。 */
485
488
  this._lastActivityMs = Date.now()
489
+ // 長いターンの終了直後に、入力時刻だけを見て idle 撤去しないための終了時刻。
490
+ this._lastTurnSettledMs = 0
486
491
  /** 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
487
492
  * 新しい)。recency 同値時に per-cwd=K が新しいセッションを残せるようにする。
488
493
  * ⚠️ カウンタは **claude / codex 共通**(session-seq.mjs)。総 live 上限 M は両 CLI を合算して
@@ -702,6 +707,8 @@ export class ClaudeStreamSession {
702
707
  * close()/shutdown() で子プロセスが残留する。per-message と同じく AbortController を
703
708
  * options へ渡し、interrupt が reject したときと close() のときに最終手段として abort する。 */
704
709
  this._residentAbortController = null
710
+ this._foldedIdle = false
711
+ this._idleFoldAbortController = null
705
712
 
706
713
  /** jsonl ライブ追従 watcher (0.6.5)。外部 (tmux 等) の追記を拾う。 */
707
714
  this._watcher = null
@@ -1338,26 +1345,33 @@ export class ClaudeStreamSession {
1338
1345
  softDetach(ttlMs, onTimeout) {
1339
1346
  this._detached = true
1340
1347
  if (this._idleTimer) clearTimeout(this._idleTimer)
1348
+ const detachedAt = Date.now()
1349
+ const schedule = (remaining) => {
1350
+ // Node.js のタイマー上限を超える TTL も、期限まで分割して待つ。
1351
+ this._idleTimer = setTimeout(tick, Math.min(remaining, 2147483647))
1352
+ this._idleTimer.unref?.()
1353
+ }
1341
1354
  const tick = () => {
1342
1355
  // 再アタッチ済みなら撤去しない (reattach が _detached=false にする)
1343
- if (!this._detached) {
1356
+ if (!this._detached || this._closed) {
1344
1357
  this._idleTimer = null
1345
1358
  return
1346
1359
  }
1347
- // 走行中ターンは完走を待ち TTL 後に再チェック (走行中は絶対に撤去しない)
1348
- if (this._busy) {
1349
- this._idleTimer = setTimeout(tick, ttlMs)
1350
- // idle TTL は最大 7 日。タイマー単独でイベントループを生かさない (unref)。
1351
- // 本番デーモンは WS 接続等で常駐するので reap は問題なく発火し、テスト/CLI
1352
- // など他に handle が無いプロセスは即座に終了できる。
1353
- this._idleTimer.unref?.()
1360
+ const now = Date.now()
1361
+ const remaining = Math.max(detachedAt, this._lastTurnSettledMs) + ttlMs - now
1362
+ if (remaining > 0) {
1363
+ schedule(remaining)
1364
+ return
1365
+ }
1366
+ // 実作業中は再チェックするが、detach / ターン終了由来の期限自体は延ばさない。
1367
+ if (this._busy || this._pendingMessages.length > 0 || this._hasLiveTask?.(now)) {
1368
+ schedule(ttlMs)
1354
1369
  return
1355
1370
  }
1356
1371
  this._idleTimer = null
1357
1372
  onTimeout?.()
1358
1373
  }
1359
- this._idleTimer = setTimeout(tick, ttlMs)
1360
- this._idleTimer.unref?.()
1374
+ schedule(ttlMs)
1361
1375
  }
1362
1376
 
1363
1377
  /**
@@ -1420,7 +1434,8 @@ export class ClaudeStreamSession {
1420
1434
  // pending へ退避し、現ターンの result 後に 1 件ずつ drain する。前ターン未完了のまま
1421
1435
  // 次を push すると SDK streaming-input で割り込み扱いになり得るため (公式 interrupt 警告)。
1422
1436
  // pending は queue_state で UI (送信待ちチップ) に出す (per-message と同じ体験)。
1423
- if (this._busy) {
1437
+ if (this._busy || (this._foldedIdle && this._residentQuery)) {
1438
+ // fold の abort が完了するまでの入力は finally の既存 resume/drain 経路へ渡す。
1424
1439
  this._enqueuePending(prompt, ultracode, inputId)
1425
1440
  this.logger?.info(
1426
1441
  { stream_id: this.stream_id, queued: this._pendingMessages.length },
@@ -1629,6 +1644,7 @@ export class ClaudeStreamSession {
1629
1644
  /* ignore */
1630
1645
  }
1631
1646
  }
1647
+ this._lastTurnSettledMs = Date.now()
1632
1648
  this._busy = false
1633
1649
  if (aborted) {
1634
1650
  this.logger?.info({ stream_id: this.stream_id }, "claude turn aborted")
@@ -2157,6 +2173,7 @@ export class ClaudeStreamSession {
2157
2173
  // 中断直後の 1 ターンだけを帰属不明として扱い、それが確定したら次からは信頼に戻す。
2158
2174
  if (this._lastTurnTexts?.startedUncertain) this._turnTextsUncertain = false
2159
2175
  denyPending?.("turn ended")
2176
+ this._lastTurnSettledMs = Date.now()
2160
2177
  this._busy = false
2161
2178
  // result の後に同一ターンの idle が遅れて届く場合がある。pending drain は同期的に
2162
2179
  // 次ターンの busy を立てるため、その idle を新ターンへ誤帰属させないよう先にガードする。
@@ -2180,6 +2197,9 @@ export class ClaudeStreamSession {
2180
2197
  * (await するため async 化。idle ハンドラからは fire-and-forget で呼ばれる)。 */
2181
2198
  async _drainResidentPending() {
2182
2199
  if (this._closed) return
2200
+ // fold 中の旧 query には渡さず、終了時の finally が resume して pending を拾う。
2201
+ // 通常の interrupt 後の drain は従来どおり進める。
2202
+ if (this._foldedIdle && this._residentQuery) return
2183
2203
  if (this._pendingMessages.length === 0) {
2184
2204
  // 症状1 自己修復 (2026-07-21): _drainPending と同旨。落ちた drain broadcast で送信待ち
2185
2205
  // チップが stale 残留するのを、ターン settle 毎の force 再表明で補正する (空 = count=0)。
@@ -2210,6 +2230,7 @@ export class ClaudeStreamSession {
2210
2230
  this._emitQueueState([next.text], { startedIds: [next.id] })
2211
2231
  else this._emitQueueState([], { force: true })
2212
2232
  } catch (err) {
2233
+ this._lastTurnSettledMs = Date.now()
2213
2234
  this._busy = false
2214
2235
  this._pendingMessages.unshift(next)
2215
2236
  this.logger?.warn(
@@ -2286,6 +2307,7 @@ export class ClaudeStreamSession {
2286
2307
  try {
2287
2308
  const q = this.sdk.query({ prompt: this._inputQueue, options })
2288
2309
  this._residentQuery = q
2310
+ this._foldedIdle = false
2289
2311
  for await (const msg of q) {
2290
2312
  let suppressEmit = false
2291
2313
  this._observeTurnTexts(msg)
@@ -2369,7 +2391,14 @@ export class ClaudeStreamSession {
2369
2391
  }
2370
2392
  }
2371
2393
  } catch (err) {
2372
- if (!this._closed) {
2394
+ // run 固有の controller と照合する。fold フラグだけでは、再開後のエラーや
2395
+ // abort と同時に起きた無関係なエラーまで握り潰してしまう。
2396
+ const idleFoldAbort = this._idleFoldAbortController === myAbort && myAbort.signal.aborted && (
2397
+ err?.name === "AbortError" || err?.code === "ABORT_ERR" ||
2398
+ // SDK の abort 終了エラーは Error 派生で name が AbortError とは限らない。
2399
+ err?.message === "Claude Code process aborted by user" || err?.message === "Operation aborted"
2400
+ )
2401
+ if (!this._closed && !idleFoldAbort) {
2373
2402
  try {
2374
2403
  this.onError?.(err)
2375
2404
  } catch {
@@ -2383,6 +2412,7 @@ export class ClaudeStreamSession {
2383
2412
  // generator になっていたことを検知できるようにするため。
2384
2413
  this._residentQueryReturned = true
2385
2414
  this._residentQuery = null
2415
+ if (this._busy) this._lastTurnSettledMs = Date.now()
2386
2416
  this._busy = false
2387
2417
  denyPending("closed")
2388
2418
  // 常駐 query の終了。detached 後にここへ来たら reap する。
@@ -2503,6 +2533,7 @@ export class ClaudeStreamSession {
2503
2533
  // いたが、中断ターンの in-flight イベントが _turnProgressed を立ててしまうため成立して
2504
2534
  // いなかった)。
2505
2535
  this._turnSettled = true
2536
+ if (this._busy) this._lastTurnSettledMs = Date.now()
2506
2537
  this._busy = false
2507
2538
  this._armStaleIdleGuard()
2508
2539
  // 孤児カード防止 (2026-07-10 監査): 中断したターンに紐づく未応答 permission は
@@ -2586,9 +2617,25 @@ export class ClaudeStreamSession {
2586
2617
  }
2587
2618
  }
2588
2619
 
2620
+ /** idle の常駐プロセスだけを畳み、再接続と次入力用にセッションを保持する。 */
2621
+ foldIdleResident() {
2622
+ if (this._closed || !this._residentQuery || this._foldedIdle) return false
2623
+ this._foldedIdle = true
2624
+ // 既に別の理由で abort された run を idle fold のものとして扱わない。
2625
+ if (!this._residentAbortController?.signal.aborted) {
2626
+ this._idleFoldAbortController = this._residentAbortController
2627
+ }
2628
+ this._hardAbortResident()
2629
+ this.logger?.info(
2630
+ { stream_id: this.stream_id, session_id: this.sessionId, cwd: this.cwd, reason: "inactive" },
2631
+ "claude idle resident query folded",
2632
+ )
2633
+ return true
2634
+ }
2635
+
2589
2636
  /** SDK-P0-4 (2026-07-13): 常駐 query の最終手段の強制終了。interrupt() (control request) が
2590
- * 失敗・無応答のときと close() から呼ぶ。AbortController を落とすと generator が終了し、
2591
- * _runResidentQuery の finally が pending を resume 付きで再起動して取りこぼしを防ぐ。 */
2637
+ * 失敗・無応答のときと close() / idle fold から呼ぶ。AbortController を落とすと generator が
2638
+ * 終了し、finally が pending を resume 付きで再起動して取りこぼしを防ぐ。 */
2592
2639
  _hardAbortResident() {
2593
2640
  const ac = this._residentAbortController
2594
2641
  if (!ac || ac.signal?.aborted) return
@@ -2632,6 +2679,8 @@ export class ClaudeStreamSession {
2632
2679
  * idle セッション (_residentQueryReturned=false) は生存中なので reattach 可。 */
2633
2680
  _isReattachable() {
2634
2681
  if (this._closed) return false
2682
+ // 意図的に畳んだ常駐 query は、同じセッションで次入力から再開できる。
2683
+ if (this._foldedIdle) return true
2635
2684
  if (this._residentQueryReturned && !this._residentQuery) return false
2636
2685
  return true
2637
2686
  }
@@ -2803,7 +2852,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2803
2852
  *
2804
2853
  * TUI モードに切り替えた session は tmux ペインの対話 claude が write を担うため、
2805
2854
  * SDK セッションを生かしたままだと同一 cwd の jsonl を二重ホストして競合する
2806
- * (DESIGN.md C7)。soft-detach (7 日 resident) では止まらないので、ここで明示 close する。
2855
+ * (DESIGN.md C7)。soft-detach (既定 3 時間 resident) では止まらないので、ここで明示 close する。
2807
2856
  *
2808
2857
  * ⚠️ `reason` は exit イベントにそのまま載る。既定の `tui-bind-stop` は上記 TUI bind 用の
2809
2858
  * 名前なので、**TUI 以外の理由で止めるときは必ず渡す**こと (既定のままだと、存在しない
@@ -4202,6 +4251,18 @@ export class ClaudeStreamBridge extends EventEmitter {
4202
4251
  return !!session._hasLiveTask?.(now)
4203
4252
  }
4204
4253
 
4254
+ /** 購読数に関係なく、実作業が無く無操作 TTL を過ぎた常駐 query だけを畳む。 */
4255
+ foldInactiveSessions(ttlMs = IDLE_ATTACHED_TTL_MS, now = Date.now()) {
4256
+ let folded = 0
4257
+ for (const { session } of this._liveCandidatesForCwd()) {
4258
+ if (!session._residentQuery || this._hasSessionWork(session, now)) continue
4259
+ const lastActive = Math.max(session._lastActivityMs ?? 0, session._lastTurnSettledMs ?? 0)
4260
+ if (now - lastActive < ttlMs) continue
4261
+ if (session.foldIdleResident()) folded += 1
4262
+ }
4263
+ return folded
4264
+ }
4265
+
4205
4266
  /**
4206
4267
  * daemon の deferred restart が待つべき実作業があるか。
4207
4268
  * 多端末 subscribe は warm 保持には必要だが作業ではないため、ここでは数えない。
@@ -330,22 +330,48 @@ export function hasInheritedOutputChild() {
330
330
  return inheritedOutputChildren > 0
331
331
  }
332
332
 
333
- function runOnce(argv, { logger } = {}) {
333
+ export function runOnce(argv, { logger, redact = [] } = {}) {
334
+ const secrets = redact.filter((value) => value.length > 0).sort((a, b) => b.length - a.length)
335
+ const pattern = secrets.length
336
+ ? new RegExp(secrets.map(_escapeRegExp).join("|"), "g")
337
+ : null
338
+ const sanitize = (value) => pattern ? value.replace(pattern, "***") : value
339
+ const safeError = (err) => pattern ? new Error(sanitize(err.message)) : err
334
340
  return new Promise((resolve, reject) => {
335
341
  const [bin, ...args] = argv
336
- inheritedOutputChildren++
342
+ const cmd = sanitize(argv.join(" "))
343
+ if (!pattern) inheritedOutputChildren++
337
344
  let released = false
338
345
  const release = () => {
339
346
  if (released) return
340
347
  released = true
341
- inheritedOutputChildren--
348
+ if (!pattern) inheritedOutputChildren--
342
349
  }
343
350
  let child
344
351
  try {
345
- child = spawn(bin, args, { stdio: "inherit" })
352
+ child = spawn(bin, args, { stdio: pattern ? ["inherit", "pipe", "pipe"] : "inherit" })
346
353
  } catch (err) {
347
354
  release()
348
- throw err
355
+ throw safeError(err)
356
+ }
357
+ // close を待って全バイトを結合し、チャンク境界(UTF-8 の途中を含む)での漏出を防ぐ。
358
+ // 上限を超えたら収集済みも捨てて読み捨てに切り替える(途中で切ると資格の断片が残りうるため、
359
+ // 部分表示はしない)。pipe は close まで drain し続け、子を書き込み待ちで止めない。
360
+ const captured = { stdout: { chunks: [], bytes: 0, overflow: false }, stderr: { chunks: [], bytes: 0, overflow: false } }
361
+ if (pattern) {
362
+ for (const name of ["stdout", "stderr"]) {
363
+ const buf = captured[name]
364
+ child[name].on("data", (chunk) => {
365
+ if (buf.overflow) return
366
+ buf.bytes += chunk.length
367
+ if (buf.bytes > REDACTED_OUTPUT_MAX_BYTES) {
368
+ buf.overflow = true
369
+ buf.chunks = []
370
+ return
371
+ }
372
+ buf.chunks.push(chunk)
373
+ })
374
+ }
349
375
  }
350
376
  // spawn 失敗 (ENOENT 等) は exit が来ないことがある。error でも必ず settle させ、
351
377
  // bundle 同期を未完了のまま残さない (resolve/reject は最初の 1 回だけ有効)。
@@ -355,22 +381,54 @@ function runOnce(argv, { logger } = {}) {
355
381
  }
356
382
  child.once("error", (err) => {
357
383
  release()
358
- reject(err)
359
- safeLog("warn", { cmd: argv.join(" "), error: err.message }, "hub-bundle post_install failed")
384
+ reject(safeError(err))
385
+ safeLog("warn", { cmd, error: sanitize(err.message) }, "hub-bundle post_install failed")
360
386
  })
361
- child.on("exit", (code) => {
387
+ child.on(pattern ? "close" : "exit", (code) => {
362
388
  release()
389
+ // 先に settle する。転送先の障害(ENOSPC / EPIPE 等)でコマンドの成否を変えない。
363
390
  if (code === 0) {
364
391
  resolve(code)
365
- safeLog("info", { cmd: argv.join(" ") }, "hub-bundle post_install ok")
366
392
  } else {
367
- reject(new Error(`post_install command failed (exit ${code}): ${argv.join(" ")}`))
368
- safeLog("warn", { cmd: argv.join(" "), code }, "hub-bundle post_install failed")
393
+ reject(new Error(`post_install command failed (exit ${code}): ${cmd}`))
394
+ }
395
+ if (pattern) {
396
+ for (const name of ["stdout", "stderr"]) {
397
+ const buf = captured[name]
398
+ const text = buf.overflow
399
+ ? `[hub-agent] 出力が ${REDACTED_OUTPUT_MAX_BYTES} バイトを超えたため省略しました\n`
400
+ : sanitize(Buffer.concat(buf.chunks).toString("utf8"))
401
+ if (text) forwardRedactedOutput(process[name], text)
402
+ }
403
+ }
404
+ if (code === 0) {
405
+ safeLog("info", { cmd }, "hub-bundle post_install ok")
406
+ } else {
407
+ safeLog("warn", { cmd, code }, "hub-bundle post_install failed")
369
408
  }
370
409
  })
371
410
  })
372
411
  }
373
412
 
413
+ // 伏せ字済み出力の 1 ストリームあたりの収集上限。claude mcp add の通常出力は数百バイト。
414
+ const REDACTED_OUTPUT_MAX_BYTES = 1024 * 1024
415
+
416
+ // 伏せ字済み出力を親の stdout / stderr へ書く。書き込み失敗は診断出力の欠落として扱い、
417
+ // 同期例外も非同期の error も外へ出さない。error listener はこの書き込みの間だけ付け、
418
+ // 共有ストリームの他のエラーを恒久的に握り潰さない。
419
+ function forwardRedactedOutput(stream, text) {
420
+ const onError = () => {}
421
+ stream.on("error", onError)
422
+ try {
423
+ stream.write(text, () => {
424
+ // callback 後に error が emit される実装があるため、1 tick 置いてから外す。
425
+ setImmediate(() => stream.removeListener("error", onError))
426
+ })
427
+ } catch {
428
+ stream.removeListener("error", onError)
429
+ }
430
+ }
431
+
374
432
  /**
375
433
  * Bundle response の `mcp_servers` を Claude Code CLI に登録する。
376
434
  *
@@ -474,7 +532,7 @@ export async function applyMcpServers(
474
532
  // 未登録 → remove は失敗するが正常系。
475
533
  }
476
534
  }
477
- await run(argv, { logger })
535
+ await run(argv, wantHeader ? { logger, redact: [agentToken] } : { logger })
478
536
  added.push(s.name)
479
537
  } catch (err) {
480
538
  // ヘッダ不要の外部 MCP は既存なら "already exists" で失敗する。これは「既に正しく
package/src/main.mjs CHANGED
@@ -2159,10 +2159,15 @@ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codex
2159
2159
  } catch (err) {
2160
2160
  logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
2161
2161
  }
2162
+ try {
2163
+ claudeBridge?.foldInactiveSessions?.()
2164
+ } catch (err) {
2165
+ logger?.warn({ err: err?.message }, "claude foldInactiveSessions failed")
2166
+ }
2162
2167
  // 子⑤ (T06544): claude 側 warm 境界(per-cwd=K / N-cwd LRU)。K は現行の既定 3
2163
2168
  // (**旧仕様**は per-cwd=1 で「各 cwd 1 本」だった。1 cwd で複数会話を同時に生かすため
2164
2169
  // 一般化し、総量は下の総 live 上限 M で押さえる)。従来 claude は per-session の softDetach
2165
- // タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2170
+ // タイマーだけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2166
2171
  // SDK 常駐 query が蓄積し得た。codex の reconcileSessions と対で cwd 単位に有界化する。
2167
2172
  try {
2168
2173
  claudeBridge?.reconcileWarmBounds?.()