@cocorograph/hub-agent 0.8.22 → 0.8.24

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.22",
3
+ "version": "0.8.24",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -33,6 +33,8 @@ import {
33
33
  } from "./tool-result-mask.mjs"
34
34
  import { getOrCreateSiblingToken } from "./sibling-token.mjs"
35
35
  import { recordInjectSourcePersistent } from "./inject-source-store.mjs"
36
+ import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
37
+ import { nextSessionSeq } from "./session-seq.mjs"
36
38
 
37
39
  /**
38
40
  * sibling-inject (2026-07-04) 用: spawn する claude プロセス自身の環境変数に、
@@ -181,20 +183,87 @@ function readNeedsAuthCacheNames() {
181
183
  * sessions に stream_id が永久残留し、最後の 1 台が外れないと idle softDetach が起動しない。
182
184
  * 正常に開いている端末は input が無くても WS heartbeat 等で活性更新されないため、閾値は
183
185
  * 長め (既定 1 時間) にして「明らかに死んだ」端末のみを掃除する。env で調整可能。 */
184
- const DEAD_TERMINAL_TTL_MS =
185
- Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
186
+ const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
187
+ "HUB_AGENT_DEAD_TERMINAL_TTL_MS",
188
+ 60 * 60 * 1000,
189
+ )
186
190
 
187
- /** 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
191
+ /** 子⑤ (T06544): warm 保持を「各 cwd K 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
188
192
  * 上限(codex-stream-bridge.mjs の WARM_CWD_MAX と対)。claude は従来 per-session の softDetach
189
193
  * タイマー(7 日)だけで件数上限が無かったため、多数の cwd を渡り歩くと warm セッション(= SDK
190
194
  * 常駐 query プロセス)が N 個を超えて蓄積し得た。cwd 単位の LRU で最古 cwd から早期 reap する。
191
195
  * env HUB_AGENT_WARM_CWD_MAX で調整可能(codex と共有)。 */
192
- const WARM_CWD_MAX = Number(process.env.HUB_AGENT_WARM_CWD_MAX) || 20
196
+ const WARM_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_CWD_MAX", 20)
197
+
198
+ /** 1 cwd あたりの live セッション上限 K(既定 3)。旧仕様の per-cwd=1 を一般化したもの。
199
+ * 「総量は増やさず配分の自由度だけ上げる」ため、総 live 上限 M(warm-bounds-total.mjs)と
200
+ * セットで効かせる。⚠️ 既存 env(HUB_AGENT_WARM_CWD_MAX = 同時 warm 保持する cwd 数 N)の
201
+ * 意味は変えない。新しい上限は新しい env 名で足す(codex 側の WARM_PER_CWD_MAX と対)。 */
202
+ const WARM_PER_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_PER_CWD_MAX", 3)
203
+
204
+ /** タスク実行中セッションを warm 境界の刈り取りから保護する際の**失効時間**(**鼓動が観測できる
205
+ * 種別**用。既定 30 分)。「最後に生存を確認してから」の相対時間で、`task_progress` が届くたびに
206
+ * 先送りされる。実測(/tmp/bgprobe/raw2.jsonl)で `local_agent`(Agent ツールのサブエージェント)
207
+ * は task_progress の鼓動が届くため、この短い TTL で十分に走行中を守れる。
208
+ * ⚠️ これは**仕様上の最大保護時間**であり、これを超えて鼓動が途絶えたタスクは保護されない
209
+ * (終了イベントを取りこぼしたときに保護が永久化しないための上限として必須)。 */
210
+ const TASK_PROTECT_TTL_MS = readPositiveIntEnv(
211
+ "HUB_AGENT_TASK_PROTECT_TTL_MS",
212
+ 30 * 60 * 1000,
213
+ )
193
214
 
