@cocorograph/hub-agent 0.8.23 → 0.8.25

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.
@@ -11,7 +11,7 @@
11
11
  * claude.input → input() (idle は turn/start、生成中は次ターン FIFO)
12
12
  * claude.interrupt → interrupt() (turn/interrupt)
13
13
  * claude.detach → detach() (TTL 付き graceful close)
14
- * (codex 固有) → keepalive() (閲覧中の生存シグナル。pty.resize 相当)
14
+ * (codex 固有) → keepalive() (閲覧中の生存シグナル)
15
15
  * claude.permission.reply → permissionReply() (client.respond/respondError)
16
16
  * claude.event (生 SDK イベント素通し) → 'event' (生 notification 素通し、同じ思想)
17
17
  * claude.permission.request → 'permission' (承認/入力要求の生 method+params 素通し)
@@ -52,6 +52,8 @@ import {
52
52
  verifyCodexDeliveryAudit,
53
53
  } from "./codex-delivery-audit.mjs"
54
54
  import { getOrCreateSiblingToken } from "./sibling-token.mjs"
55
+ import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
56
+ import { nextSessionSeq } from "./session-seq.mjs"
55
57
  import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
56
58
 
57
59
  /**
@@ -92,49 +94,90 @@ function siblingEnvFor(cwd) {
92
94
  // これにより意味論は「**閲覧中=無期限(keepalive で LRU 先頭維持)/ 直近 5 セッションは
93
95
  // detach しても 7 日 warm / 上限超過は LRU で 1 個 reap / busy は不可侵 / stuck は救済**」。
94
96
 
97
+ // ⚠️ 以下の上限 / TTL 系 env は **すべて readPositiveIntEnv を通す**(positive-int-env.mjs)。
98
+ // 素の `Number(env) || 既定` だと "1.5" や "Infinity" が通り、上限が for の開始添字に入ると
99
+ // `list[1.5]` が undefined になって `_isProtected(undefined)` で TypeError になる。
100
+ // 既定値と意味は従来のまま。不正値は既定へ戻して warn する(黙って丸めない)。
101
+
95
102
  // warm 保持の idle TTL(非 busy がこの時間 無活性なら reap の対象。上限内でも 7 日で頭打ち)。
96
- const IDLE_DETACH_TTL_MS =
97
- Number(process.env.HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS) || 7 * 24 * 60 * 60 * 1000
103
+ const IDLE_DETACH_TTL_MS = readPositiveIntEnv(
104
+ "HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS",
105
+ 7 * 24 * 60 * 60 * 1000,
106
+ )
98
107
 
99
- // 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd 1 本 × 同時 warm
108
+ // 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd K 本 × 同時 warm
100
109
  // 保持する cwd 数 N」へ再定義(親 T06539「1 cwd 1 本化」に整合)。旧 WARM_POOL_MAX(総数 LRU=5)は
101
110
  // 廃止し、境界を cwd 単位で定義する。1 セッション ≈ 120-140MB(node ラッパー + 本体 + MCP 子)
102
111
  // なので既定 20 cwd(~2.4-2.8GB 上限)。端末リソースで無理なら env で下げる。
103
112
  // 後方互換: 旧 HUB_AGENT_CODEX_WARM_POOL_MAX を設定していても新 cwd 上限として解釈する。
104
- const WARM_CWD_MAX =
105
- Number(process.env.HUB_AGENT_WARM_CWD_MAX) ||
106
- Number(process.env.HUB_AGENT_CODEX_WARM_POOL_MAX) ||
107
- 20
108
-
109
- // 子⑤ (T06544, codex-review round5 Finding 1): warm 境界の「最新 1 本を残す」判定で recency
110
- // (lastActivityMs) が同一ミリ秒になったときのタイブレーク用の単調増加シーケンス。生成順が後 =
111
- // 値が大きい = 新しいので、per-cwd=1 のタイでは **必ず新しく attach したセッションを残す**
112
- // (旧実装は安定ソートの挿入順で旧セッションが残り、新規 attach が即 reap される非決定性があった)。
113
- let CODEX_SESSION_SEQ = 0
113
+ const WARM_CWD_MAX = readPositiveIntEnv(
114
+ ["HUB_AGENT_WARM_CWD_MAX", "HUB_AGENT_CODEX_WARM_POOL_MAX"],
115
+ 20,
116
+ )
117
+
118
+ // 1 cwd あたりの live セッション上限 K(既定 3)。旧仕様の per-cwd=1 を一般化したもので、
119
+ // 「1 cwd で複数会話を同時に生かす」ため。総量は増やさず配分の自由度だけ上げる方針なので、
120
+ // 総 live 上限 M(warm-bounds-total.mjs、claude と合算)とセットで効かせる。
121
+ // ⚠️ 既存 env(HUB_AGENT_WARM_CWD_MAX / HUB_AGENT_CODEX_WARM_POOL_MAX = 同時 warm 保持する
122
+ // cwd 数 N)の意味は変えない。新しい上限は新しい env 名で足す。
123
+ const WARM_PER_CWD_MAX = readPositiveIntEnv("HUB_AGENT_WARM_PER_CWD_MAX", 3)
114
124
 
115
125
  // stuck turn 検出の閾値。activeTurnId が立っているのに app-server 通知がこの時間途絶えた
116
126
  // セッションは「ハングしたターン」とみなし protected から外して reap する(healthy な長ターンは
117
127
  // reasoning/output delta で都度 touch されるため該当しない)。旧 `HUB_AGENT_DEAD_TERMINAL_TTL_MS`
118
128
  // を後方互換で受ける。既定 1h。
119
- const STUCK_TURN_TTL_MS =
120
- Number(process.env.HUB_AGENT_CODEX_STUCK_TURN_TTL_MS) ||
121
- Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
122
- 60 * 60 * 1000
129
+ const STUCK_TURN_TTL_MS = readPositiveIntEnv(
130
+ ["HUB_AGENT_CODEX_STUCK_TURN_TTL_MS", "HUB_AGENT_DEAD_TERMINAL_TTL_MS"],
131
+ 60 * 60 * 1000,
132
+ )
123
133
 
124
134
  // 案B (T06542): 死端末 GC の TTL。多端末購読で、応答を返さなくなった(クラッシュ / タブ強制終了で
125
135
  // codex.detach を送れなかった)購読端末を購読集合から掃除する閾値。claude-stream-bridge.mjs の
126
136
  // DEAD_TERMINAL_TTL_MS(既定 1h)と同型・同既定。stuck turn TTL とは別軸(あちらはターン固着、
127
137
  // こちらは端末の無応答)だが既定は揃える。
128
- const DEAD_TERMINAL_TTL_MS =
129
- Number(process.env.HUB_AGENT_CODEX_DEAD_TERMINAL_TTL_MS) ||
130
- Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
131
- 60 * 60 * 1000
138
+ const DEAD_TERMINAL_TTL_MS = readPositiveIntEnv(
139
+ ["HUB_AGENT_CODEX_DEAD_TERMINAL_TTL_MS", "HUB_AGENT_DEAD_TERMINAL_TTL_MS"],
140
+ 60 * 60 * 1000,
141
+ )
142
+
143
+ // 実行中の背景コマンド(commandExecution)を warm 境界の刈り取りから保護する失効時間(既定 4 時間)。
144
+ //
145
+ // 【なぜ必要か(旧「意図的な非対称」の訂正)】codex-cli 0.145.0 の実測
146
+ // (/tmp/satoru_bgprobe/raw2.jsonl)で、`item/started(commandExecution, status=inProgress)` →
147
+ // `turn/completed` の順に届いたあと **そのコマンドはまだ走っている**(sleep 120 で確認)。
148
+ // つまり activeTurnId(= busy)だけでは実行中の背景コマンドを守れず、claude 側の _liveTasks と
149
+ // 同型の台帳が要る。自然終了でも `item/completed` は届く(sleep 20 の追試で turn/completed の
150
+ // 17 秒後に status=completed。/tmp/satoru_bgprobe/raw4.jsonl)ので、通常は終了で解ける。
151
+ //
152
+ // 【なぜ claude の local_bash と同じ長さか】codex の `item/commandExecution/outputDelta`(鼓動)は
153
+ // **コマンド依存で来ないことがある**(出力の無いコマンドでは 1 件も来ない)。鼓動が保証されない
154
+ // 種別は長い方に揃える、という claude 側と同じ方針。
155
+ //
156
+ // ⚠️ これは**仕様上の最大保護時間**であり、**4 時間を超える背景コマンドは保護されない**。
157
+ //
158
+ // 【有界なのは「時間」だけで、「件数」ではない】(cross-review round2 minor)
159
+ // 1 エントリあたりの保護時間は TTL で有界(4 時間で頭打ち)。一方で **同時に保護されうるセッション
160
+ // 数には K(per-cwd)も N(cwd 数)も M(総 live 上限、warm-bounds-total.mjs)も効かない**。すべての
161
+ // 境界が protected を skip する設計なので、4 時間以内に別セッションで背景コマンドを増やせば、保護中の
162
+ // 合計は M を超えて増え続ける。これは「走行中を絶対に殺さない」を優先した**明示的な超過受容**であり、
163
+ // **M をハードな資源上限と読んではいけない**。超過量は warm-bounds-total.mjs が返す
164
+ // `liveTotal` / `unresolved` のログで観測できる(運用判断はその実測値を見る)。
165
+ //
166
+ // ⚠️ 将来ハード上限(保護中でも刈る)を足すなら、**実行中コマンドを中断するという新しい失敗モード**
167
+ // が生まれる。この TTL 設計の延長で暗黙に足さないこと。足すなら victim の選び方・利用者への通知・
168
+ // 再開可否を別仕様として設計する。
169
+ const CODEX_EXEC_PROTECT_TTL_MS = readPositiveIntEnv(
170
+ "HUB_AGENT_CODEX_EXEC_PROTECT_TTL_MS",
171
+ 4 * 60 * 60 * 1000,
172
+ )
132
173
 
133
174
  // 案B (T06542, codex-review round3 major#2): turn/start RPC 成功後に turn/started 通知が来るのを待つ
134
175
  // 上限。これを超えたら「ターンが正しく始まらなかった」とみなし、pump が待機中の queue を reject して
135
176
  // 状態を解除する(turn/started/completed/exit が来ないケースで promise が永久宙吊りになるのを防ぐ)。
136
- const TURN_START_TIMEOUT_MS =
137
- Number(process.env.HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS) || 30 * 1000
177
+ const TURN_START_TIMEOUT_MS = readPositiveIntEnv(
178
+ "HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS",
179
+ 30 * 1000,
180
+ )
138
181
 
139
182
  // browser が ack を取りこぼしても queue snapshot から受理済みと再確認できるよう、
140
183
  // 直近 input_id を bounded ring で保持する (Claude queue_state.accepted と同じ契約)。
@@ -143,8 +186,10 @@ const ACCEPTED_INPUT_ID_RING_MAX = 256
143
186
  // sibling-inject の turn RPC が成功した後、app-server が同じ userMessage を
144
187
  // item/completed で echo するまで待つ上限。RPC accepted と userMessage delivered を
145
188
  // 分離し、旧 stream / 初期化競合で rollout に残らない入力を成功扱いしない。
146
- const DELIVERY_ECHO_TIMEOUT_MS =
147
- Number(process.env.HUB_AGENT_CODEX_DELIVERY_ECHO_TIMEOUT_MS) || 8 * 1000
189
+ const DELIVERY_ECHO_TIMEOUT_MS = readPositiveIntEnv(
190
+ "HUB_AGENT_CODEX_DELIVERY_ECHO_TIMEOUT_MS",
191
+ 8 * 1000,
192
+ )
148
193
 
149
194
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
150
195
 
@@ -158,9 +203,11 @@ const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0
158
203
  // が起きる。thread/resume 自体はモデル側の会話コンテキスト復元のため全量が必要だが、
159
204
  // **browser へ渡す表示用履歴は末尾 N ターン分に絞る** ことで断崖を回避する。上限は
160
205
  // 「item 数」と「概算バイト数」の 2 軸で、末尾から詰めてどちらか先に達したら truncated。
161
- const HISTORY_MAX_ITEMS = Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_ITEMS) || 200
162
- const HISTORY_MAX_BYTES =
163
- Number(process.env.HUB_AGENT_CODEX_HISTORY_MAX_BYTES) || 1.5 * 1024 * 1024
206
+ const HISTORY_MAX_ITEMS = readPositiveIntEnv("HUB_AGENT_CODEX_HISTORY_MAX_ITEMS", 200)
207
+ const HISTORY_MAX_BYTES = readPositiveIntEnv(
208
+ "HUB_AGENT_CODEX_HISTORY_MAX_BYTES",
209
+ 1.5 * 1024 * 1024,
210
+ )
164
211
 
165
212
  // Slice 6: thread/resume が返す Thread.turns[].items[] を単一の時系列配列へ平坦化する。
166
213
  // turns は開始ターン順の配列、各 turn.items も時系列順のため単純結合で会話順を保てる。
@@ -318,6 +365,7 @@ export class CodexStreamBridge extends EventEmitter {
318
365
  clientFactory,
319
366
  detachTtlMs,
320
367
  warmCwdMax,
368
+ warmPerCwdMax,
321
369
  stuckTurnTtlMs,
322
370
  deadTerminalTtlMs,
323
371
  turnStartTimeoutMs,
@@ -337,9 +385,19 @@ export class CodexStreamBridge extends EventEmitter {
337
385
  this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
338
386
  // Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
339
387
  this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
340
- // 子⑤ (T06544): 同時 warm 保持する cwd 数の上限(既定 20)。各 cwd は 1 本に畳む。
341
- // テストから小さい値を注入可能。
342
- this._warmCwdMax = warmCwdMax ?? WARM_CWD_MAX
388
+ // 子⑤ (T06544): 同時 warm 保持する cwd 数の上限 N(既定 20)。各 cwd は K 本に畳む。
389
+ // テストから小さい値を注入可能。⚠️ 正の整数以外(1.5 / Infinity / NaN)は既定へ戻して warn する。
390
+ // 1.5 が for の開始添字に入ると `list[1.5]` が undefined になり `_isProtected(undefined)` で
391
+ // TypeError(positive-int-env.mjs 参照)。
392
+ this._warmCwdMax = sanitizePositiveInt(warmCwdMax, WARM_CWD_MAX, {
393
+ logger: this.logger,
394
+ label: "codex.warmCwdMax",
395
+ })
396
+ // 1 cwd あたりの live セッション上限 K(既定 3)。テストから 1 を注入すると旧 per-cwd=1 挙動。
397
+ this._warmPerCwdMax = sanitizePositiveInt(warmPerCwdMax, WARM_PER_CWD_MAX, {
398
+ logger: this.logger,
399
+ label: "codex.warmPerCwdMax",
400
+ })
343
401
  // Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
344
402
  this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
345
403
  // 案B (T06542): 死端末 GC の TTL(既定 1h)。テストから短縮可能。
@@ -542,8 +600,10 @@ export class CodexStreamBridge extends EventEmitter {
542
600
  // (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
543
601
  stream_id,
544
602
  // 子⑤ round5 Finding 1: 生成順の単調増加シーケンス(warm 境界のタイブレーク用。値が大きい =
545
- // 新しい)。recency 同値時に per-cwd=1 が新しいセッションを残せるようにする。
546
- seq: (CODEX_SESSION_SEQ += 1),
603
+ // 新しい)。recency 同値時に per-cwd=K が新しいセッションを残せるようにする。
604
+ // ⚠️ カウンタは **claude / codex 共通**(session-seq.mjs)。総 live 上限 M は両 CLI を合算して
605
+ // 古い順に刈るため、CLI ごとに別カウンタだと CLI をまたいだ比較が成立しない。
606
+ seq: nextSessionSeq(),
547
607
  cwd,
548
608
  // 案B (T06542): 多端末購読集合。この live session を購読中の全端末 stream_id。reattach は
549
609
  // 「所有権付け替え」ではなく「購読者追加」。detach で当該端末を外し、最後の1台が外れたら
@@ -626,6 +686,24 @@ export class CodexStreamBridge extends EventEmitter {
626
686
  * deliveryId: string, sourceStreamId: string, deliveryPending: boolean,
627
687
  * deliveryPromise: Promise<object>, settleDelivery: Function}>} */
