@cocorograph/hub-agent 0.8.107 → 0.8.109

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.107",
3
+ "version": "0.8.109",
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 保持には必要だが作業ではないため、ここでは数えない。
@@ -322,18 +322,50 @@ function _postInstallArgv(cmd, win) {
322
322
  return null
323
323
  }
324
324
 
325
+ // stdout/stderr を継承した子プロセスの数。agent.log の回転はこの子の追記を保護できないため、
326
+ // 0 でない間は回転を延期する (log-rotation.mjs)。
327
+ let inheritedOutputChildren = 0
328
+
329
+ export function hasInheritedOutputChild() {
330
+ return inheritedOutputChildren > 0
331
+ }
332
+
325
333
  function runOnce(argv, { logger } = {}) {
326
334
  return new Promise((resolve, reject) => {
327
335
  const [bin, ...args] = argv
328
- const child = spawn(bin, args, { stdio: "inherit" })
336
+ inheritedOutputChildren++
337
+ let released = false
338
+ const release = () => {
339
+ if (released) return
340
+ released = true
341
+ inheritedOutputChildren--
342
+ }
343
+ let child
344
+ try {
345
+ child = spawn(bin, args, { stdio: "inherit" })
346
+ } catch (err) {
347
+ release()
348
+ throw err
349
+ }
350
+ // spawn 失敗 (ENOENT 等) は exit が来ないことがある。error でも必ず settle させ、
351
+ // bundle 同期を未完了のまま残さない (resolve/reject は最初の 1 回だけ有効)。
352
+ // 診断ログは settle の後に出し、ログ書き込みの失敗 (ENOSPC 等) が settle を妨げないようにする。
353
+ const safeLog = (level, fields, message) => {
354
+ try { logger?.[level]?.(fields, message) } catch {}
355
+ }
356
+ child.once("error", (err) => {
357
+ release()
358
+ reject(err)
359
+ safeLog("warn", { cmd: argv.join(" "), error: err.message }, "hub-bundle post_install failed")
360
+ })
329
361
  child.on("exit", (code) => {
362
+ release()
330
363
  if (code === 0) {
331
- logger?.info({ cmd: argv.join(" ") }, "hub-bundle post_install ok")
332
364
  resolve(code)
365
+ safeLog("info", { cmd: argv.join(" ") }, "hub-bundle post_install ok")
333
366
  } else {
334
- const err = new Error(`post_install command failed (exit ${code}): ${argv.join(" ")}`)
335
- logger?.warn({ cmd: argv.join(" "), code }, "hub-bundle post_install failed")
336
- reject(err)
367
+ reject(new Error(`post_install command failed (exit ${code}): ${argv.join(" ")}`))
368
+ safeLog("warn", { cmd: argv.join(" "), code }, "hub-bundle post_install failed")
337
369
  }
338
370
  })
339
371
  })
@@ -0,0 +1,150 @@
1
+ import { randomBytes } from "node:crypto"
2
+ import { closeSync, fstatSync, ftruncateSync, openSync, readdirSync, readSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+
6
+ export const DEFAULT_LOG_MAX_BYTES = 50 * 1024 * 1024
7
+ export const LOG_CHECK_INTERVAL_MS = 10 * 60 * 1000
8
+ // 回転は同期 I/O で常駐処理を止めるため、設定値で無制限に大きくさせない。
9
+ export const MAX_LOG_MAX_BYTES = 512 * 1024 * 1024
10
+
11
+ export function resolveLogMaxBytes(value = process.env.HUB_AGENT_LOG_MAX_BYTES) {
12
+ if (typeof value !== "string" || !/^\d+$/.test(value)) return DEFAULT_LOG_MAX_BYTES
13
+ const bytes = Number(value)
14
+ if (!Number.isSafeInteger(bytes) || bytes <= 0) return DEFAULT_LOG_MAX_BYTES
15
+ return Math.min(bytes, MAX_LOG_MAX_BYTES)
16
+ }
17
+
18
+ function sameFile(a, b) {
19
+ return a.dev === b.dev && a.ino === b.ino
20
+ }
21
+
22
+ // 削除判断に使うので、確実に居ないと分かる ESRCH のときだけ死亡扱いにする
23
+ // (hub-bundle.mjs の _pidAlive はロック回収用で、EPERM 以外を死亡扱いにする別契約)。
24
+ function processAlive(pid) {
25
+ try {
26
+ process.kill(pid, 0)
27
+ return true
28
+ } catch (err) {
29
+ return err?.code !== "ESRCH"
30
+ }
31
+ }
32
+
33
+ /** 異常終了で残った一時ファイルを、作成したプロセスが既に居ない場合だけ消す。 */
34
+ function removeStaleTemps(dir, prefix) {
35
+ for (const name of readdirSync(dir)) {
36
+ if (!name.startsWith(prefix)) continue
37
+ const pid = Number(name.slice(prefix.length).split("-")[0])
38
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid === process.pid || processAlive(pid)) continue
39
+ try { unlinkSync(path.join(dir, name)) } catch {}
40
+ }
41
+ }
42
+
43
+ /**
44
+ * logrotate の copytruncate と同じ方式。fd は launchd が開いたまま使うため rename はしない。
45
+ * 前提: この fd へ書く writer は同期 write だけ (main.mjs の pino は sync destination)。
46
+ * 同期区間の中でサイズ取得・コピー・truncate を行うので、同じプロセスの write とは競合しない。
47
+ * stdout を継承した子プロセスが動いている間は isDeferred で延期する。
48
+ */
49
+ export function rotateLogIfNeeded(logger, {
50
+ fd = 1,
51
+ home = os.homedir(),
52
+ maxBytes = resolveLogMaxBytes(),
53
+ isDeferred = () => false,
54
+ } = {}) {
55
+ try {
56
+ if (isDeferred()) return false
57
+ const before = fstatSync(fd)
58
+ if (!before.isFile()) return false
59
+ const logPath = path.join(home, ".hub", "agent.log")
60
+ let target
61
+ try {
62
+ target = statSync(logPath)
63
+ } catch (err) {
64
+ if (err.code === "ENOENT") return false
65
+ throw err
66
+ }
67
+ if (!sameFile(before, target) || before.size <= maxBytes) return false
68
+
69
+ const source = openSync(logPath, "r")
70
+ try {
71
+ if (!sameFile(before, fstatSync(source))) return false
72
+ // 上限値に比例してメモリを確保せず、末尾だけを小さな塊でコピーする。
73
+ const buffer = Buffer.alloc(64 * 1024)
74
+ let position = before.size - maxBytes
75
+ if (readSync(source, buffer, 0, 1, position - 1) !== 1) {
76
+ throw new Error("log changed before rotation")
77
+ }
78
+ let atLineStart = buffer[0] === 10
79
+ // 旧 .1 はコピー完了まで壊さない。一時ファイルを排他作成して rename で置き換える
80
+ // (unlink はリンク自体を消すだけで参照先を辿らない。wx は既存のリンクや FIFO を開かない)。
81
+ const backupPath = `${logPath}.1`
82
+ const tempPrefix = `${path.basename(backupPath)}.tmp-`
83
+ removeStaleTemps(path.dirname(logPath), tempPrefix)
84
+ const tempPath = path.join(
85
+ path.dirname(logPath),
86
+ `${tempPrefix}${process.pid}-${randomBytes(6).toString("hex")}`,
87
+ )
88
+ const backup = openSync(tempPath, "wx", 0o600)
89
+ let completed = false
90
+ let closeError = null
91
+ try {
92
+ while (position < before.size) {
93
+ const length = readSync(source, buffer, 0, Math.min(buffer.length, before.size - position), position)
94
+ if (length === 0) throw new Error("log changed during rotation")
95
+ position += length
96
+ let offset = 0
97
+ if (!atLineStart) {
98
+ const newline = buffer.subarray(0, length).indexOf(10)
99
+ if (newline === -1) continue
100
+ offset = newline + 1
101
+ atLineStart = true
102
+ }
103
+ while (offset < length) {
104
+ const written = writeSync(backup, buffer, offset, length - offset)
105
+ if (written === 0) throw new Error("log backup write made no progress")
106
+ offset += written
107
+ }
108
+ }
109
+ completed = true
110
+ } finally {
111
+ // close が失敗しても未確定の一時ファイルは消す (稼働中は残骸回収の対象外になるため)。
112
+ try {
113
+ closeSync(backup)
114
+ } catch (err) {
115
+ if (completed) {
116
+ completed = false
117
+ closeError = err
118
+ }
119
+ }
120
+ if (!completed) {
121
+ try { unlinkSync(tempPath) } catch {}
122
+ }
123
+ }
124
+ if (closeError) throw closeError
125
+ try {
126
+ renameSync(tempPath, backupPath)
127
+ } catch (err) {
128
+ try { unlinkSync(tempPath) } catch {}
129
+ throw err
130
+ }
131
+ } finally {
132
+ closeSync(source)
133
+ }
134
+ ftruncateSync(fd, 0)
135
+ logger?.info?.({ previousBytes: before.size, maxBytes }, "agent log rotated")
136
+ return true
137
+ } catch (err) {
138
+ // ログ出力自体の失敗も agent を停止させない。
139
+ try { logger?.warn?.({ error: err.message }, "agent log rotation failed") } catch {}
140
+ return false
141
+ }
142
+ }
143
+
144
+ export function startLogRotation(logger, { schedule = setInterval, ...options } = {}) {
145
+ const check = () => rotateLogIfNeeded(logger, options)
146
+ check()
147
+ const timer = schedule(check, LOG_CHECK_INTERVAL_MS)
148
+ timer.unref()
149
+ return timer
150
+ }
package/src/main.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { performance } from "node:perf_hooks"
2
2
  import { createHash } from "node:crypto"
3
3
  import { logPhaseTiming, startEventLoopMetrics } from "./startup-metrics.mjs"
4
+ import { startLogRotation } from "./log-rotation.mjs"
4
5
  /**
5
6
  * hub-agent daemon 本体。
6
7
  *
@@ -37,6 +38,7 @@ import {
37
38
  import {
38
39
  codexTerminalSkipVersion,
39
40
  compareBundleVersions,
41
+ hasInheritedOutputChild,
40
42
  readCliBundleVersionSync,
41
43
  readCliInstalledVersionSync,
42
44
  readCodexApprovalStatusSync,
@@ -150,7 +152,10 @@ export function codexAvailableForDispatch(ctx) {
150
152
  return (ctx?._resolveCodexBinary || resolveCodexBinary)().available
151
153
  }
152
154
 
153
- const logger = pino({ name: "hub-agent" })
155
+ // 同期書き込みにする。既定の非同期 destination は投入済み write の完了を待つ手段が無く
156
+ // (minLength 0 の flush は即時 callback)、agent.log の copy+truncate 回転の間に完了した
157
+ // write が消えるため。
158
+ const logger = pino({ name: "hub-agent" }, pino.destination({ dest: 1, sync: true }))
154
159
 
155
160
 
156
161
  const BUNDLE_MANIFEST_PATH =
@@ -908,6 +913,7 @@ export function wireBridgeEvents(client, { claudeBridge, codexBridge }) {
908
913
  }
909
914
 
910
915
  export async function startDaemon({ version, claudeSdk } = {}) {
916
+ const logRotationTimer = startLogRotation(logger, { isDeferred: hasInheritedOutputChild })
911
917
  startEventLoopMetrics(logger)
912
918
  const config = await readConfig()
913
919
  if (!config) {
@@ -1190,6 +1196,7 @@ export async function startDaemon({ version, claudeSdk } = {}) {
1190
1196
  client.stop()
1191
1197
  // 0.6.2 fix: watchdog を解放して event loop を抜けられるようにする
1192
1198
  clearInterval(keepaliveTimer)
1199
+ clearInterval(logRotationTimer)
1193
1200
  process.exit(0)
1194
1201
  }
1195
1202
  restartCoordinator = new DeferredRestartCoordinator({
@@ -2152,10 +2159,15 @@ export function startStateLoop({ client, logger, intervalMs, claudeBridge, codex
2152
2159
  } catch (err) {
2153
2160
  logger?.warn({ err: err?.message }, "gcDeadTerminals failed")
2154
2161
  }
2162
+ try {
2163
+ claudeBridge?.foldInactiveSessions?.()
2164
+ } catch (err) {
2165
+ logger?.warn({ err: err?.message }, "claude foldInactiveSessions failed")
2166
+ }
2155
2167
  // 子⑤ (T06544): claude 側 warm 境界(per-cwd=K / N-cwd LRU)。K は現行の既定 3
2156
2168
  // (**旧仕様**は per-cwd=1 で「各 cwd 1 本」だった。1 cwd で複数会話を同時に生かすため
2157
2169
  // 一般化し、総量は下の総 live 上限 M で押さえる)。従来 claude は per-session の softDetach
2158
- // タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2170
+ // タイマーだけで件数上限が無かったため、多数の cwd を渡り歩くと warm な
2159
2171
  // SDK 常駐 query が蓄積し得た。codex の reconcileSessions と対で cwd 単位に有界化する。
2160
2172
  try {
2161
2173
  claudeBridge?.reconcileWarmBounds?.()