194
- /** 子⑤ (T06544, codex-review round5 Finding 1): warm 境界の「最新 1 本を残す」判定で recency が
195
- * 同一ミリ秒になったときのタイブレーク用の単調増加シーケンス(codex 側 CODEX_SESSION_SEQ と対)。
196
- * 生成順が後 = 値が大きい = 新しいので、per-cwd=1 のタイでは必ず新しいセッションを残す。 */
197
- let CLAUDE_SESSION_SEQ = 0
215
+ /** **鼓動が来ない種別**(`local_bash` = run_in_background の Bash、および未知の task_type)用の
216
+ * 失効時間(既定 4 時間)。
217
+ *
218
+ * 【なぜ分けるか】実測で `local_bash` は task_progress が **1 件も来ない**(40 秒でも 240 秒でも
219
+ * 0 件。/tmp/bgprobe/raw2.jsonl, raw4.jsonl)。全種別を共通 TTL にすると lastSeenAt が開始時刻から
220
+ * 動かず、実質「開始から 30 分」になり、**30 分を超える正常な背景コマンド(ビルド・同期・
221
+ * サーバー等)が実行中なのに保護を失う**。終了イベント(task_updated / task_notification)は
222
+ * 240 秒後でも確実に届くことを実測済みなので、TTL を延ばしても通常は終了で解ける。
223
+ *
224
+ * 【未知の task_type もこちら】鼓動が来るか分からないものを短い TTL で切ると実行中を刈る。
225
+ * 「未知の status を終端扱いしない」(TASK_TERMINAL_STATUSES)と同じく安全側へ倒す。
226
+ *
227
+ * ⚠️ これも**仕様上の最大保護時間**で、**4 時間を超える背景コマンドは保護されない**。
228
+ *
229
+ * 【有界なのは「時間」だけで、「件数」ではない】(cross-review round2 minor)
230
+ * 1 エントリあたりの保護時間は TTL で有界(4 時間で必ず頭打ち)。一方で **同時に保護されうる
231
+ * セッション数には K(per-cwd)も N(cwd 数)も M(総 live 上限、warm-bounds-total.mjs)も効かない**。
232
+ * すべての境界が protected を skip する設計なので、4 時間以内に別セッションで背景コマンドを
233
+ * 増やせば、保護中の合計は M を超えて増え続ける。これは「走行中を絶対に殺さない」を優先した
234
+ * **明示的な超過受容**であり、**M をハードな資源上限と読んではいけない**。
235
+ * 超過量は warm-bounds-total.mjs が返す `liveTotal` / `unresolved` のログで観測できる
236
+ * (運用判断はその実測値を見る)。
237
+ *
238
+ * ⚠️ 将来ハード上限(保護中でも刈る)を足すなら、**実行中タスクを中断するという新しい失敗モード**
239
+ * が生まれる。この TTL 設計の延長で暗黙に足さないこと。足すなら victim の選び方・利用者への通知・
240
+ * 再開可否を別仕様として設計する。 */
241
+ const BG_COMMAND_PROTECT_TTL_MS = readPositiveIntEnv(
242
+ "HUB_AGENT_BG_COMMAND_PROTECT_TTL_MS",
243
+ 4 * 60 * 60 * 1000,
244
+ )
245
+
246
+ /** `task_progress` の鼓動が届くと**実測で確認済み**の task_type。ここに無い種別(`local_bash` /
247
+ * 未知)は鼓動が来ない前提で長い TTL(BG_COMMAND_PROTECT_TTL_MS)を適用する。 */
248
+ const HEARTBEAT_TASK_TYPES = new Set(["local_agent"])
249
+
250
+ /** task_type に応じた保護の失効時間を返す。未知の種別は**長い方**(安全側)。 */
251
+ function taskProtectTtlFor(taskType) {
252
+ return HEARTBEAT_TASK_TYPES.has(taskType) ? TASK_PROTECT_TTL_MS : BG_COMMAND_PROTECT_TTL_MS
253
+ }
254
+
255
+ /** SDK の `system:task_updated` が patch.status に載せる**終端**ステータス。
256
+ * 出典: node_modules/@anthropic-ai/claude-agent-sdk/sdk.d.ts の `SDKTaskUpdatedMessage`
257
+ * (`status?: 'pending' | 'running' | 'completed' | 'failed' | 'killed' | 'paused'`, sdk.d.ts:4101)。
258
+ * ⚠️ `paused` は終端に含めない(再開しうるため)。ここに載っていない未知の値は「まだ走行中かも
259
+ * しれない」とみなして保護を解除しない(未知の状態を勝手に終端扱いして走行中タスクを刈らない
260
+ * ため。保護の上限は失効時間が保証する)。
261
+ * ⚠️ SDK 更新で union が変わったら test/claude-task-status-drift.test.mjs が落ちる。 */
262
+ export const TASK_TERMINAL_STATUSES = new Set(["completed", "failed", "killed"])
263
+
264
+ /** 上と対になる**非終端**ステータス(同じ union の残り全部)。分類の網羅性をテストで検査するために
265
+ * 明示的に持つ(SDK に未知の値が増えたら「どちらでもない」ものとして検出できる)。 */
266
+ export const TASK_NONTERMINAL_STATUSES = new Set(["pending", "running", "paused"])
198
267
 
