@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.
@@ -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 が返ると連携カードが通常の青い
@@ -1900,22 +2077,14 @@ export class CodexStreamBridge extends EventEmitter {
1900
2077
  * busy: boolean, detached: boolean, idle_ms: number}>}
1901
2078
  */
1902
2079
  describeLiveSessions(cwd) {
1903
- const now = Date.now()
1904
2080
  const out = []
1905
2081
  // 案B: sessions は多対一なので distinct session で走査する(購読端末数ぶん重複させない)。
2082
+ // ⚠️ 記述子の組み立ては **_describe に一本化** する(claude 版が describeLiveSessions →
2083
+ // _describeCandidate へ寄せているのと同じ構造)。ここで別に組み立てると、片方だけ直した
2084
+ // ときに「一覧に出るフィールド」と「配送結果・候補一覧に出るフィールド」が無言でずれる。
1906
2085
  for (const session of this._liveSessions()) {
1907
2086
  if (cwd && session.cwd !== cwd) continue
1908
- out.push({
1909
- kind: "codex",
1910
- cwd: session.cwd,
1911
- stream_id: session.stream_id,
1912
- thread_id: session.threadId || null,
1913
- busy: !!session.activeTurnId,
1914
- detached: !!session.detachRequested,
1915
- // idle_ms は「新しさ」の指標(一覧の並び・宛先選定に使う)なので、閲覧 keepalive で
1916
- // 動く lastActivityMs ではなく **会話ターン境界時刻 lastTurnAtMs** を基準にする。
1917
- idle_ms: now - (session.lastTurnAtMs ?? session.lastActivityMs ?? now),
1918
- })
2087
+ out.push(this._describe(session))
1919
2088
  }
1920
2089
  return out
1921
2090
  }