628
688
  pendingInjectSources: [],
689
+ /** 実行中の背景コマンド(commandExecution)の台帳。claude 側 `_liveTasks` と同型。
690
+ * key = `item.id`(`item/started` / `item/completed` の両方に載る唯一の安定 ID)、
691
+ * value = { threadId, startedAt, lastSeenAt }。
692
+ *
693
+ * ⚠️ `turn/completed` の後もコマンドは走り続ける(実測 /tmp/satoru_bgprobe/raw2.jsonl)ので、
694
+ * activeTurnId(busy)だけでは守れない。この台帳で `_isProtected` を延長する。
695
+ *
696
+ * ⚠️ **コマンド本文(`item.command`)は保持しない**。台帳に載せると失効 warn 等のログ経由で
697
+ * `~/.hub/agent.log`(launchd の StandardOutPath で 0644 のまま長期保存される)へ本文が流れ、
698
+ * Bearer トークン・API キー・個人情報が引数や heredoc ごと永続化される。会話 UI に流れること
699
+ * (認可された閲覧者だけが見る)と、永続ログに長期保存されることは境界が違う。
700
+ * ⚠️ ハッシュ化で代替しないこと。コマンド行はエントロピーが低く辞書で復元できるため、
701
+ * 「本文を出さない」の代わりにならない。診断は item_id / thread_id で辿る。
702
+ *
703
+ * ⚠️ threadId は**記録するだけ**で判定には使わない(帰属ガードは本 PR のスコープ外)。
704
+ * @type {Map<string, {threadId: string|null,
705
+ * startedAt: number, lastSeenAt: number}>} */
706
+ liveExecItems: new Map(),
629
707
  }