199
268
  /**
200
269
  * モデル能力レジストリ (2026-07-11): モデル名 → { supportsEffort, supportsXhighEffort }。
@@ -391,8 +460,10 @@ export class ClaudeStreamSession {
391
460
  * 使った時刻」が残り、最古 cwd から LRU reap できる。 */
392
461
  this._lastActivityMs = Date.now()
393
462
  /** 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
394
- * 新しい)。recency 同値時に per-cwd=1 が新しいセッションを残せるようにする。 */
395
- this._seq = CLAUDE_SESSION_SEQ += 1
463
+ * 新しい)。recency 同値時に per-cwd=K が新しいセッションを残せるようにする。
464
+ * ⚠️ カウンタは **claude / codex 共通**(session-seq.mjs)。総 live 上限 M は両 CLI を合算して
465
+ * 古い順に刈るため、CLI ごとに別カウンタだと CLI をまたいだ比較が成立しない。 */
466
+ this._seq = nextSessionSeq()
396
467
  this.cwd = cwd
397
468
  this.model = model || null
398
469
  this.permissionMode = permissionMode || null
@@ -496,6 +567,20 @@ export class ClaudeStreamSession {
496
567
  * 本文一致で _emit が消費してタグ付けする。人間の実入力や tool_result は本 set に無いので
497
568
  * タグされない (誤タグ防止)。echo が来ない稀ケースの無限成長は上限で古い順に切る。 */
498
569
  this._pendingInjectSources = []
570
+
571
+ /** 走行中タスク(Agent ツールのサブエージェント / 背景 Bash コマンド)の台帳。
572
+ * key = SDK の `task_id`、value = { toolUseId, taskType, startedAt, lastSeenAt }。
573
+ *
574
+ * ⚠️ key を `tool_use_id` にしてはいけない: 終了イベントの片方(`system:task_updated`)は
575
+ * `tool_use_id` を持たず `task_id` しか載せないため、tool_use_id を鍵にすると終了を
576
+ * 取りこぼして保護が居座る(実測ログ /tmp/bgprobe/raw2.jsonl で確認)。
577
+ *
578
+ * ⚠️ 前面 / 背景を区別しない(確定した設計判断)。前面実行中はそもそも `_busy` で保護される
579
+ * ので区別しても挙動は変わらず、区別するには tool_use の `run_in_background` と突き合わせる
580
+ * 第 2 の仕組みが要る。一方、背景コマンドの終了イベントは**ターン終了後**に届く(実測:
581
+ * `result` の 14 秒後)ため、背景は保護対象に含める必要がある。 */
582
+ this._liveTasks = new Map()
583
+
499
584
  /** ultracode (0.6.28): 常駐 query へ現在適用済みの ultracode 状態。ターン単位の
500
585
  * ワンショット適用を applyFlagSettings で reconcile する際の差分判定に使う。
501
586
  * query を (再)起動すると flag settings は既定に戻るため、_runResidentQuery 冒頭で
@@ -620,7 +705,109 @@ export class ClaudeStreamSession {
620
705
  if (idx !== -1) this._pendingInjectSources.splice(idx, 1)
621
706
  }
622
707
 
708
+ /**
709
+ * SDK の `system:task_*` を観測して走行中タスク台帳(_liveTasks)を更新する。
710
+ *
711
+ * 呼び出しは `_emit` の先頭 1 箇所だけ。常駐ループ・per-message ループ・jsonl watcher の
712
+ * 3 経路が必ず `_emit` を通るため、ここが唯一の合流点になる(経路ごとに配線すると、
713
+ * どれか 1 本を足したときに無言で観測漏れする)。
714
+ *
715
+ * 実測(/tmp/bgprobe/raw2.jsonl, raw3.jsonl)に基づく扱い:
716
+ * - `task_started` : 新規登録(前面 / 背景の両方が出す)
717
+ * - `task_progress` : **既存エントリがあるときだけ** lastSeenAt を延ばす。エントリが
718
+ * 無ければ新規作成しない(終了後に遅れて届いた進捗でよみがえるのを
719
+ * 防ぐ)。⚠️ ここは「安全側」ではない(cross-review round2 nit):
720
+ * 開始イベント(task_started)を本当に取りこぼしていた場合、実行中
721
+ * なのに保護が付かず reap されうる。**過剰保護と終了後の復活を避ける
722
+ * ため、旧挙動(保護しない)へ fail-open している**というトレードオフ。
723
+ * ⚠️ この記述を根拠に「未知の progress から新規登録する」実装へ
724
+ * 変えないこと。それをやると遅延イベントで保護が復活する。
725
+ * - `task_updated` : patch.status が終端なら削除。**未知の値なら削除せず lastSeenAt 更新**
726
+ * - `task_notification` : status によらず削除(冪等。同一 task_id が複数回来ても壊れない)
727
+ *
728
+ * ⚠️ 例外を外へ投げないこと。`_emit` は全メッセージが通る経路なので、ここで throw すると
729
+ * ストリーム全体が壊れる。すべて握って logger?.warn に留める。
730
+ * @param {object} msg SDK メッセージ
731
+ * @param {number} [now] 時刻(テストから注入可能)
732
+ */
733
+ _observeTaskEvent(msg, now = Date.now()) {
734
+ try {
735
+ if (!msg || msg.type !== "system") return
736
+ const subtype = msg.subtype
737
+ if (
738
+ subtype !== "task_started" &&
739
+ subtype !== "task_progress" &&
740
+ subtype !== "task_updated" &&
741
+ subtype !== "task_notification"
742
+ ) {
743
+ return
744
+ }
745
+ const taskId = msg.task_id
746
+ if (typeof taskId !== "string" || !taskId) return
747
+ if (subtype === "task_started") {
748
+ this._liveTasks.set(taskId, {
749
+ toolUseId: typeof msg.tool_use_id === "string" ? msg.tool_use_id : null,
750
+ taskType: typeof msg.task_type === "string" ? msg.task_type : null,
751
+ startedAt: now,
752
+ lastSeenAt: now,
753
+ })
754
+ return
755
+ }
756
+ if (subtype === "task_notification") {
757
+ this._liveTasks.delete(taskId)
758
+ return
759
+ }
760
+ if (subtype === "task_updated") {
761
+ const status = msg.patch?.status
762
+ if (typeof status === "string" && TASK_TERMINAL_STATUSES.has(status)) {
763
+ this._liveTasks.delete(taskId)
764
+ return
765
+ }
766
+ // 未知 / 非終端の status: 走行中とみなして生存を更新する(失効時間が上限を保証する)。
767
+ const entry = this._liveTasks.get(taskId)
768
+ if (entry) entry.lastSeenAt = now
769
+ return
770
+ }
771
+ // task_progress: 生存の鼓動。既存エントリのみ延命する(新規作成はしない)。
772
+ const entry = this._liveTasks.get(taskId)
773
+ if (entry) entry.lastSeenAt = now
774
+ } catch (err) {
775
+ this.logger?.warn(
776
+ { err: err?.message, stream_id: this.stream_id },
777
+ "observe task event threw",
778
+ )
779
+ }
780
+ }
781
+
782
+ /** 走行中タスクを持つか(warm 境界の保護判定用)。失効したエントリを掃除してから判定する。
783
+ * 失効は「最後に生存を確認してから」の相対時間で、**task_type ごとに TTL が違う**
784
+ * (鼓動が来る local_agent は 30 分 / 鼓動が来ない local_bash・未知は 4 時間)。
785
+ * ⚠️ 失効させて保護を落とす = そのセッションは kill されうる、という意味なので、**必ず warn を
786
+ * 残す**(黙って kill 可能にしない。後から「なぜ実行中に畳まれたのか」を追えるようにする)。 */
787
+ _hasLiveTask(now = Date.now()) {
788
+ if (!this._liveTasks || this._liveTasks.size === 0) return false
789
+ for (const [taskId, entry] of this._liveTasks) {
790
+ const ttl = taskProtectTtlFor(entry?.taskType)
791
+ const elapsed = now - (entry?.lastSeenAt ?? 0)
792
+ if (elapsed <= ttl) continue
793
+ this._liveTasks.delete(taskId)
794
+ this.logger?.warn?.(
795
+ {
796
+ stream_id: this.stream_id,
797
+ task_id: taskId,
798
+ task_type: entry?.taskType ?? null,
799
+ elapsed_s: Math.round(elapsed / 1000),
800
+ ttl_ms: ttl,
801
+ },
802
+ "task protection expired (session is reapable again; a still-running task would be killed)",
803
+ )
804
+ }
805
+ return this._liveTasks.size > 0
806
+ }
807
+
623
808
  _emit(event) {
809
+ // 走行中タスクの観測は全経路の合流点であるここで 1 回だけ行う(例外は内部で握る)。
810
+ this._observeTaskEvent(event)
624
811
  if (!this.onEvent) return
625
812
  let out = event
626
813
  if (MASK_ENABLED) {
@@ -3353,14 +3540,26 @@ export class ClaudeStreamBridge extends EventEmitter {
3353
3540
  * セッションも保護する(reap すると未処理入力が失われる)。
3354
3541
  * R1(c) 多ウィンドウ相互 reap 抑止: 2 端末以上が現在ライブ購読中の多端末共有セッション
3355
3542
  * (T06542) も保護する。同一 cwd で別 session_id を開いた新規 1 本を残すために、複数
3356
- * ウィンドウが見ている共有セッションを per-cwd=1 / cwd-LRU が刈ってしまう事故を防ぐ
3357
- * (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。 */
3358
- _isSessionProtected(session) {
3543
+ * ウィンドウが見ている共有セッションを per-cwd / cwd-LRU が刈ってしまう事故を防ぐ
3544
+ * (単端末の重複セッション collapse は従来どおり働く: 単端末は count=1 で非保護)。
3545
+ * タスク実行中 (_hasLiveTask) も保護する: 背景サブエージェント / 背景コマンドはターン終了後も
3546
+ * 走り続ける(実測で終了イベントは result の 14 秒後)ため、_busy だけでは守れない。保護は
3547
+ * 失効時間つきで、終了イベントを取りこぼしても永久化しない。
3548
+ * ⚠️ 旧コメントは「codex 側には対応物を置いていない(意図的な非対称)」としていたが、これは
3549
+ * **事実として誤り**だったので訂正する。codex-cli 0.145.0 の実測(/tmp/satoru_bgprobe/raw2.jsonl,
3550
+ * raw4.jsonl)では `item/started(commandExecution, status=inProgress)` → `turn/completed` の順に
3551
+ * 届き、**turn/completed の後もそのコマンドは走り続ける**(sleep 120 で確認)。自然終了でも
3552
+ * `item/completed` は届く(sleep 20 の追試で turn/completed の 17 秒後)。したがって codex 側にも
3553
+ * 対応物(`liveExecItems` 台帳 + `_hasLiveExec`)を置いた(codex-stream-bridge.mjs)。
3554
+ * 観測は **通知の台帳**で行い、権威照会 `thread/backgroundTerminals/list` は使わない
3555
+ * (実験 API の有効化が要るうえ、threadId ごとの照会なので子スレッドの背景コマンドを取りこぼす)。 */
3556
+ _isSessionProtected(session, now = Date.now()) {
3359
3557
  if (!session) return false
3360
3558
  if (session._busy) return true
3361
3559
  if (Array.isArray(session._pendingMessages) && session._pendingMessages.length > 0) {
3362
3560
  return true
3363
3561
  }
3562
+ if (session._hasLiveTask?.(now)) return true
3364
3563
  return this._liveTerminalCount(session) >= 2
3365
3564
  }
3366
3565
 
@@ -3382,26 +3581,62 @@ export class ClaudeStreamBridge extends EventEmitter {
3382
3581
  }
3383
3582
 
3384
3583
  /**
3385
- * 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
3584
+ * 総 live 上限 M(warm-bounds-total.mjs)用の **CLI 非依存アダプタ**。live セッションを
3585
+ * `{ kind, cwd, recency, seq, protected, reap }` の共通形へ包んで返すだけ(新しい判断は持たない)。
3586
+ * warm セッションは this.sessions から外れ _liveBySession に残るため union を dedup して走査する。
3587
+ * @param {number} [now]
3588
+ * @returns {Array<object>}
3589
+ */
3590
+ listWarmCandidates(now = Date.now()) {
3591
+ const out = []
3592
+ const seen = new Set()
3593
+ for (const s of [...this.sessions.values(), ...this._liveBySession.values()]) {
3594
+ if (!s || s._closed || seen.has(s)) continue
3595
+ seen.add(s)
3596
+ out.push({
3597
+ kind: "claude",
3598
+ cwd: s.cwd,
3599
+ recency: this._sessionRecency(s),
3600
+ seq: s._seq ?? 0,
3601
+ protected: this._isSessionProtected(s, now),
3602
+ reap: (reason) => this._reapForWarmBounds(s, reason),
3603
+ })
3604
+ }
3605
+ return out
3606
+ }
3607
+
3608
+ /**
3609
+ * 子⑤ (T06544): warm 保持を「各 cwd K 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する
3386
3610
  * (codex-stream-bridge.mjs の `_reconcileWarmBounds` と対)。state loop から周期実行する。
3387
- * Phase 1 (per-cwd=1): 同一 cwd に 2 本以上の live session があれば最終活性が最新の 1 本を残し、
3388
- * 残りの **非 busy** を reap する(WS 再接続は購読者追加=同一 session なので通常は発火せず、
3389
- * 別会話を同一 cwd で開始した等で 2 本目の SDK query ができたときだけ畳む)。
3611
+ * Phase 1 (per-cwd=K): 同一 cwd に K 本を超える live session があれば最終活性が新しい順に
3612
+ * 先頭 K 本を残し、それ以降の **非 busy** を reap する(K の既定は 3。旧仕様の per-cwd=1 を
3613
+ * 一般化したもので、1 cwd で複数会話を同時に生かすため。総量は Phase 2 と総 live 上限 M
3614
+ * (warm-bounds-total.mjs)で押さえる)。
3390
3615
  * Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が上限を超えたら、cwd の最終活性が
3391
3616
  * 最も古い cwd から、その cwd の非 busy session を reap して cwd 数を上限まで戻す。
3392
3617
  * busy(走行中ターン)は Phase 1/2 いずれでも reap しない(完走を待つ)。warm セッションは
3393
3618
  * this.sessions から外れ _liveBySession に残るため、両索引の union を dedup して走査する。
3394
- * @param {{ maxCwds?: number, now?: number }} [opts] テストから上限/時刻を注入可能。
3619
+ * @param {{ maxCwds?: number, maxPerCwd?: number, now?: number }} [opts] テストから上限/時刻を注入可能。
3395
3620
  * @returns {number} reap 数
3396
3621
  */
3397
- reconcileWarmBounds({ maxCwds = WARM_CWD_MAX, now = Date.now() } = {}) {
3398
- void now // recency 比較は相対値のため now は将来の拡張用(署名の対称性維持)。
3622
+ reconcileWarmBounds({ maxCwds, maxPerCwd, now = Date.now() } = {}) {
3623
+ // ⚠️ 上限は **正の整数**でなければならない。1.5 のような値が for の開始添字に入ると、
3624
+ // undefined の reap が no-op なのに reaped だけ加算され「畳んでいないのに畳んだと報告する」。
3625
+ // 不正値は既定へ戻して warn(黙って floor しない。positive-int-env.mjs 参照)。
3626
+ const cwdLimit = sanitizePositiveInt(maxCwds, WARM_CWD_MAX, {
3627
+ logger: this.logger,
3628
+ label: "claude.warmBounds.maxCwds",
3629
+ })
3630
+ const keepPerCwd = sanitizePositiveInt(maxPerCwd, WARM_PER_CWD_MAX, {
3631
+ logger: this.logger,
3632
+ label: "claude.warmBounds.maxPerCwd",
3633
+ })
3399
3634
  const liveSessions = () =>
3400
3635
  Array.from(new Set([...this.sessions.values(), ...this._liveBySession.values()])).filter(
3401
3636
  (s) => s && !s._closed,
3402
3637
  )
3403
3638
  let reaped = 0
3404
- // Phase 1: per-cwd=1。cwd ごとに最終活性が新しい順へ並べ、先頭(最新)以外の非 busy を reap。
3639
+ // Phase 1: per-cwd=K。cwd ごとに最終活性が新しい順へ並べ、先頭 K 本以外の非 busy を reap。
3405
3640
  const byCwd = new Map()
3406
3641
  for (const s of liveSessions()) {
3407
3642
  const list = byCwd.get(s.cwd)
@@ -3409,14 +3644,14 @@ export class ClaudeStreamBridge extends EventEmitter {
3409
3644
  else byCwd.set(s.cwd, [s])
3410
3645
  }
3411
3646
  for (const list of byCwd.values()) {
3412
- if (list.length <= 1) continue
3647
+ if (list.length <= keepPerCwd) continue
3413
3648
  // 新しい順。recency 同値は _seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
3414
3649
  list.sort(
3415
3650
  (a, b) =>
3416
3651
  this._sessionRecency(b) - this._sessionRecency(a) || (b._seq ?? 0) - (a._seq ?? 0),
3417
3652
  )
3418
- for (let i = 1; i < list.length; i += 1) {
3419
- if (this._isSessionProtected(list[i])) continue // 走行中/pending は不可侵(一時的に >1 を許容)
3653
+ for (let i = keepPerCwd; i < list.length; i += 1) {
3654
+ if (this._isSessionProtected(list[i], now)) continue // 走行中/pending/タスク実行中は不可侵(一時的に >K を許容)
3420
3655
  this._reapForWarmBounds(list[i], "warm-bounds:per-cwd")
3421
3656
  reaped += 1
3422
3657
  }
@@ -3432,10 +3667,10 @@ export class ClaudeStreamBridge extends EventEmitter {
3432
3667
  const r = this._sessionRecency(s)
3433
3668
  if (r > info.recency) info.recency = r
3434
3669
  if ((s._seq ?? 0) > info.seq) info.seq = s._seq ?? 0 // round5 Finding 1: cwd タイブレーク用
3435
- if (this._isSessionProtected(s)) info.hasProtected = true
3670
+ if (this._isSessionProtected(s, now)) info.hasProtected = true
3436
3671
  info.sessions.push(s)
3437
3672
  }
3438
- const overflow = cwdInfo.size - maxCwds
3673
+ const overflow = cwdInfo.size - cwdLimit
3439
3674
  if (overflow > 0) {
3440
3675
  // protected(走行中/pending)を含む cwd は退避対象外(上限にカウントはするが reap しない)。
3441
3676
  // 最古 recency の cwd から。recency 同値は seq(新しさ)で必ず新しい cwd を残す。
@@ -3446,7 +3681,7 @@ export class ClaudeStreamBridge extends EventEmitter {
3446
3681
  for (const info of evictableCwds) {
3447
3682
  if (cwdsEvicted >= overflow) break
3448
3683
  for (const s of info.sessions) {
3449
- if (this._isSessionProtected(s)) continue
3684
+ if (this._isSessionProtected(s, now)) continue
3450
3685
  this._reapForWarmBounds(s, "warm-bounds:cwd-lru")
3451
3686
  reaped += 1
3452
3687
  }