@@ -1923,7 +2092,9 @@ export class CodexStreamBridge extends EventEmitter {
1923
2092
  /**
1924
2093
  * 指定 cwd の「権威セッション」を返す(T06407 増分2, axis2 の単一正本)。同一 cwd に複数
1925
2094
  * ライブ codex セッションがあるとき、**最終活性が最新のもの**(=いまユーザーが使っている
1926
- * 会話)を選ぶ。sendToCwd の宛先選定と同じ規則に揃える。
2095
+ * 会話)を選ぶ。⚠️ 表示系(context% の収束)専用の規則であり、**配送(sendToCwd)はこの
2096
+ * 推測を使わない**(曖昧なら配送せず ambiguous を返す)。誤った表示は次 tick で直るが、
2097
+ * 誤配送は取り返しがつかないため、両者の判定を意図的に分けてある。
1927
2098
  *
1928
2099
  * ⚠️ context%(ドーナツ)は thread 固有の値なので「どのセッションから読んでも同じ」という
1929
2100
  * 旧 rate-limit 前提(先着 1 件で十分)は成り立たない。state loop(threadIdForCwd 経由)と
@@ -1975,19 +2146,15 @@ export class CodexStreamBridge extends EventEmitter {
1975
2146
  * 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
1976
2147
  * 2026-07-04)。Claude 版 `sendToCwd` の対。
1977
2148
  *
1978
- * ⚠️ 同一 cwd に複数ライブセッションがあるとき (ブラウザの WS 再接続ごとに新しい
1979
- * stream_id でセッションが増えるため頻繁に起きる) は **最終活性が最新のもの** を
1980
- * 選ぶ。旧実装は Map の挿入順 = 最古を選んでいたため、ブラウザが既に居ない
1981
- * ゾンビセッションへ注入して「ok なのに画面に出ない」事故が起きた (2026-07-17)。
1982
- * claude-stream-bridge.mjs の同名メソッドと同じ選択規則に揃えてある。
2149
+ * ⚠️ 宛先解決: 候補を **attached (前面=detach 未要求) 優先** の層に分け、層内が 1 本のときだけ
2150
+ * 配送する。層内が 2 本以上なら推測せず `{status:"ambiguous", candidates}` を返して配送しない
2151
+ * (fail-safe)。claude-stream-bridge.mjs の同名メソッドと同じ判定規則である。
1983
2152
  *
1984
2153
  * ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
1985
2154
  * src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
1986
2155
  *
1987
2156
  * ⚠️ 返り値 (2026-07-23, 統一再設計 1-1): claude 版と契約を揃えるため boolean から status
1988
- * オブジェクトへ変更した。codex は計数(describeLiveSessions)と配送が同一集合 (_liveSessions())
1989
- * を見るため claude のような undercount 由来のサイレント誤配送は構造的に起きず、宛先選定は
1990
- * 従来どおり「会話ターン境界時刻が最新」のヒューリスティックを維持する (挙動不変)。
2157
+ * オブジェクトへ変更した。
1991
2158
  *
1992
2159
  * @param {string} cwd
1993
2160
  * @param {string} text
@@ -1995,6 +2162,7 @@ export class CodexStreamBridge extends EventEmitter {
1995
2162
  * {status:"delivered", session:object, delivery_id:string, accepted_via:string}
1996
2163
  * | {status:"delivery_unconfirmed", session:object, delivery_id:string, accepted_via:string, reason:string}
1997
2164
  * | {status:"delivery_failed", session:object, delivery_id:string, reason:string}
2165
+ * | {status:"ambiguous", candidates:object[]}
1998
2166
  * | {status:"not_found"}
1999
2167
  * >}
2000
2168
  */
@@ -2005,27 +2173,30 @@ export class CodexStreamBridge extends EventEmitter {
2005
2173
  // attached (前面=detach 未要求) 優先 (2026-07-23 統一再設計 1-1, cross-review 指摘4):
2006
2174
  // detach 済み warm セッションが lastTurnAtMs 上たまたま新しくても、attached な現行セッションが
2007
2175
  // あればそちらへ配送する (browser を離れた/畳まれかけの旧会話 ghost への誤配送を防ぐ)。
2008
- // claude と違い codex は同一 cwd に複数 attached が併存し得る (別スレッドの新会話 / ターン
2009
- // 実行中) ため fail-safe ambiguous にはせず、tier 内は会話ターン境界時刻が最新のものを選ぶ
2010
- // (閲覧 keepalive で動く lastActivityMs は使わない = 見ているだけの古い会話が本命を追い越さない)。
2011
2176
  const attached = cands.filter((s) => !s.detachRequested)
2012
2177
  const tier = attached.length ? attached : cands
2013
- let best = null
2014
- let bestAt = -1
2015
- for (const session of tier) {
2016
- const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
2017
- if (at >= bestAt) {
2018
- bestAt = at
2019
- best = session
2020
- }
2021
- }
2022
- if (!best) {
2178
+ if (!tier.length) {
2023
2179
  this.logger?.warn?.(
2024
2180
  { cwd, live_sessions: this.describeLiveSessions() },
2025
2181
  "codex sendToCwd: no live session for cwd (ask-sibling will fail)",
2026
2182
  )
2027
2183
  return { status: "not_found" }
2028
2184
  }
2185
+ // 層内が 2 本以上なら **推測せず配送しない** (fail-safe。claude 版 sendToCwd と同じ判定):
2186
+ // 旧実装は tier 内で「会話ターン境界時刻 lastTurnAtMs が最新のもの」を選んでいたが、これは
2187
+ // 「同一 cwd の live は実質 1 本 (2 本目は transient)」という前提の上に成り立つ推測だった。
2188
+ // 同一 cwd に複数 live を常態として許す方針へ変わった以上、この推測は誤配送の温床になる
2189
+ // (別スレッドの新会話へ配ってしまう / 見た目は ok なのに本命の画面には出ない)。曖昧なときは
2190
+ // 候補を返して呼び出し側に stream_id / thread_id で名指しさせる (sendToStream)。
2191
+ if (tier.length > 1) {
2192
+ const candidates = tier.map((s) => this._describe(s))
2193
+ this.logger?.warn?.(
2194
+ { cwd, candidates },
2195
+ "codex sendToCwd: ambiguous target (multiple live sessions for cwd; not delivering)",
2196
+ )
2197
+ return { status: "ambiguous", candidates }
2198
+ }
2199
+ const best = tier[0]
2029
2200
  // detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
2030
2201
  // detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
2031
2202
  // 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
@@ -2033,17 +2204,16 @@ export class CodexStreamBridge extends EventEmitter {
2033
2204
  this._touch(best)
2034
2205
  return this._deliverInjection(best, text, source, {
2035
2206
  cwd,
2036
- selection: "most-recently-active",
2207
+ selection: "sole-candidate",
2037
2208
  candidates: this.describeLiveSessions(cwd).length,
2038
2209
  })
2039
2210
  }
2040
2211
 
2041
2212
  /**
2042
2213
  * 指定した id (stream_id もしくは thread_id) のライブセッションへ直接注入する
2043
- * (sibling-inject の **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd は cwd 内で
2044
- * 最終活性が最新のものを選ぶヒューリスティックのため、同一 cwd に複数の生きた codex
2045
- * セッションが同居すると誤爆する。呼び出し側が describeLiveSessions で得た id を明示
2046
- * すれば曖昧さを回避できる。
2214
+ * (sibling-inject の **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd は同一 cwd に
2215
+ * 複数の生きた codex セッションが同居すると (推測配送せず) ambiguous を返して止まる。
2216
+ * 呼び出し側が describeLiveSessions で得た id を明示すれば、その曖昧さを解消して配送できる。
2047
2217
  *
2048
2218
  * stream_id は WS 再接続 / in-memory reattach で付け替わる (不安定) 一方、thread_id は
2049
2219
  * 会話に固定 (安定) なので、**両方を受け付け** どちらで指定しても解決できるようにする。
@@ -2257,14 +2427,30 @@ export class CodexStreamBridge extends EventEmitter {
2257
2427
  }
2258
2428
  }
2259
2429
 
2260
- /** describeLiveSessions と同形の 1 セッション記述子 (sendToStream の結果整形に共用)。 */
2430
+ /**
2431
+ * セッション記述子の **単一定義** (describeLiveSessions / sendToCwd / sendToStream の
2432
+ * 結果整形が全てここを通る)。claude 版 `_describeCandidate` の対。
2433
+ *
2434
+ * ⚠️ なぜ 1 箇所に集約するか (2026-08-02): 同じ「セッションの記述子」が describeLiveSessions と
2435
+ * _describe の 2 か所で別々に定義されていると、片方だけ直したときに無言でずれる。実際
2436
+ * `detached` / `idle_ms` は一覧側にしか無く、sendToCwd の ambiguous 候補一覧 (= 利用者が
2437
+ * 宛先を選ぶために読むもの) には落ちていなかった。`idle_ms` は「どれが最近動いた会話か」を
2438
+ * 示す唯一の手がかりなので、候補一覧に無いと利用者は宛先を選べない。
2439
+ *
2440
+ * `idle_ms` は「新しさ」の指標(一覧の並び・宛先選定に使う)なので、閲覧 keepalive で動く
2441
+ * lastActivityMs ではなく **会話ターン境界時刻 lastTurnAtMs** を基準にする(見ているだけの
2442
+ * 古い会話が本命を追い越さないように)。
2443
+ */
2261
2444
  _describe(session) {
2445
+ const now = Date.now()
2262
2446
  return {
2263
2447
  kind: "codex",
2264
2448
  cwd: session.cwd,
2265
2449
  stream_id: session.stream_id,
2266
2450
  thread_id: session.threadId || null,
2267
2451
  busy: !!session.activeTurnId,
2452
+ detached: !!session.detachRequested,
2453
+ idle_ms: now - (session.lastTurnAtMs ?? session.lastActivityMs ?? now),
2268
2454
  }
2269
2455
  }
2270
2456
 
@@ -2280,6 +2466,9 @@ export class CodexStreamBridge extends EventEmitter {
2280
2466
  * - healthy running turn: `activeTurnId` が立ち、app-server 通知が STUCK_TTL 以内に届いている。
2281
2467
  * activeTurnId 固着で通知が STUCK_TTL 途絶えたもの(stuck/ハング)は protected から外し reap
2282
2468
  * 対象にする(プロセスリーク・固着の救済)。
2469
+ * - 実行中の背景コマンド(`liveExecItems` が非空): `turn/completed` の後も走り続ける
2470
+ * commandExecution を守る(実測 /tmp/satoru_bgprobe/raw2.jsonl)。claude 側のタスク保護
2471
+ * (`_hasLiveTask`)と対になる仕組みで、失効時間つきなので終了通知を落としても永久化しない。
2283
2472
  * @param {object} session
2284
2473
  * @param {number} now
2285
2474
  * @returns {boolean}
@@ -2292,6 +2481,12 @@ export class CodexStreamBridge extends EventEmitter {
2292
2481
  // turn RPC accepted 後、対応 userMessage echo を待っている間も protected。
2293
2482
  // ここを外すと別 attach の per-cwd reconcile が ACK 先を配達確認前に reap できる。
2294
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
2295
2490
  if (!session.activeTurnId) return false
2296
2491
  return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
2297
2492
  }
@@ -2332,24 +2527,46 @@ export class CodexStreamBridge extends EventEmitter {
2332
2527
  }
2333
2528
 
2334
2529
  /**
2335
- * 子⑤ (T06544): warm 保持を「各 cwd 1 本 × 同時 warm 保持する cwd 数 N(既定 20)」で有界化する。
2336
- * 旧 `_evictWarmPoolOverflow`(マシン全体の総セッション数 LRU=5)を置換し、境界を「総数」ではなく
2337
- * 「cwd 単位」で定義する(親 T06539「1 cwd 1 本化」に整合)。
2338
- * Phase 1 (per-cwd=1): 同一 cwd に 2 本以上の live session があれば、最終活性が最新の 1 本を残し
2339
- * 残りの **非 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)で押さえる)。
2340
2554
  * Phase 2 (N-cwd LRU): live session を持つ distinct cwd 数が warmCwdMax を超えたら、cwd の最終活性
2341
2555
  * が最も古い cwd から、その cwd の非 protected session を reap して cwd 数を上限まで戻す。
2342
2556
  * protected(healthy running turn / stuck 未満)は Phase 1/2 いずれでも reap しない(busy 不可侵)。
2343
2557
  * 全 cwd が busy 等で上限に収められないときは一時的に超過を許容する(走行中ターンを絶対に殺さない)。
2344
2558
  * 多端末購読は 1 session に集約されるため(_liveSessions で dedup)、purely な多端末利用では
2345
- * per-cwd=1 は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が 2 本以上できたとき」だけ発火する。
2559
+ * per-cwd=K は自動的に満たされ、Phase 1 は「同一 cwd に別 app-server が K 本を超えてできたとき」
2560
+ * だけ発火する(旧 per-cwd=1 では「2 本以上できたとき」だった)。
2346
2561
  * genuine attach 直後と reconcile の両方から呼ぶ。
2347
2562
  * @param {number} now
2348
2563
  * @returns {number} reap した数
2349
2564
  */
2350
2565
  _reconcileWarmBounds(now, reason = "warm-bounds") {
2351
2566
  let evicted = 0
2352
- // 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
2353
2570
  const byCwd = new Map()
2354
2571
  for (const s of this._liveSessions()) {
2355
2572
  const list = byCwd.get(s.cwd)
@@ -2357,13 +2574,13 @@ export class CodexStreamBridge extends EventEmitter {
2357
2574
  else byCwd.set(s.cwd, [s])
2358
2575
  }
2359
2576
  for (const list of byCwd.values()) {
2360
- if (list.length <= 1) continue
2577
+ if (list.length <= keepPerCwd) continue
2361
2578
  // 新しい順。recency 同値は seq(生成順)で必ず新しい方を先頭(= 残す)にする(round5 Finding 1)。
2362
2579
  list.sort(
2363
2580
  (a, b) =>
2364
2581
  (b.lastActivityMs ?? 0) - (a.lastActivityMs ?? 0) || (b.seq ?? 0) - (a.seq ?? 0),
2365
2582
  )
2366
- for (let i = 1; i < list.length; i += 1) {
2583
+ for (let i = keepPerCwd; i < list.length; i += 1) {
2367
2584
  if (this._isProtected(list[i], now)) continue // busy は残す(一時的に >1 を許容)
2368
2585
  this._reapSession(list[i], now, `${reason}:per-cwd`)
2369
2586
  evicted += 1
@@ -2393,7 +2610,8 @@ export class CodexStreamBridge extends EventEmitter {
2393
2610
  for (const info of evictableCwds) {
2394
2611
  if (cwdsEvicted >= overflow) break
2395
2612
  for (const s of info.sessions) {
2396
- // per-cwd 畳み込み後は各 cwd 1 本のはずだが、念のため非 protected のみ reap。
2613
+ // per-cwd 畳み込み後も各 cwd に**最大 K 本**残る(旧 per-cwd=1 の「1 本」ではない)ので、
2614
+ // cwd 内の全 session を回して非 protected だけを reap する。
2397
2615
  if (this._isProtected(s, now)) continue
2398
2616
  this._reapSession(s, now, `${reason}:cwd-lru`)
2399
2617
  evicted += 1
@@ -2417,8 +2635,11 @@ export class CodexStreamBridge extends EventEmitter {
2417
2635
  * 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
2418
2636
  * (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
2419
2637
  * 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
2420
- * 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd 1 本 × 同時 warm cwd 数 N
2421
- * (既定 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)で押さえる。)
2422
2643
  * busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
2423
2644
  *
2424
2645
  * 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
@@ -2446,7 +2667,7 @@ export class CodexStreamBridge extends EventEmitter {
2446
2667
  reaped += 1
2447
2668
  }
2448
2669
  }
2449
- // 3: warm 境界(per-cwd=1 / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
2670
+ // 3: warm 境界(per-cwd=K / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
2450
2671
  reaped += this._reconcileWarmBounds(now, "warm-bounds")
2451
2672
  return reaped
2452
2673
  }