630
708
  session.ready = new Promise((resolve) => {
631
709
  settleReady = resolve
@@ -752,8 +830,8 @@ export class CodexStreamBridge extends EventEmitter {
752
830
  total_ms: Date.now() - attachStartedAt,
753
831
  timing,
754
832
  })
755
- // 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=1 / N-cwd LRU)を即時
756
- // 適用する(次の reconnect まで ~5s 待たず、同一 cwd の重複や cwd 数超過が居座るのを防ぐ)。
833
+ // 子⑤ (T06544): genuine attach で 1 本増えたので warm 境界(per-cwd=K / N-cwd LRU)を即時
834
+ // 適用する(次の reconnect まで ~5s 待たず、cwd 内の K 本超過や cwd 数超過が居座るのを防ぐ)。
757
835
  // 新セッションはたった今 touch 済み = 各判定で最新なので退避対象にならず、最も古い非 protected
758
836
  // が落ちる。
759
837
  this._reconcileWarmBounds(Date.now(), "warm-bounds-on-attach")
@@ -1345,12 +1423,111 @@ export class CodexStreamBridge extends EventEmitter {
1345
1423
  if (this.sessions.get(session.stream_id) === session) this.sessions.delete(session.stream_id)
1346
1424
  }
1347
1425
 
1426
+ /**
1427
+ * app-server 通知を観測して、実行中の背景コマンド台帳(`session.liveExecItems`)を更新する。
1428
+ *
1429
+ * 呼び出しは `_onNotification` の先頭 1 箇所だけ(claude 側 `_observeTaskEvent` と同じ理由で、
1430
+ * switch の中に散らさない)。実測(codex-cli 0.145.0 / /tmp/satoru_bgprobe/raw2.jsonl, raw4.jsonl)
1431
+ * に基づく扱い:
1432
+ * - `item/started` + `item.type === "commandExecution"` + `item.status === "inProgress"` → 登録
1433
+ * - `item/completed` + `item.type === "commandExecution"` → `item.id` で削除(冪等)
1434
+ * - `item/commandExecution/outputDelta`(`params.itemId`)→ **既存エントリがあるときだけ**延命。
1435
+ * 新規作成しない(完了後に遅れて届いた delta で蘇らせない。claude の task_progress と同じ)
1436
+ *
1437
+ * ⚠️ `item.type` であって `item_type` ではない(実測のフィールド名)。
1438
+ * ⚠️ 対象は `commandExecution` に限る。`mcpToolCall` も長時間化しうるが「turn/completed の後も
1439
+ * 走り続けるか」を**実測していない**ため含めない(未実測のものを保護に入れると、逆に
1440
+ * 解除されない保護を作りかねない)。実測できたら同じ台帳に足せばよい。
1441
+ * ⚠️ 例外を外へ投げないこと。`_onNotification` は全通知が通るので、ここで throw すると
1442
+ * ストリーム全体が壊れる。
1443
+ *
1444
+ * @param {object} session
1445
+ * @param {object} msg app-server 通知({ method, params })
1446
+ * @param {number} [now] 時刻(テストから注入可能)
1447
+ */
1448
+ _observeExecItem(session, msg, now = Date.now()) {
1449
+ try {
1450
+ const method = msg?.method
1451
+ if (typeof method !== "string" || !method) return
1452
+ const ledger = session?.liveExecItems
1453
+ if (!ledger) return
1454
+ const params = msg.params
1455
+ if (method === "item/started" || method === "item/completed") {
1456
+ const item = params?.item
1457
+ if (!item || item.type !== "commandExecution") return
1458
+ const id = item.id
1459
+ if (typeof id !== "string" || !id) return
1460
+ if (method === "item/completed") {
1461
+ ledger.delete(id)
1462
+ return
1463
+ }
1464
+ if (item.status !== "inProgress") return
1465
+ ledger.set(id, {
1466
+ // 帰属判定には使わない(記録のみ)。別件で子スレッド起因を切り分けたくなったとき用。
1467
+ threadId: typeof params?.threadId === "string" ? params.threadId : null,
1468
+ // ⚠️ `item.command`(コマンド本文)はここで**捨てる**。台帳へ持つと失効 warn 経由で
1469
+ // 永続ログ(~/.hub/agent.log)に本文が残り、秘密情報が長期保存される。
1470
+ startedAt: now,
1471
+ lastSeenAt: now,
1472
+ })
1473
+ return
1474
+ }
1475
+ if (method === "item/commandExecution/outputDelta") {
1476
+ const itemId = params?.itemId
1477
+ if (typeof itemId !== "string" || !itemId) return
1478
+ const entry = ledger.get(itemId)
1479
+ if (entry) entry.lastSeenAt = now
1480
+ }
1481
+ } catch (err) {
1482
+ this.logger?.warn?.(
1483
+ { err: err?.message, stream_id: session?.stream_id },
1484
+ "codex observe exec item threw",
1485
+ )
1486
+ }
1487
+ }
1488
+
1489
+ /** 実行中の背景コマンドを持つか(warm 境界の保護判定用)。**先に失効エントリを掃除してから**
1490
+ * 判定する。失効は「最後に生存を確認してから CODEX_EXEC_PROTECT_TTL_MS」。
1491
+ * ⚠️ 失効させる = そのセッションは kill されうる、という意味なので必ず warn を残す
1492
+ * (黙って kill 可能にしない)。
1493
+ * ⚠️ warn に**コマンド本文を載せない**(この warn は永続ログへ出る。理由は liveExecItems の
1494
+ * コメント参照)。診断は `item_id` と 2 つの thread id で辿る:
1495
+ * - `thread_id` … セッションの root スレッド(`session.threadId`)。**意味を変えないこと**
1496
+ * (既存のログ集計がこのキーで root を前提にしている)
1497
+ * - `exec_thread_id`… その commandExecution の通知に載っていた `params.threadId`(子スレッド
1498
+ * 起因かを切り分けたいとき用。root を上書きせず**別名**で出す) */
1499
+ _hasLiveExec(session, now = Date.now()) {
1500
+ const ledger = session?.liveExecItems
1501
+ if (!ledger || ledger.size === 0) return false
1502
+ for (const [itemId, entry] of ledger) {
1503
+ const elapsed = now - (entry?.lastSeenAt ?? 0)
1504
+ if (elapsed <= CODEX_EXEC_PROTECT_TTL_MS) continue
1505
+ ledger.delete(itemId)
1506
+ this.logger?.warn?.(
1507
+ {
1508
+ stream_id: session?.stream_id,
1509
+ thread_id: session?.threadId ?? null,
1510
+ item_id: itemId,
1511
+ exec_thread_id: entry?.threadId ?? null,
1512
+ elapsed_s: Math.round(elapsed / 1000),
1513
+ ttl_ms: CODEX_EXEC_PROTECT_TTL_MS,
1514
+ },
1515
+ "codex exec protection expired (session is reapable again; a still-running command would be killed)",
1516
+ )
1517
+ }
1518
+ return ledger.size > 0
1519
+ }
1520
+
1348
1521
  _onNotification(session, msg) {
1349
1522
  const { stream_id } = session
1350
1523
  // app-server からの通知 = ターンが進行している証跡。これで touch することで、
1351
1524
  // 正常に走っているターンは reaper に回収されず、通知が途絶えたスタックターンだけが
1352
1525
  // 放棄セッションとして回収される。
1353
1526
  this._touch(session)
1527
+ // 実行中の背景コマンド台帳の更新は **ここ 1 箇所** で行う(switch の中に散らさない)。
1528
+ // 全通知がこの関数を通るので、ここが唯一の合流点になる(経路ごとに配線すると、method を
1529
+ // 足したときに無言で観測漏れする)。claude 側の `_emit` → `_observeTaskEvent` と同じ設計。
1530
+ this._observeExecItem(session, msg)
1354
1531
  // 連携カード種別 (Phase 2, cross-review round1 major#2): タグ判定を switch より前に行い、
1355
1532
  // **history にもタグ済み item を積む**。旧実装は未タグの msg.params.item を history へ入れてから
1356
1533
  // ライブ emit だけタグしていたため、cwd reattach で historyItems が返ると連携カードが通常の青い
@@ -2289,6 +2466,9 @@ export class CodexStreamBridge extends EventEmitter {
2289
2466
  * - healthy running turn: `activeTurnId` が立ち、app-server 通知が STUCK_TTL 以内に届いている。
2290
2467
  * activeTurnId 固着で通知が STUCK_TTL 途絶えたもの(stuck/ハング)は protected から外し reap
2291
2468
  * 対象にする(プロセスリーク・固着の救済)。
2469
+ * - 実行中の背景コマンド(`liveExecItems` が非空): `turn/completed` の後も走り続ける
2470
+ * commandExecution を守る(実測 /tmp/satoru_bgprobe/raw2.jsonl)。claude 側のタスク保護
2471
+ * (`_hasLiveTask`)と対になる仕組みで、失効時間つきなので終了通知を落としても永久化しない。
2292
2472
  * @param {object} session
2293
2473
  * @param {number} now
2294
2474
  * @returns {boolean}
@@ -2301,6 +2481,12 @@ export class CodexStreamBridge extends EventEmitter {
2301
2481
  // turn RPC accepted 後、対応 userMessage echo を待っている間も protected。
2302
2482
  // ここを外すと別 attach の per-cwd reconcile が ACK 先を配達確認前に reap できる。
2303
2483
  if (session.pendingInjectSources?.some((entry) => entry.deliveryPending)) return true
2484
+ // 実行中の背景コマンド(commandExecution)を保護する。**この 1 箇所**で足すことで、reap の
2485
+ // 3 経路(per-cwd / N-cwd LRU / idle-TTL・stuck)と総 live 上限 M のすべてに同時に効く
2486
+ // (経路ごとに書くと、経路が増えたときに片方だけ守られる非対称が生まれる)。
2487
+ // ⚠️ activeTurnId の判定より **前** に置くこと。背景コマンドが残るのは turn/completed の
2488
+ // 後(= activeTurnId が null に戻った後)なので、下の early return を通すと届かない。
2489
+ if (this._hasLiveExec(session, now)) return true
2304
2490
  if (!session.activeTurnId) return false
2305
2491
  return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
2306
2492
  }
@@ -2341,24 +2527,46 @@ export class CodexStreamBridge extends EventEmitter {
2341
2527
  }
2342
2528
 
2343
2529
  /**
2344
- * 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する。
2345
- * 旧 `_evictWarmPoolOverflow`(マシン全体の総セッション数 LRU=5)を置換し、境界を「総数」ではなく
2346
- * 「cwd 単位」で定義する(親 T06539「1 cwd 1 本化」に整合)。
2347
- * Phase 1 (per-cwd=1): 同一 cwd に 2 本以上の live session があれば、最終活性が最新の 1 本を残し
2348
- * 残りの **非 protected** を reap する(WS 再接続や別スレッド開始で一時的に増えた重複を畳む)。
2530
+ * 総 live 上限 M(warm-bounds-total.mjs)用の **CLI 非依存アダプタ**。live セッションを
2531
+ * `{ kind, cwd, recency, seq, protected, reap }` の共通形へ包んで返すだけ(新しい判断は持たない)。
2532
+ * 多端末購読は _liveSessions() で dedup 済み。
2533
+ * @param {number} [now]
2534
+ * @returns {Array<object>}
2535
+ */
2536
+ listWarmCandidates(now = Date.now()) {
2537
+ return this._liveSessions().map((s) => ({
2538
+ kind: "codex",
2539
+ cwd: s.cwd,
2540
+ recency: s.lastActivityMs ?? 0,
2541
+ seq: s.seq ?? 0,
2542
+ protected: this._isProtected(s, now),
2543
+ reap: (reason) => this._reapSession(s, now, reason),
2544
+ }))
2545
+ }
2546
+
2547
+ /**
2548
+ * 子⑤ (T06544): warm 保持を「各 cwd K 本(既定 3)× 同時 warm 保持する cwd 数 N(既定 20)」で
2549
+ * 有界化する。旧 `_evictWarmPoolOverflow`(マシン全体の総セッション数 LRU=5)を置換し、境界を
2550
+ * 「総数」ではなく「cwd 単位」で定義する。
2551
+ * Phase 1 (per-cwd=K): 同一 cwd に K 本を超える live session があれば、最終活性が新しい順に
2552
+ * 先頭 K 本を残し、それ以降の **非 protected** を reap する(K=1 なら旧挙動。総量は Phase 2 と
2553
+ * 総 live 上限 M(warm-bounds-total.mjs)で押さえる)。
2349
2554
  * Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が warmCwdMax を超えたら、cwd の最終活性
2350
2555
  * が最も古い cwd から、その cwd の非 protected session を reap して cwd 数を上限まで戻す。
2351
2556
  * protected(healthy running turn / stuck 未満)は Phase 1/2 いずれでも reap しない(busy 不可侵)。
2352
2557
  * 全 cwd が busy 等で上限に収められないときは一時的に超過を許容する(走行中ターンを絶対に殺さない)。
2353
2558
  * 多端末購読は 1 session に集約されるため(_liveSessions で dedup)、purely な多端末利用では
2354
- * per-cwd=1 は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が 2 本以上できたとき」だけ発火する。
2559
+ * per-cwd=K は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が K 本を超えてできたとき」
2560
+ * だけ発火する(旧 per-cwd=1 では「2 本以上できたとき」だった)。
2355
2561
  * genuine attach 直後と reconcile の両方から呼ぶ。
2356
2562
  * @param {number} now
2357
2563
  * @returns {number} reap した数
2358
2564
  */
2359
2565
  _reconcileWarmBounds(now, reason = "warm-bounds") {
2360
2566
  let evicted = 0
2361
- // Phase 1: per-cwd=1。cwd ごとに最終活性が新しい順へ並べ、先頭(最新)以外の非 protected を reap。
2567
+ // Phase 1: per-cwd=K。cwd ごとに最終活性が新しい順へ並べ、先頭 K 本以外の非 protected を reap。
2568
+ // K はコンストラクタで正の整数へ検証済み(不正値は既定へ戻して warn 済み)。
2569
+ const keepPerCwd = this._warmPerCwdMax
2362
2570
  const byCwd = new Map()
2363
2571
  for (const s of this._liveSessions()) {
2364
2572
  const list = byCwd.get(s.cwd)
@@ -2366,13 +2574,13 @@ export class CodexStreamBridge extends EventEmitter {
2366
2574
  else byCwd.set(s.cwd, [s])
2367
2575
  }
2368
2576
  for (const list of byCwd.values()) {
2369
- if (list.length <= 1) continue
2577
+ if (list.length <= keepPerCwd) continue
2370
2578
  // 新しい順。recency 同値は seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
2371
2579
  list.sort(
2372
2580
  (a, b) =>
2373
2581
  (b.lastActivityMs ?? 0) - (a.lastActivityMs ?? 0) || (b.seq ?? 0) - (a.seq ?? 0),
2374
2582
  )
2375
- for (let i = 1; i < list.length; i += 1) {
2583
+ for (let i = keepPerCwd; i < list.length; i += 1) {
2376
2584
  if (this._isProtected(list[i], now)) continue // busy は残す(一時的に >1 を許容)
2377
2585
  this._reapSession(list[i], now, `${reason}:per-cwd`)
2378
2586
  evicted += 1
@@ -2402,7 +2610,8 @@ export class CodexStreamBridge extends EventEmitter {
2402
2610
  for (const info of evictableCwds) {
2403
2611
  if (cwdsEvicted >= overflow) break
2404
2612
  for (const s of info.sessions) {
2405
- // per-cwd 畳み込み後は各 cwd 1 本のはずだが、念のため非 protected のみ reap。
2613
+ // per-cwd 畳み込み後も各 cwd に**最大 K 本**残る(旧 per-cwd=1 の「1 本」ではない)ので、
2614
+ // cwd 内の全 session を回して非 protected だけを reap する。
2406
2615
  if (this._isProtected(s, now)) continue
2407
2616
  this._reapSession(s, now, `${reason}:cwd-lru`)
2408
2617
  evicted += 1
@@ -2426,8 +2635,11 @@ export class CodexStreamBridge extends EventEmitter {
2426
2635
  * 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
2427
2636
  * (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
2428
2637
  * 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
2429
- * 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd 1 本 × 同時 warm cwd 数 N
2430
- * (既定 20)」に有界化。同一 cwd の重複は最新 1 本へ畳み、cwd 数超過は最古 cwd から reap。
2638
+ * 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd **K 本(現行の既定 3)**
2639
+ * × 同時 warm cwd 数 N(既定 20)」に有界化。cwd 内で K 本を超えたぶんは最終活性が古い方から
2640
+ * reap し、cwd 数超過は最古 cwd から reap する。
2641
+ * (**旧仕様**は per-cwd=1 =「同一 cwd の重複は最新 1 本へ畳む」だった。1 cwd で複数会話を
2642
+ * 同時に生かすため K へ一般化し、総量は総 live 上限 M(warm-bounds-total.mjs)で押さえる。)
2431
2643
  * busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
2432
2644
  *
2433
2645
  * 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
@@ -2455,7 +2667,7 @@ export class CodexStreamBridge extends EventEmitter {
2455
2667
  reaped += 1
2456
2668
  }
2457
2669
  }
2458
- // 3: warm 境界(per-cwd=1 / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
2670
+ // 3: warm 境界(per-cwd=K / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
2459
2671
  reaped += this._reconcileWarmBounds(now, "warm-bounds")
2460
2672
  return reaped
2461
2673
  }
package/src/hooks.mjs CHANGED
@@ -17,7 +17,6 @@
17
17
  export const HOOK_NAMES = Object.freeze([
18
18
  "onAgentStart",
19
19
  "onAgentStop",
20
- "interceptPtySpawn",
21
20
  "interceptTmuxExec",
22
21
  "interceptSessionCreate",
23
22
  "interceptSessionKill",
@@ -26,21 +25,6 @@ export const HOOK_NAMES = Object.freeze([
26
25
  "transformStatusDetection",
27
26
  ])
28
27
 
29
- /**
30
- * @typedef {Object} PtySpawnArgs
31
- * @property {string} sessionName
32
- * @property {number} cols
33
- * @property {number} rows
34
- * @property {Record<string, string>} env
35
- */
36
-
37
- /**
38
- * @typedef {Object} PtySpawnDecision
39
- * @property {string} command
40
- * @property {string[]} args
41
- * @property {Record<string, string>} [env]
42
- */
43
-
44
28
  /**
45
29
  * @typedef {Object} TmuxExecArgs
46
30
  * @property {string[]} args
@@ -60,7 +44,6 @@ export const HOOK_NAMES = Object.freeze([
60
44
  * @property {{
61
45
  * onAgentStart?: (ctx: HookContext) => Promise<void>,
62
46
  * onAgentStop?: (ctx: HookContext) => Promise<void>,
63
- * interceptPtySpawn?: (ctx: HookContext & PtySpawnArgs) => Promise<PtySpawnDecision | null>,
64
47
  * interceptTmuxExec?: (ctx: HookContext & TmuxExecArgs) => Promise<TmuxExecDecision | null>,
65
48
  * }} hooks
66
49
  */