@cocorograph/hub-agent 0.7.79 → 0.7.80

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.7.79",
3
+ "version": "0.7.80",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -122,20 +122,20 @@ function readNeedsAuthCacheNames() {
122
122
  }
123
123
  }
124
124
 
125
- /** 多端末共有セッション (2026-05-30)。同じ Claude セッション (session_id) を複数端末で
126
- * 同時に開いて、どの端末でもライブ表示・入力・許可応答できるようにする。env
127
- * HUB_AGENT_CHAT_SHARED="1" で有効化 (デフォルト無効 = 従来の「最後の端末だけが live」)。
125
+ /** 多端末共有セッション (2026-05-30、T06542 で既定 ON 化・フラグ撤去)。同じ Claude セッション
126
+ * (session_id) を複数端末で同時に開いて、どの端末でもライブ表示・入力・許可応答できる。
128
127
  *
129
- * 有効時の差分:
130
- * - reattach で旧 stream_id を sessions Map から消さず、1 セッションに複数の端末
131
- * stream_id を購読登録する (this.streamIds)。これにより旧端末からの input /
132
- * permission.reply / interrupt も sessions.get(stream_id) で解決できる。
133
- * - detach は購読端末の参照カウントを下げ、最後の 1 台が外れて初めて idle softDetach
134
- * する (1 台閉じただけで走行中セッションを撤去しない)。
135
- * 配信のファンアウト自体は Hub backend が session_id 単位の group broadcast で行うため、
136
- * agent 側はイベントに session_id を載せるだけでよい (既に emit 済み)。無効時は従来の
137
- * 単一 stream_id 挙動と完全に一致する (this.streamIds は未使用のまま)。 */
138
- const CHAT_SHARED_ENABLED = process.env.HUB_AGENT_CHAT_SHARED === "1"
128
+ * 挙動 (常時):
129
+ * - reattach で旧 stream_id を sessions Map から消さず、1 セッションに複数の端末 stream_id を
130
+ * 購読登録する (this.streamIds)。これにより旧端末からの input / permission.reply / interrupt
131
+ * も sessions.get(stream_id) で解決できる。
132
+ * - detach は購読端末の参照カウントを下げ、最後の 1 台が外れて初めて idle softDetach する
133
+ * (1 台閉じただけで走行中セッションを撤去しない)。
134
+ * 配信のファンアウト自体は Hub backend が session_id 単位の group broadcast で行うため、agent 側は
135
+ * イベントに session_id を載せるだけでよい (既に emit 済み)。
136
+ *
137
+ * ⚠️ 旧 env `HUB_AGENT_CHAT_SHARED` は撤去した (T06542)。以前フラグ OFF だと「最後の端末だけ
138
+ * live」に退行し、環境間で挙動が割れる事故源だったため、単一挙動へ一本化した (Codex 側 案B と対称)。 */
139
139
 
140
140
  /** B11: 多端末共有時、一定期間 input/permission 等の活性が無い購読端末キーを死端末とみなして
141
141
  * GC する閾値 (ミリ秒)。端末がクラッシュして claude.detach を送らず消えると streamIds /
@@ -322,15 +322,15 @@ class ClaudeStreamSession {
322
322
  onTurnSettled,
323
323
  }) {
324
324
  this.stream_id = stream_id
325
- /** 多端末共有: このセッションを購読中の全端末 stream_id。CHAT_SHARED_ENABLED 時のみ
326
- * 参照され、reattach で増え detach で減る。最後の 1 台が外れると idle softDetach に入る。
325
+ /** 多端末共有 (T06542 で常時): このセッションを購読中の全端末 stream_id。reattach で増え
326
+ * detach で減る。最後の 1 台が外れると idle softDetach に入る。
327
327
  * this.stream_id は「最も新しく attach した端末」= legacy emit / primary 用に残す。 */
328
328
  this.streamIds = new Set([stream_id])
329
329
  /** B11: 購読端末ごとの最終アクティビティ時刻 (epoch ms)。attach / reattach / input /
330
- * permission 応答で更新する。CHAT_SHARED_ENABLED 時に「死端末 GC」が参照し、一定期間
331
- * 無活動の購読端末キーを掃除する。これが無いと端末がクラッシュして claude.detach を
332
- * 送らず消えると stream_id が streamIds/sessions に永久残留し、最後の 1 台が外れないと
333
- * idle softDetach が起動しないため死端末がセッションを永久に生かしてしまう。 */
330
+ * permission 応答で更新する。「死端末 GC」(gcDeadTerminals) が参照し、一定期間 無活動の
331
+ * 購読端末キーを掃除する。これが無いと端末がクラッシュして claude.detach を送らず消えると
332
+ * stream_id が streamIds/sessions に永久残留し、最後の 1 台が外れないと idle softDetach が
333
+ * 起動しないため死端末がセッションを永久に生かしてしまう。 */
334
334
  this.lastActivityByStream = new Map([[stream_id, Date.now()]])
335
335
  this.cwd = cwd
336
336
  this.model = model || null
@@ -1866,12 +1866,9 @@ export class ClaudeStreamBridge extends EventEmitter {
1866
1866
  // ユニークだが異常系でも cwd を跨がない)。
1867
1867
  const live = this._liveBySession.get(this._liveKeyFor(scwd, resumeSessionId))
1868
1868
  if (live && !live._closed) {
1869
- // 多端末共有 (CHAT_SHARED_ENABLED): 旧 stream_id を Map から消さない。同一セッションを
1870
- // 複数端末が購読する状態を作り、どの端末の input/permission/interrupt も解決できるように
1871
- // する。無効時は従来どおり旧 stream_id を撤去し「最後の 1 端末だけ live」に戻す。
1872
- if (!CHAT_SHARED_ENABLED) {
1873
- this.sessions.delete(live.stream_id)
1874
- }
1869
+ // 多端末共有 (T06542 で常時): 旧 stream_id を Map から消さない。同一セッションを複数端末が
1870
+ // 購読する状態を作り、どの端末の input/permission/interrupt も解決できるようにする
1871
+ // (旧端末は reattach.streamIds に足され、backend が session group へ fan-out する)。
1875
1872
  // 改修5: 再アタッチ時に model/permission/maxThinkingTokens を引き継ぎ反映する。
1876
1873
  // browser はバッジ変更時に新しい値を載せた claude.attach を同一 resume で送る
1877
1874
  // ため、ここで適用すると常駐 query を維持したまま次ターンから切り替わる。
@@ -2052,6 +2049,11 @@ export class ClaudeStreamBridge extends EventEmitter {
2052
2049
  this.logger?.warn({ stream_id, request_id }, "claude.permission.reply but stream missing")
2053
2050
  return false
2054
2051
  }
2052
+ // T06542 バグ修正: 許可応答も端末の活性シグナル。touch を呼ばないと「入力せず許可応答だけを
2053
+ // 返し続ける端末」が lastActivityByStream 未更新のまま gcDeadTerminals(1h) に死端末として掃除され、
2054
+ // 以降その端末からの入力/応答が stream_missing になる (session 定義コメント「permission 応答で
2055
+ // 更新する」と実装が不一致だった)。多端末共有を常時 ON 化すると顕在化するためここで直す。
2056
+ s.touch(stream_id)
2055
2057
  const decision = allow
2056
2058
  ? { behavior: "allow", updatedInput: updatedInput || {} }
2057
2059
  : { behavior: "deny", message: denyMessage || "denied by user" }
@@ -2550,21 +2552,19 @@ export class ClaudeStreamBridge extends EventEmitter {
2550
2552
  detach({ stream_id }) {
2551
2553
  const s = this.sessions.get(stream_id)
2552
2554
  if (!s) return false
2553
- // 多端末共有: この端末分の購読だけ外す。まだ他端末が見ているならセッションは生かし、
2554
- // softDetach (idle 撤去タイマー) には入らない。最後の 1 台が外れたときだけ従来の
2555
- // idle softDetach に進む。無効時は streamIds が常に 1 要素なので即 softDetach に落ちる。
2556
- if (CHAT_SHARED_ENABLED) {
2557
- s.streamIds.delete(stream_id)
2558
- s.lastActivityByStream.delete(stream_id) // B11: 活性記録も掃除
2559
- if (this.sessions.get(stream_id) === s) this.sessions.delete(stream_id)
2560
- if (s.streamIds.size > 0) {
2561
- // primary を生存端末へ寄せ替え (legacy emit / softDetach 撤去ログ用)。
2562
- // 末尾 (最も新しく購読した端末) を .at(-1) で明示的に選ぶ。
2563
- if (s.stream_id === stream_id) {
2564
- s.stream_id = Array.from(s.streamIds).at(-1)
2565
- }
2566
- return true
2555
+ // 多端末共有 (T06542 で常時): この端末分の購読だけ外す。まだ他端末が見ているならセッションは
2556
+ // 生かし、softDetach (idle 撤去タイマー) には入らない。最後の 1 台が外れたときだけ従来の
2557
+ // idle softDetach に進む。
2558
+ s.streamIds.delete(stream_id)
2559
+ s.lastActivityByStream.delete(stream_id) // B11: 活性記録も掃除
2560
+ if (this.sessions.get(stream_id) === s) this.sessions.delete(stream_id)
2561
+ if (s.streamIds.size > 0) {
2562
+ // primary を生存端末へ寄せ替え (legacy emit / softDetach 撤去ログ用)。
2563
+ // 末尾 (最も新しく購読した端末) を .at(-1) で明示的に選ぶ。
2564
+ if (s.stream_id === stream_id) {
2565
+ s.stream_id = Array.from(s.streamIds).at(-1)
2567
2566
  }
2567
+ return true
2568
2568
  }
2569
2569
  // 常駐化: browser 切断ではセッションを止めない (reap しない)。走行中ターンは完走し、
2570
2570
  // idle のまま IDLE_DETACH_TTL_MS 経過して初めて撤去する。TTL 内に再接続 (再アタッチ)
@@ -2585,11 +2585,10 @@ export class ClaudeStreamBridge extends EventEmitter {
2585
2585
  /** B11: 多端末共有時に「死端末」(一定期間 input/permission 等の活性が無い購読端末) を
2586
2586
  * GC する。端末がクラッシュして claude.detach を送らず消えた stream_id を掃除し、最後の
2587
2587
  * 1 台が外れたら通常の idle softDetach (走行中は完走を待つ) に進ませる。state loop など
2588
- * 既存の定期ループから呼ぶ想定。CHAT_SHARED_ENABLED 無効時は何もしない。
2588
+ * 既存の定期ループから呼ぶ想定 (T06542 で常時有効)。
2589
2589
  * @param {number} [ttlMs] 死端末とみなす無活性閾値 (テスト用に上書き可)。
2590
2590
  * @returns {number} GC した端末キー数。 */
2591
2591
  gcDeadTerminals(ttlMs = DEAD_TERMINAL_TTL_MS) {
2592
- if (!CHAT_SHARED_ENABLED) return 0
2593
2592
  const now = Date.now()
2594
2593
  let removed = 0
2595
2594
  for (const session of new Set(this.sessions.values())) {
@@ -21,10 +21,14 @@
21
21
  * (新 app-server を起動しない)。browser の WS 再接続で stream_id が変わっても、fresh 直後
22
22
  * (rollout 0件) の会話も含めて復元できる。詳細は attach() の docstring 参照。
23
23
  *
24
+ * ## 多端末購読 (案B / T06542)
25
+ * 複数ブラウザタブでの **同時** 同一スレッド共有を実装済み (Claude の多端末共有と対称)。
26
+ * session は購読集合 `streamIds` を持ち、reattach は「所有権付け替え」ではなく「購読者追加」。
27
+ * どの購読端末からの input/permission も解決でき、配信は Hub backend が thread_id グループ
28
+ * broadcast で行う (agent はイベントに thread_id を載せるだけ)。詳細は attach()/_tryReattach()/
29
+ * detach()/gcDeadTerminals() の docstring 参照。旧 Slice 2 の「単一勝者のみ (同時共有は非対応)」は撤廃。
30
+ *
24
31
  * Slice 2 のスコープ外 (意図的に見送り、後続スライスで追加する):
25
- * - 複数ブラウザタブでの **同時** 同一スレッド共有 (Claude の HUB_AGENT_CHAT_SHARED 相当)。
26
- * in-memory reattach は「1 つの持ち主が WS を張り直す」ケース (単一勝者) であり、複数タブが
27
- * 同時に同一スレッドを操作する共有はまだ扱わない。
28
32
  * - 画像/添付入力 (テキストのみ)。
29
33
  * - acceptForSession や execpolicy/network amendment 等の高度な承認 decision
30
34
  * (permissionReply は decision をそのまま素通しするので呼び出し側が組み立てる)。
@@ -98,6 +102,21 @@ const STUCK_TURN_TTL_MS =
98
102
  Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
99
103
  60 * 60 * 1000
100
104
 
105
+ // 案B (T06542): 死端末 GC の TTL。多端末購読で、応答を返さなくなった(クラッシュ / タブ強制終了で
106
+ // codex.detach を送れなかった)購読端末を購読集合から掃除する閾値。claude-stream-bridge.mjs の
107
+ // DEAD_TERMINAL_TTL_MS(既定 1h)と同型・同既定。stuck turn TTL とは別軸(あちらはターン固着、
108
+ // こちらは端末の無応答)だが既定は揃える。
109
+ const DEAD_TERMINAL_TTL_MS =
110
+ Number(process.env.HUB_AGENT_CODEX_DEAD_TERMINAL_TTL_MS) ||
111
+ Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
112
+ 60 * 60 * 1000
113
+
114
+ // 案B (T06542, codex-review round3 major#2): turn/start RPC 成功後に turn/started 通知が来るのを待つ
115
+ // 上限。これを超えたら「ターンが正しく始まらなかった」とみなし、pump が待機中の queue を reject して
116
+ // 状態を解除する(turn/started/completed/exit が来ないケースで promise が永久宙吊りになるのを防ぐ)。
117
+ const TURN_START_TIMEOUT_MS =
118
+ Number(process.env.HUB_AGENT_CODEX_TURN_START_TIMEOUT_MS) || 30 * 1000
119
+
101
120
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
102
121
 
103
122
  // T06499 (2026-07-18): 巨大スレッドの resume ガード。長期運用したスレッドの rollout は
@@ -273,6 +292,8 @@ export class CodexStreamBridge extends EventEmitter {
273
292
  detachTtlMs,
274
293
  warmPoolMax,
275
294
  stuckTurnTtlMs,
295
+ deadTerminalTtlMs,
296
+ turnStartTimeoutMs,
276
297
  rolloutPresence,
277
298
  historyMaxItems,
278
299
  historyMaxBytes,
@@ -287,6 +308,10 @@ export class CodexStreamBridge extends EventEmitter {
287
308
  this._warmPoolMax = warmPoolMax ?? WARM_POOL_MAX
288
309
  // Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
289
310
  this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
311
+ // 案B (T06542): 死端末 GC の TTL(既定 1h)。テストから短縮可能。
312
+ this._deadTerminalTtlMs = deadTerminalTtlMs ?? DEAD_TERMINAL_TTL_MS
313
+ // round3 major#2: turn/start 後に turn/started を待つ上限(既定 30s)。テストから短縮可能。
314
+ this._turnStartTimeoutMs = turnStartTimeoutMs ?? TURN_START_TIMEOUT_MS
290
315
  // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
291
316
  this._rolloutPresence = rolloutPresence || codexRolloutPresence
292
317
  // T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
@@ -381,8 +406,19 @@ export class CodexStreamBridge extends EventEmitter {
381
406
  // unhandled rejection を作らず、複数の reconnect が同じ promise を安全に await できる)。
382
407
  let settleReady
383
408
  const session = {
409
+ // primary stream_id: legacy な単一値 emit / ログ用。案B では最新購読端末へ寄せる
410
+ // (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
384
411
  stream_id,
385
412
  cwd,
413
+ // 案B (T06542): 多端末購読集合。この live session を購読中の全端末 stream_id。reattach は
414
+ // 「所有権付け替え」ではなく「購読者追加」。detach で当該端末を外し、最後の1台が外れたら
415
+ // warm 保持(reconcile が LRU/idle TTL/stuck で reap)。sessions Map は各購読 stream_id →
416
+ // この session の多対一。distinct session 走査は必ず _liveSessions() で dedup する。
417
+ streamIds: new Set([stream_id]),
418
+ // 死端末 GC 用の端末別最終活性。browser→agent アクション(input/permission/interrupt/
419
+ // keepalive)で更新し、応答を返さなくなった購読端末を gcDeadTerminals が掃除する
420
+ // (claude-stream-bridge.mjs の lastActivityByStream と同型)。
421
+ lastActivityByStream: new Map([[stream_id, Date.now()]]),
386
422
  // #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
387
423
  attachId: attachId || null,
388
424
  client,
@@ -393,6 +429,26 @@ export class CodexStreamBridge extends EventEmitter {
393
429
  // この session を引き当て _tryReattach の session.ready 合流に入り、二重 app-server を作らない。
394
430
  resumeThreadId: resumeThreadId || null,
395
431
  activeTurnId: null,
432
+ // 案B (T06542, codex-review round3 major#1): 同時入力を **session 単位の単一 FIFO + 単一 pump**で
433
+ // 直列化し、二重 turn/start・queue 追い越し・並行 steer を構造的に排除する。旧 turnStarting/
434
+ // pendingInputs の二重路 (queue 経路 + 直通経路) が overtaking と並行リトライ二重ターンの温床だった。
435
+ /** @type {Array<{input: object[], model?: string, effort?: string, resolve: Function,
436
+ * reject: Function, retriedAsStart?: boolean}>} round3 major#1: **session 単位の単一 FIFO**。
437
+ * 全 input() はここに積まれ、単一 pump (_pumpInputs) が **1 件ずつ直列**に処理する。直通経路を
438
+ * 廃したので、drain 中の新規 input が queue を追い越したり、並行 turn/start・steer を発行して
439
+ * 二重ターンになる事故が起きない。各 promise は **実 delivery (turn/start か turn/steer の RPC)**で
440
+ * settle する (main.mjs が codex.input.ack/nack を実配達に合わせて出せる)。 */
441
+ inputQueue: [],
442
+ /** round3 major#1: pump が走行中か (単一 pump を保証する singleflight フラグ)。 */
443
+ pumping: false,
444
+ /** @type {{resolve: Function, timer: any} | null} round3 major#2: turn/start 後に turn/started
445
+ * (activeTurnId 確定) を待つ barrier の待機者。timeout 付きでハングを防ぐ。 */
446
+ _turnActiveWaiter: null,
447
+ /** round4 major#2: turn/started 通知ごとに +1 する世代カウンタ。pump は turn/start dispatch 前に
448
+ * 値を控え、応答後に「world が前進したか (turn が始まった/更に完了した)」を activeTurnId だけでなく
449
+ * この前進でも判定する。fast-turn (start 応答と started+completed が同一 chunk で pump 再開前に
450
+ * 処理される) で activeTurnId=null に戻っていても、世代前進で検知して誤 timeout を防ぐ。 */
451
+ turnGeneration: 0,
396
452
  // request_id -> { turnId, method, params }。major#1: reattach で新 stream へ未応答の承認要求を
397
453
  // 再送できるよう、元の method/params も保持する (Claude の _permissionResolvers と同型)。
398
454
  pendingApprovals: new Map(),
@@ -579,32 +635,41 @@ export class CodexStreamBridge extends EventEmitter {
579
635
  * 付け替えられなければ null を返し、呼び出し側 attach() は通常経路へフォールスルーする。
580
636
  */
581
637
  async _tryReattach(session, newStreamId) {
582
- // ⚠️ 付け替え前の stream_id を **await の前に capture** する (major #1: 同じ attach_id へ
583
- // 複数 reconnect が session.ready 待ちで重なったとき、`session.stream_id` を await 後に
584
- // 読むと勝者が既に付け替えた現在値を追ってしまい「別 stream へ移動済み」を検出できず、
585
- // 敗者も成功してしまう。capture 値で CAS することで **単一勝者**にする)。
638
+ // 案B (T06542): reattach は「所有権付け替え」ではなく **購読者追加**。旧 stream を Map から
639
+ // 消さず、newStreamId を streamIds へ足して両方が同一 live session を購読する状態を作る
640
+ // (どの端末の input/permission/interrupt も解決でき、backend が thread group へ fan-out する)。
641
+ // これにより「2 本目の app-server を立てず、旧端末も生かしたまま」新端末を相乗りさせる。
586
642
  // 戻り値 status:
587
- // "reattached" - この呼び出しが session を newStreamId へ付け替えた (info 同梱)。
588
- // "superseded" - 別 reattach が勝って session を奪った (session は生きている)。呼び出し側は
589
- // **通常経路に落とさず** 失敗させる (二重 thread を作らない)。
643
+ // "reattached" - newStreamId を購読者として追加した (info 同梱)。
644
+ // "superseded" - newStreamId が既に別 session を保持している異常時のみ (通常は起きない。
645
+ // attach() が :318 で has(stream_id) を弾くため)。呼び出し側は通常経路に
646
+ // 落とさず失敗させる (二重 thread を作らない)。
590
647
  // "unavailable"- session が死んだ / first の thread 確定に失敗 (ライブ session 無し)。
591
648
  // 呼び出し側は通常経路へフォールスルーしてよい。
592
- const originalStreamId = session.stream_id
593
649
  let threadId = session.threadId
594
650
  if (!threadId && session.ready) threadId = await session.ready
595
651
  // first の thread op が失敗 / session が死んだ = ライブ session はもう無い。
596
652
  if (!threadId || session.dead) return { status: "unavailable" }
597
- // await 中に別 reattach が勝って session を移動した = concurrent の敗者。
598
- if (this.sessions.get(originalStreamId) !== session) return { status: "superseded" }
599
- if (this.sessions.has(newStreamId)) return { status: "superseded" } // 新 stream 側が別 session 保持 (異常)
600
- this.sessions.delete(originalStreamId)
653
+ const existingForNew = this.sessions.get(newStreamId)
654
+ if (existingForNew && existingForNew !== session) return { status: "superseded" }
655
+ // 購読者追加: streamIds / 端末別活性 / Map を更新し、primary を最新端末へ寄せる
656
+ // (legacy 単一値 emit / ログ用。真の配信は backend の thread group fan-out が担う)。
657
+ const now = Date.now()
658
+ session.streamIds.add(newStreamId)
659
+ session.lastActivityByStream.set(newStreamId, now)
660
+ this.sessions.set(newStreamId, session)
601
661
  session.stream_id = newStreamId
602
662
  session.detachRequested = false
603
- session.lastActivityMs = Date.now()
604
- this.sessions.set(newStreamId, session)
663
+ session.lastActivityMs = now
605
664
  this.logger?.info?.(
606
- { stream_id: newStreamId, thread_id: threadId, cwd: session.cwd, reattached: true },
607
- "codex session reattached (in-memory)",
665
+ {
666
+ stream_id: newStreamId,
667
+ thread_id: threadId,
668
+ cwd: session.cwd,
669
+ reattached: true,
670
+ subscribers: session.streamIds.size,
671
+ },
672
+ "codex session reattached (multi-terminal subscribe)",
608
673
  )
609
674
  // major#1: 新 stream へ未応答の承認要求を再送する (Claude の _replayPendingPermissions と同型)。
610
675
  // 承認要求は app-server との in-flight callback で、履歴 hydrate には含まれない。旧 stream 宛に
@@ -719,9 +784,38 @@ export class CodexStreamBridge extends EventEmitter {
719
784
  })
720
785
  }
721
786
 
722
- /** gcAbandonedSessions 用の最終活性更新。browser 入力 / app-server 通知の両方で呼ぶ。 */
723
- _touch(session) {
724
- if (session) session.lastActivityMs = Date.now()
787
+ /**
788
+ * reconcile 用の最終活性更新。browser 入力 / app-server 通知の両方で呼ぶ。
789
+ * 案B: stream_id を伴う browser→agent アクション(input/permission/interrupt/keepalive)では
790
+ * **端末別の活性(lastActivityByStream)も更新**し、gcDeadTerminals が「応答を返さない購読端末」
791
+ * だけを掃除できるようにする。app-server 通知(stream_id なし)は session 全体の活性のみ更新する。
792
+ */
793
+ _touch(session, stream_id) {
794
+ if (!session) return
795
+ const now = Date.now()
796
+ session.lastActivityMs = now
797
+ if (stream_id && session.streamIds?.has(stream_id)) {
798
+ session.lastActivityByStream.set(stream_id, now)
799
+ }
800
+ }
801
+
802
+ /**
803
+ * 案B: sessions Map は各購読 stream_id → session の多対一なので、distinct な live session を
804
+ * 走査するときは必ずこれで dedup する(同一 session を購読端末数ぶん重複カウントしない)。
805
+ * @returns {object[]} 非 dead な distinct session の配列
806
+ */
807
+ _liveSessions() {
808
+ return Array.from(new Set(this.sessions.values())).filter((s) => !s.dead)
809
+ }
810
+
811
+ /** 案B: この session を指す全購読 stream_id キーを sessions Map から外す(reap / exit 共通)。 */
812
+ _dropAllStreamKeys(session) {
813
+ const ids = session.streamIds ? Array.from(session.streamIds) : []
814
+ for (const sid of ids) {
815
+ if (this.sessions.get(sid) === session) this.sessions.delete(sid)
816
+ }
817
+ // primary が streamIds に含まれない異常時の保険。
818
+ if (this.sessions.get(session.stream_id) === session) this.sessions.delete(session.stream_id)
725
819
  }
726
820
 
727
821
  _onNotification(session, msg) {
@@ -733,11 +827,18 @@ export class CodexStreamBridge extends EventEmitter {
733
827
  switch (msg.method) {
734
828
  case "turn/started":
735
829
  session.activeTurnId = msg.params?.turn?.id || null
830
+ // round4 major#2: 世代を前進させる (pump が fast-turn を activeTurnId だけでなく世代前進でも検知)。
831
+ session.turnGeneration += 1
832
+ // round3 major#2: 起動確定 → pump の turn-active barrier を解除する (次 item が steer へ進む)。
833
+ this._settleTurnActiveWaiter(session)
736
834
  // 会話ターン境界で「新しさ」を更新(閲覧 keepalive では更新しない)。
737
835
  session.lastTurnAtMs = Date.now()
738
836
  break
739
837
  case "turn/completed":
740
838
  session.activeTurnId = null
839
+ // round3 major#2: turn/started を取りこぼしたまま turn/completed が来た異常時も barrier が
840
+ // 固着しないよう解除する (pump が次 item を fresh に turn/start できる)。
841
+ this._settleTurnActiveWaiter(session)
741
842
  session.lastTurnAtMs = Date.now()
742
843
  this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
743
844
  // Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
@@ -810,7 +911,12 @@ export class CodexStreamBridge extends EventEmitter {
810
911
  _onClientExit(session, info) {
811
912
  if (session.dead) return
812
913
  session.dead = true
813
- this.sessions.delete(session.stream_id)
914
+ // round3: app-server が死んだら barrier を解除し queue を reject する(promise 宙吊り防止)。
915
+ this._settleTurnActiveWaiter(session)
916
+ this._drainRejectQueue(session, { reason: "queue_dead" })
917
+ // 案B: app-server が死んだら全購読 stream_id キーを Map から外す(1 端末分だけでは
918
+ // 他の購読キーが dangling で残る)。
919
+ this._dropAllStreamKeys(session)
814
920
  // app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
815
921
  // custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
816
922
  // (codex_core の `Custom tool call output is missing for call id: ...` の発生源)。
@@ -861,55 +967,177 @@ export class CodexStreamBridge extends EventEmitter {
861
967
  const session = this._require(stream_id)
862
968
  // Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
863
969
  // 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
864
- this._touch(session)
970
+ // 案B: stream_id を渡し端末別活性も更新(死端末 GC が入力継続端末を掃除しない)。
971
+ this._touch(session, stream_id)
865
972
  const input = userInputFromMessage(message)
866
- if (session.activeTurnId) {
867
- try {
868
- await session.client.request("turn/steer", {
869
- threadId: session.threadId,
870
- expectedTurnId: session.activeTurnId,
871
- input,
872
- })
873
- return
874
- } catch (err) {
875
- // S3 Codex 堅牢化 (2026-07-05): turn/steer が reject される最大の原因は
876
- // expectedTurnId 不一致 (turn/completed 通知の取りこぼしで session.activeTurnId
877
- // が古いまま残るケース) や request() タイムアウト。回復キューが無いと、以降の
878
- // 入力が全て turn/steer として送られ続けて無反応になる (報告症状②の原因)。
879
- // ここで activeTurnId を捨てて「busy ではない」扱いに戻し、同一メッセージを
880
- // 1 回だけ turn/start として自動リトライすることで自己修復する。
881
- session.activeTurnId = null
882
- try {
883
- const retryParams = { threadId: session.threadId, input }
884
- if (model) retryParams.model = model
885
- if (effort) retryParams.effort = effort
886
- await session.client.request("turn/start", retryParams)
973
+ // round3 major#1: 全 input を **session 単位の単一 FIFO** に積み、単一 pump に処理させる。
974
+ // promise は pump が実 delivery で settle する (即 resolve しない)。
975
+ return new Promise((resolve, reject) => {
976
+ session.inputQueue.push({ input, model, effort, resolve, reject, retriedAsStart: false })
977
+ void this._pumpInputs(session)
978
+ })
979
+ }
980
+
981
+ /**
982
+ * round3 major#1 (案B / T06542): session 単位の単一 FIFO pump。**1 件ずつ直列**に処理し、
983
+ * 並行 turn/start・turn/steer や queue の追い越しを構造的に排除する (singleflight)。
984
+ * - 非 busy (activeTurnId 無し): turn/start で新ターン。成功後、次を steer にするため
985
+ * turn/started (activeTurnId 確定) を **timeout 付き**で待つ (round3 major#2: ハング防止)。
986
+ * - busy (activeTurnId 有り): turn/steer で追い打ち。
987
+ * - turn/steer 失敗 (expectedTurnId 不一致など): activeTurnId を捨て、同 item を **1 回だけ**
988
+ * turn/start にフォールバックして自己修復する (旧 self-heal を FIFO 内で継続。並行はしない)。
989
+ * 各 item の promise は実 delivery の RPC 結果で settle する。
990
+ */
991
+ async _pumpInputs(session) {
992
+ if (session.pumping) return
993
+ session.pumping = true
994
+ try {
995
+ while (session.inputQueue.length > 0) {
996
+ if (session.dead) {
997
+ this._drainRejectQueue(session, { reason: "queue_dead" })
887
998
  return
888
- } catch (retryErr) {
889
- // 呼び出し元 (main.mjs の codex.input dispatch) が既存の .catch() で
890
- // codex.error を送出する経路に reason を乗せて届ける (bridge 側で二重に
891
- // 'error' イベントを emit すると frontend へ codex.error が二重送出される
892
- // ため、ここでは throw のみに留める)。
893
- const reason = "turn_mismatch_retry_failed"
894
- throw Object.assign(
895
- new Error(
896
- `turn/steer failed (${err.message}) and turn/start retry also failed: ${retryErr.message}`,
897
- ),
898
- { reason, cause: retryErr },
899
- )
900
999
  }
1000
+ const item = session.inputQueue[0]
1001
+ // round4 major#3: 試行種別 (steer/start) を **await の前に** 確定する。await 中に
1002
+ // turn/completed が来て session.activeTurnId が null に戻ると、catch 時点の値では steer 試行が
1003
+ // start と誤判定され、self-heal リトライがスキップされて item が誤 reject される。
1004
+ const attemptedSteer = !!session.activeTurnId
1005
+ try {
1006
+ if (attemptedSteer) {
1007
+ await session.client.request("turn/steer", {
1008
+ threadId: session.threadId,
1009
+ expectedTurnId: session.activeTurnId,
1010
+ input: item.input,
1011
+ })
1012
+ session.inputQueue.shift()
1013
+ item.resolve()
1014
+ } else {
1015
+ // round4 major#2: turn/start dispatch の **前に** 世代を控える。start 応答後に
1016
+ // turn/started(+completed) が pump 再開前に処理されても、世代前進で「ターンが始まった」を
1017
+ // 検知でき、誤 timeout しない。
1018
+ const genBefore = session.turnGeneration
1019
+ const params = { threadId: session.threadId, input: item.input }
1020
+ if (item.model) params.model = item.model
1021
+ if (item.effort) params.effort = item.effort
1022
+ await session.client.request("turn/start", params)
1023
+ session.inputQueue.shift()
1024
+ item.resolve()
1025
+ // 次の item を steer にするため turn/started (= 世代前進) を待つ。timeout でハングを防ぐ。
1026
+ // timeout したら「ターンが正しく始まらなかった」とみなし残り queue を reject して打ち切る。
1027
+ const becameActive = await this._waitTurnActive(session, genBefore)
1028
+ if (!becameActive) {
1029
+ this._drainRejectQueue(session, { reason: "turn_start_timeout" })
1030
+ return
1031
+ }
1032
+ }
1033
+ } catch (err) {
1034
+ if (attemptedSteer && !item.retriedAsStart) {
1035
+ // turn/steer 失敗 → 自己修復: activeTurnId を捨て、同 item を turn/start で 1 回だけリトライ
1036
+ // (shift しない = 同 item を次ループの start 経路で再処理)。並行はしない (単一 pump)。
1037
+ // 元の steer エラーを stash して、リトライも失敗したとき合成メッセージを作れるようにする。
1038
+ session.activeTurnId = null
1039
+ item.retriedAsStart = true
1040
+ item.priorSteerError = err instanceof Error ? err : new Error(String(err))
1041
+ continue
1042
+ }
1043
+ // start 失敗 or リトライ済み steer 失敗 → この item を reject。activeTurnId は捨てる
1044
+ // (ターンは始まっていない/不明)。次の item は fresh に turn/start する。
1045
+ session.inputQueue.shift()
1046
+ session.activeTurnId = null
1047
+ let e
1048
+ if (item.retriedAsStart && item.priorSteerError) {
1049
+ // steer→start リトライがともに失敗: 合成メッセージ + reason (main.mjs の classifyCodexInputError
1050
+ // が reason を優先採用。従来の単一 input() 実装と同じ診断を保つ)。
1051
+ e = Object.assign(
1052
+ new Error(
1053
+ `turn/steer failed (${item.priorSteerError.message}) and turn/start retry also failed: ${err instanceof Error ? err.message : String(err)}`,
1054
+ ),
1055
+ { reason: "turn_mismatch_retry_failed", cause: err },
1056
+ )
1057
+ } else {
1058
+ e = err instanceof Error ? err : new Error(String(err))
1059
+ if (!e.reason) e.reason = "start_error"
1060
+ }
1061
+ item.reject(e)
1062
+ }
1063
+ }
1064
+ } finally {
1065
+ session.pumping = false
1066
+ // pumping=false 直後の enqueue 競合窓を拾い直す (取りこぼし防止)。再帰は 1 段のみ。
1067
+ if (session.inputQueue.length > 0 && !session.dead) {
1068
+ void this._pumpInputs(session)
901
1069
  }
902
1070
  }
903
- const params = { threadId: session.threadId, input }
904
- if (model) params.model = model
905
- if (effort) params.effort = effort
906
- await session.client.request("turn/start", params)
1071
+ }
1072
+
1073
+ /**
1074
+ * round3 major#2 + round4 major#2: turn/start 後に「ターンが始まった」ことを timeout 付きで待つ
1075
+ * barrier。activeTurnId が立っているか、または **turn/start dispatch 前に控えた世代 genBefore から
1076
+ * 世代が前進している** (fast-turn で started+completed 済み) なら即 true を返し、誤 timeout しない。
1077
+ * @param {object} session
1078
+ * @param {number} [genBefore] turn/start dispatch 直前の session.turnGeneration
1079
+ * @returns {Promise<boolean>} ターンが始まったら true、timeout したら false。
1080
+ */
1081
+ _waitTurnActive(session, genBefore = -1) {
1082
+ if (session.activeTurnId || session.turnGeneration > genBefore) return Promise.resolve(true)
1083
+ return new Promise((resolve) => {
1084
+ const timer = setTimeout(() => {
1085
+ if (session._turnActiveWaiter && session._turnActiveWaiter.timer === timer) {
1086
+ session._turnActiveWaiter = null
1087
+ }
1088
+ resolve(false)
1089
+ }, this._turnStartTimeoutMs)
1090
+ // minor (round4): 通知を出さない単体テスト等でプロセスを timeout 分保持しないよう unref する。
1091
+ if (typeof timer.unref === "function") timer.unref()
1092
+ // 既存 waiter があれば置換前に false 解決しておく (通常は無い)。
1093
+ if (session._turnActiveWaiter) {
1094
+ try {
1095
+ clearTimeout(session._turnActiveWaiter.timer)
1096
+ } catch {
1097
+ /* noop */
1098
+ }
1099
+ session._turnActiveWaiter.resolve(false)
1100
+ }
1101
+ session._turnActiveWaiter = { resolve: () => resolve(true), timer }
1102
+ })
1103
+ }
1104
+
1105
+ /** round3 major#2: turn/started / completed / exit / 死亡で待機中の barrier を解除する。 */
1106
+ _settleTurnActiveWaiter(session) {
1107
+ const w = session._turnActiveWaiter
1108
+ if (!w) return
1109
+ session._turnActiveWaiter = null
1110
+ try {
1111
+ clearTimeout(w.timer)
1112
+ } catch {
1113
+ /* noop */
1114
+ }
1115
+ w.resolve()
1116
+ }
1117
+
1118
+ /**
1119
+ * round3: FIFO の未処理 item を一括 reject する (session 死亡 / turn_start timeout で pump が
1120
+ * 続行できないとき。ack も nack も返らない配達喪失を防ぐ)。
1121
+ */
1122
+ _drainRejectQueue(session, cause) {
1123
+ if (!session || !Array.isArray(session.inputQueue) || session.inputQueue.length === 0) return
1124
+ const pending = session.inputQueue
1125
+ session.inputQueue = []
1126
+ const reason = cause?.reason || "queue_dead"
1127
+ for (const { reject } of pending) {
1128
+ reject(
1129
+ Object.assign(new Error("codex input dropped: turn could not be delivered"), {
1130
+ reason,
1131
+ cause,
1132
+ }),
1133
+ )
1134
+ }
907
1135
  }
908
1136
 
909
1137
  /** @returns {Promise<void>} */
910
1138
  async interrupt({ stream_id }) {
911
1139
  const session = this._require(stream_id)
912
- this._touch(session)
1140
+ this._touch(session, stream_id)
913
1141
  if (!session.activeTurnId) return
914
1142
  await session.client.request("turn/interrupt", {
915
1143
  threadId: session.threadId,
@@ -920,7 +1148,8 @@ export class CodexStreamBridge extends EventEmitter {
920
1148
  /** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
921
1149
  permissionReply({ stream_id, request_id, result }) {
922
1150
  const session = this._require(stream_id)
923
- this._touch(session)
1151
+ // 案B エッジ②: 先着 1 応答で解決。stream_id を渡し応答端末の活性を更新(死端末 GC 対象外に)。
1152
+ this._touch(session, stream_id)
924
1153
  if (!session.pendingApprovals.has(request_id)) {
925
1154
  this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
926
1155
  return
@@ -941,18 +1170,48 @@ export class CodexStreamBridge extends EventEmitter {
941
1170
  keepalive({ stream_id }) {
942
1171
  const session = this.sessions.get(stream_id)
943
1172
  if (!session || session.dead) return false
944
- this._touch(session)
1173
+ this._touch(session, stream_id)
945
1174
  return true
946
1175
  }
947
1176
 
948
1177
  /**
949
- * グレースフル切断(Stage 5)。**すぐには reap しない**。detachRequested を立てて
950
- * warm 保持し、以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)に委ねる。これにより
951
- * 「別 CLI 画面へ切替えた瞬間 5 分で殺される」問題を解消し、直近 5 セッションは 7 日 warm に。
1178
+ * グレースフル切断(Stage 5 + 案B T06542)。**すぐには reap しない**。
1179
+ *
1180
+ * 案B: detach は「この端末分の購読解除」。まだ他端末が購読していればセッションは生かし、
1181
+ * detachRequested も立てない(別端末が見ているため warm ではなく現役)。最後の 1 台が外れた
1182
+ * ときだけ detachRequested を立て warm 保持し、以降は周期 reconcile(LRU 上限 / 長 idle TTL /
1183
+ * stuck)に委ねる。これにより「別 CLI 画面へ切替えた瞬間 5 分で殺される」問題を解消しつつ、
1184
+ * 多端末購読中に 1 タブ閉じても残りの端末のライブ表示が切れない。
952
1185
  */
953
1186
  detach({ stream_id }) {
954
1187
  const session = this.sessions.get(stream_id)
955
1188
  if (!session || session.dead) return
1189
+ // ⚠️ 不変条件(案B): **live(非 dead)session は常に stream key を 1 つ以上持つ**。codex は
1190
+ // claude と違い warm セッションの唯一の索引が this.sessions なので、最後の 1 端末分の key まで
1191
+ // 消すと reconcile が到達できず(reap されずリーク)、_findLiveSessionByCwd の reattach 引き当ても
1192
+ // 効かなくなる。よって「購読が 2 つ以上のときだけ実際に外す」。
1193
+ if (session.streamIds.size > 1) {
1194
+ // 他端末がまだ購読中: この端末分の購読解除のみ。セッションは残りの端末が生かす。
1195
+ session.streamIds.delete(stream_id)
1196
+ session.lastActivityByStream.delete(stream_id)
1197
+ if (this.sessions.get(stream_id) === session) this.sessions.delete(stream_id)
1198
+ // primary を生存端末へ寄せ替える(末尾=最も新しく購読した端末を .at(-1) で明示選択)。
1199
+ if (session.stream_id === stream_id) {
1200
+ session.stream_id = Array.from(session.streamIds).at(-1)
1201
+ }
1202
+ this.logger?.info?.(
1203
+ {
1204
+ stream_id,
1205
+ thread_id: session.threadId,
1206
+ cwd: session.cwd,
1207
+ remaining_subscribers: session.streamIds.size,
1208
+ },
1209
+ "codex terminal unsubscribed (session kept; other terminals still watching)",
1210
+ )
1211
+ return
1212
+ }
1213
+ // 最後の 1 台が外れた: key / streamIds は残したまま(reconcile 到達性のため)warm 保持し、
1214
+ // detachRequested を立てる。以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)が reap を判断する。
956
1215
  session.detachRequested = true
957
1216
  this.logger?.info?.(
958
1217
  {
@@ -961,10 +1220,55 @@ export class CodexStreamBridge extends EventEmitter {
961
1220
  cwd: session.cwd,
962
1221
  busy: !!session.activeTurnId,
963
1222
  },
964
- "codex detach requested by browser (kept warm; reconcile handles reap)",
1223
+ "codex detach requested by browser (last terminal; kept warm; reconcile handles reap)",
965
1224
  )
966
1225
  }
967
1226
 
1227
+ /**
1228
+ * 案B (T06542): 死端末 GC。多端末購読で、クラッシュ / タブ強制終了で codex.detach を送れずに
1229
+ * 消えた購読端末(一定期間 input/permission/interrupt/keepalive の活性が無い stream_id)を購読集合
1230
+ * から掃除する。claude-stream-bridge.mjs の gcDeadTerminals と同型。複数端末が購読するセッション
1231
+ * のみ対象(1 台しか居なければ reconcile の idle TTL / LRU に任せる)。全端末が死端末で空になったら
1232
+ * detachRequested を立て warm 保持へ落とす。reconcileSessions の先頭から呼ばれる(main.mjs の
1233
+ * state loop 経由)ほか、テストから直接も呼べる。
1234
+ * @param {number} [ttlMs] 死端末とみなす無活性閾値(テスト用に上書き可)。
1235
+ * @returns {number} GC した端末キー数。
1236
+ */
1237
+ gcDeadTerminals(ttlMs = this._deadTerminalTtlMs) {
1238
+ const now = Date.now()
1239
+ let removed = 0
1240
+ for (const session of this._liveSessions()) {
1241
+ // 複数端末が購読しているセッションのみ対象(1 台なら reconcile の idle TTL / LRU に任せる)。
1242
+ if (session.streamIds.size <= 1) continue
1243
+ for (const sid of Array.from(session.streamIds)) {
1244
+ // ⚠️ 不変条件: live session は常に stream key を 1 つ以上残す(reconcile 到達性)。全端末が
1245
+ // 死端末でも最後の 1 個は消さず、下で detachRequested を立てて idle TTL reap に委ねる。
1246
+ if (session.streamIds.size <= 1) break
1247
+ const last = session.lastActivityByStream.get(sid) ?? 0
1248
+ if (now - last < ttlMs) continue
1249
+ session.streamIds.delete(sid)
1250
+ session.lastActivityByStream.delete(sid)
1251
+ if (this.sessions.get(sid) === session) this.sessions.delete(sid)
1252
+ removed += 1
1253
+ if (session.stream_id === sid) {
1254
+ session.stream_id = Array.from(session.streamIds).at(-1)
1255
+ }
1256
+ this.logger?.info?.(
1257
+ { stream_id: sid, thread_id: session.threadId, cwd: session.cwd },
1258
+ "codex dead terminal GC'd (no activity past TTL)",
1259
+ )
1260
+ }
1261
+ // 生き残った最後の 1 端末も死端末(無活性が TTL 超過)なら warm 保持へ落とす
1262
+ // (key は残したまま。reconcile が idle TTL / LRU で最終的に reap する)。
1263
+ if (session.streamIds.size === 1) {
1264
+ const [onlySid] = session.streamIds
1265
+ const last = session.lastActivityByStream.get(onlySid) ?? 0
1266
+ if (now - last >= ttlMs) session.detachRequested = true
1267
+ }
1268
+ }
1269
+ return removed
1270
+ }
1271
+
968
1272
  /**
969
1273
  * 診断用: 生きているセッションの一覧 (sibling-inject の失敗理由に載せる / ログ用)。
970
1274
  * 「TUI codex は hub-agent の管理外なので ask-sibling の宛先にならない」ことを
@@ -978,8 +1282,8 @@ export class CodexStreamBridge extends EventEmitter {
978
1282
  describeLiveSessions(cwd) {
979
1283
  const now = Date.now()
980
1284
  const out = []
981
- for (const session of this.sessions.values()) {
982
- if (session.dead) continue
1285
+ // 案B: sessions は多対一なので distinct session で走査する(購読端末数ぶん重複させない)。
1286
+ for (const session of this._liveSessions()) {
983
1287
  if (cwd && session.cwd !== cwd) continue
984
1288
  out.push({
985
1289
  kind: "codex",
@@ -1034,8 +1338,9 @@ export class CodexStreamBridge extends EventEmitter {
1034
1338
  if (!cwd || !text) return false
1035
1339
  let best = null
1036
1340
  let bestAt = -1
1037
- for (const session of this.sessions.values()) {
1038
- if (session.dead || session.cwd !== cwd) continue
1341
+ // 案B: distinct session で走査(多対一 Map の重複を除く)。
1342
+ for (const session of this._liveSessions()) {
1343
+ if (session.cwd !== cwd) continue
1039
1344
  // 宛先選定の「最新」は会話ターン境界時刻で判定する(閲覧 keepalive で動く
1040
1345
  // lastActivityMs を使うと「見ているだけの古い会話」が本命を追い越す)。
1041
1346
  const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
@@ -1109,9 +1414,11 @@ export class CodexStreamBridge extends EventEmitter {
1109
1414
  if (!cwd || !id || !text) return { status: "not_found" }
1110
1415
  let bySid = null
1111
1416
  const byThread = []
1112
- for (const session of this.sessions.values()) {
1113
- if (session.dead || session.cwd !== cwd) continue
1114
- if (session.stream_id === id) {
1417
+ // 案B: distinct session で走査。id は **任意の購読 stream_id**(primary とは限らない)に
1418
+ // 一致し得るので streamIds.has(id) で判定する(多端末購読の非 primary 端末も宛先にできる)。
1419
+ for (const session of this._liveSessions()) {
1420
+ if (session.cwd !== cwd) continue
1421
+ if (session.streamIds.has(id)) {
1115
1422
  bySid = session
1116
1423
  break // stream_id は一意なので即確定
1117
1424
  }
@@ -1169,7 +1476,11 @@ export class CodexStreamBridge extends EventEmitter {
1169
1476
  _reapSession(session, now, reason) {
1170
1477
  if (session.dead) return
1171
1478
  session.dead = true
1172
- this.sessions.delete(session.stream_id)
1479
+ // round3: reap した session の barrier を解除し queue を reject する(promise 宙吊り防止)。
1480
+ this._settleTurnActiveWaiter(session)
1481
+ this._drainRejectQueue(session, { reason: "queue_dead" })
1482
+ // 案B: 全購読 stream_id キーを Map から外す(primary 1 個だけ消すと dangling が残る)。
1483
+ this._dropAllStreamKeys(session)
1173
1484
  try {
1174
1485
  session.client.stop()
1175
1486
  } catch (err) {
@@ -1182,7 +1493,7 @@ export class CodexStreamBridge extends EventEmitter {
1182
1493
  cwd: session.cwd,
1183
1494
  reason,
1184
1495
  idle_ms: now - (session.lastActivityMs ?? now),
1185
- live_after: this.sessions.size,
1496
+ live_after: this._liveSessions().length,
1186
1497
  },
1187
1498
  "codex session reaped",
1188
1499
  )
@@ -1203,7 +1514,8 @@ export class CodexStreamBridge extends EventEmitter {
1203
1514
  * @returns {number} 退避した数
1204
1515
  */
1205
1516
  _evictWarmPoolOverflow(now, reason = "lru-evict") {
1206
- const live = Array.from(this.sessions.values()).filter((s) => !s.dead)
1517
+ // 案B: warm pool 上限は「distinct な生存 session 数」で判定する(購読端末数ではない)。
1518
+ const live = this._liveSessions()
1207
1519
  const overflow = live.length - this._warmPoolMax
1208
1520
  if (overflow <= 0) return 0
1209
1521
  const evictable = live
@@ -1244,9 +1556,12 @@ export class CodexStreamBridge extends EventEmitter {
1244
1556
  reconcileSessions() {
1245
1557
  const now = Date.now()
1246
1558
  let reaped = 0
1559
+ // 案B: 0. 死端末 GC を先に回す(多端末購読で応答が途絶えた端末を購読集合から掃除)。これで
1560
+ // 「全端末が死んで空になったセッション」が下の idle TTL 経路で正しく reap 対象になる。
1561
+ this.gcDeadTerminals()
1247
1562
  // 1〜2: stuck turn / long idle。protected(healthy running turn)はスキップ。
1248
- for (const session of Array.from(this.sessions.values())) {
1249
- if (session.dead) continue
1563
+ // 案B: distinct session で走査(多対一 Map の重複 reap を避ける)。
1564
+ for (const session of this._liveSessions()) {
1250
1565
  if (this._isProtected(session, now)) continue
1251
1566
  const idle = now - (session.lastActivityMs ?? now)
1252
1567
  if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
@@ -1269,8 +1584,12 @@ export class CodexStreamBridge extends EventEmitter {
1269
1584
 
1270
1585
  /** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
1271
1586
  shutdown() {
1272
- for (const session of this.sessions.values()) {
1587
+ // 案B: distinct session で走査(多対一 Map の重複 stop を避ける)。
1588
+ for (const session of this._liveSessions()) {
1273
1589
  session.dead = true
1590
+ // round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
1591
+ this._settleTurnActiveWaiter(session)
1592
+ this._drainRejectQueue(session, { reason: "queue_dead" })
1274
1593
  // hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
1275
1594
  // (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
1276
1595
  this.logger?.[session.activeTurnId ? "warn" : "info"]?.(
package/src/main.mjs CHANGED
@@ -2064,9 +2064,9 @@ export function startStateLoop({ client, plugins, logger, intervalMs, claudeBrid
2064
2064
  const tick = async () => {
2065
2065
  if (stopped) return
2066
2066
  try {
2067
- // B11: 多端末共有時、クラッシュして detach を送らず消えた死端末を GC する
2067
+ // B11: 多端末共有 (T06542 で常時) で、クラッシュして detach を送らず消えた死端末を GC する
2068
2068
  // (活性なし TTL 超過の購読端末キーを掃除し、最後の 1 台が外れたら idle softDetach へ)。
2069
- // CHAT_SHARED_ENABLED 無効時は no-op。例外は state loop 全体の try/catch が拾う。
2069
+ // 例外は state loop 全体の try/catch が拾う。
2070
2070
  try {
2071
2071
  claudeBridge?.gcDeadTerminals?.()
2072
2072
  } catch (err) {
@@ -2559,6 +2559,13 @@ function makeSeenInputIdRegistry(max = SEEN_INPUT_IDS_MAX) {
2559
2559
  const seenClaudeChatInputIds = makeSeenInputIdRegistry()
2560
2560
  const seenCodexChatInputIds = makeSeenInputIdRegistry()
2561
2561
 
2562
+ // round4 major#1 (T06542): codex.input の **in-flight** dedup 用レジストリ (input_id -> Promise)。
2563
+ // `seen` は settle 後にしか登録されないため、frontend の ack watchdog (5s) が bridge の barrier/RPC
2564
+ // (turn/start→turn/started は数秒〜) より短く、同一 input_id が settle 前に再送されると bridge.input を
2565
+ // 二重に呼んで FIFO へ重複投入され、二重ターン/steer になる。処理中の input_id は再 dispatch せず既存
2566
+ // Promise へ合流させる (settle 時に元の .then/.catch が 1 回だけ ack/nack を出す)。
2567
+ const codexInflightByInputId = new Map()
2568
+
2562
2569
  /**
2563
2570
  * input_id 付き pty.data (tracked 入力) の処理。write 成功で pty.input.ack を返し、
2564
2571
  * stream 不在なら pty.error を返して browser の再 attach → 再送に繋げる。
@@ -2697,13 +2704,19 @@ export function handleCodexChatInput(msg, ctx) {
2697
2704
  return undefined
2698
2705
  }
2699
2706
  if (input_id && seenCodexChatInputIds.has(input_id)) {
2700
- // 再送 (ack 喪失想定)。turn/start・turn/steer を再送出せず ack のみ返す
2707
+ // 再送 (ack 喪失想定 / settle 済み)。turn/start・turn/steer を再送出せず ack のみ返す
2701
2708
  // (二重ターン実行を避ける。claude.input dedup と同じ考え方)。
2702
2709
  ctx.logger?.info?.({ stream_id, input_id }, "codex.input dedup (ack only)")
2703
2710
  ctx.client.send({ type: "codex.input.ack", stream_id, input_id })
2704
2711
  return undefined
2705
2712
  }
2706
- return ctx.codexBridge
2713
+ if (input_id && codexInflightByInputId.has(input_id)) {
2714
+ // round4 major#1: **処理中** の同一 input_id (watchdog の settle 前再送)。再 dispatch せず既存
2715
+ // Promise へ合流する (元の .then/.catch が settle 時に 1 回だけ ack/nack を出すので二重投入しない)。
2716
+ ctx.logger?.info?.({ stream_id, input_id }, "codex.input in-flight dedup (join, no re-dispatch)")
2717
+ return codexInflightByInputId.get(input_id)
2718
+ }
2719
+ const p = ctx.codexBridge
2707
2720
  .input({
2708
2721
  stream_id,
2709
2722
  message: msg.message,
@@ -2735,6 +2748,12 @@ export function handleCodexChatInput(msg, ctx) {
2735
2748
  ...(err.reason ? { reason: err.reason } : {}),
2736
2749
  })
2737
2750
  })
2751
+ .finally(() => {
2752
+ // in-flight レジストリから外す (成功時は seen が以降の再送を dedup、失敗時は再送で正当にリトライ)。
2753
+ if (input_id) codexInflightByInputId.delete(input_id)
2754
+ })
2755
+ if (input_id) codexInflightByInputId.set(input_id, p)
2756
+ return p
2738
2757
  }
2739
2758
 
2740
2759
  /**
@@ -15,8 +15,6 @@ import path from "node:path"
15
15
  import { spawnSync } from "node:child_process"
16
16
  import { fileURLToPath } from "node:url"
17
17
 
18
- import { readConfig } from "./config.mjs"
19
-
20
18
  const PLIST_LABEL = "co.cocorograph.hub-agent"
21
19
  const SYSTEMD_UNIT_NAME = "hub-agent.service"
22
20
  // Windows(WSL) のログオン keep-alive タスク名 (install.ps1 の $TASK_NAME と一致)。
@@ -55,7 +53,7 @@ function escapeXmlText(s) {
55
53
  .replaceAll(">", "&gt;")
56
54
  }
57
55
 
58
- function expandTemplate(text, hubAgentBin, opts = {}) {
56
+ function expandTemplate(text, hubAgentBin) {
59
57
  // hub-agent デーモンの runtime 環境に必須な追加 env を、各プラットフォームの
60
58
  // 起動ファイル形式 (plist / systemd) に合わせて差し込むためのプレースホルダ展開。
61
59
  //
@@ -78,22 +76,8 @@ function expandTemplate(text, hubAgentBin, opts = {}) {
78
76
  systemdLine = `Environment=NODE_EXTRA_CA_CERTS=${nodeExtraCa}\n`
79
77
  }
80
78
 
81
- // 多端末共有チャット (HUB_AGENT_CHAT_SHARED)。NODE_EXTRA_CA_CERTS と同じ仕組みで
82
- // plist / systemd の EnvironmentVariables に焼き込む。値ソースは (a) installService
83
- // が agent.json.chat_shared を読んで渡す opts.chatShared、または (b) install-service
84
- // 実行時の process.env.HUB_AGENT_CHAT_SHARED。これにより agent 更新で install-service
85
- // が plist を再生成しても、agent.json に chat_shared=true があればフラグが維持される
86
- // (旧実装は手動 plist 追記が再生成で消える事故があった。2026-05-30)。
87
- const chatShared =
88
- opts.chatShared === true || process.env.HUB_AGENT_CHAT_SHARED === "1"
89
- let chatPlistEntry = ""
90
- let chatSystemdLine = ""
91
- if (chatShared) {
92
- chatPlistEntry =
93
- ` <key>HUB_AGENT_CHAT_SHARED</key>\n` +
94
- ` <string>1</string>\n`
95
- chatSystemdLine = `Environment=HUB_AGENT_CHAT_SHARED=1\n`
96
- }
79
+ // T06542: 多端末共有チャットは既定 ON 化しフラグ (HUB_AGENT_CHAT_SHARED) を撤去した。
80
+ // plist/systemd への env 焼き込みも不要になったため、テンプレートの placeholder ごと削除済み。
97
81
 
98
82
  return text
99
83
  .replaceAll("__HUB_AGENT_BIN__", hubAgentBin)
@@ -101,19 +85,6 @@ function expandTemplate(text, hubAgentBin, opts = {}) {
101
85
  .replaceAll("__PATH__", process.env.PATH || "/usr/local/bin:/usr/bin:/bin")
102
86
  .replaceAll("__NODE_EXTRA_CA_CERTS_PLIST_ENTRY__", plistEntry)
103
87
  .replaceAll("__NODE_EXTRA_CA_CERTS_SYSTEMD_LINE__", systemdLine)
104
- .replaceAll("__HUB_AGENT_CHAT_SHARED_PLIST_ENTRY__", chatPlistEntry)
105
- .replaceAll("__HUB_AGENT_CHAT_SHARED_SYSTEMD_LINE__", chatSystemdLine)
106
- }
107
-
108
- /** agent.json から多端末共有フラグ (chat_shared) を読む。読めなければ false。
109
- * install-service 時に plist/systemd へ焼き込む値ソースに使う (env 併用は expandTemplate 側)。 */
110
- async function readChatSharedFromConfig() {
111
- try {
112
- const config = await readConfig()
113
- return config?.chat_shared === true
114
- } catch {
115
- return false
116
- }
117
88
  }
118
89
 
119
90
  async function ensureDir(p) {
@@ -290,12 +261,9 @@ export async function installService({ bin } = {}) {
290
261
  const hubAgentBin = bin || detectHubAgentBin()
291
262
  await ensureLogFile()
292
263
 
293
- // agent.json の永続フラグを読み、テンプレ展開に渡す (再生成でも維持されるように)。
294
- const chatShared = await readChatSharedFromConfig()
295
-
296
264
  if (process.platform === "darwin") {
297
265
  const tpl = await readTemplate("co.cocorograph.hub-agent.plist")
298
- const expanded = expandTemplate(tpl, hubAgentBin, { chatShared })
266
+ const expanded = expandTemplate(tpl, hubAgentBin)
299
267
  const dest = macPlistPath()
300
268
  await ensureDir(path.dirname(dest))
301
269
  await fs.writeFile(dest, expanded, { mode: 0o644 })
@@ -317,7 +285,7 @@ export async function installService({ bin } = {}) {
317
285
 
318
286
  if (process.platform === "linux") {
319
287
  const tpl = await readTemplate("hub-agent.service")
320
- const expanded = expandTemplate(tpl, hubAgentBin, { chatShared })
288
+ const expanded = expandTemplate(tpl, hubAgentBin)
321
289
  const dest = linuxUnitPath()
322
290
  await ensureDir(path.dirname(dest))
323
291
  await fs.writeFile(dest, expanded, { mode: 0o644 })
@@ -52,7 +52,7 @@
52
52
  <string>__PATH__</string>
53
53
  <key>HOME</key>
54
54
  <string>__HOME__</string>
55
- __NODE_EXTRA_CA_CERTS_PLIST_ENTRY____HUB_AGENT_CHAT_SHARED_PLIST_ENTRY__ </dict>
55
+ __NODE_EXTRA_CA_CERTS_PLIST_ENTRY__ </dict>
56
56
 
57
57
  <!-- KeepAlive で過剰再起動した時に 10 秒スロットルする -->
58
58
  <key>ThrottleInterval</key>
@@ -12,7 +12,7 @@ Type=simple
12
12
  # 行に、未 set なら空文字列に」展開する。
13
13
  Environment=PATH=__PATH__
14
14
  Environment=HOME=__HOME__
15
- __NODE_EXTRA_CA_CERTS_SYSTEMD_LINE____HUB_AGENT_CHAT_SHARED_SYSTEMD_LINE__WorkingDirectory=__HOME__
15
+ __NODE_EXTRA_CA_CERTS_SYSTEMD_LINE__WorkingDirectory=__HOME__
16
16
  ExecStart=__HUB_AGENT_BIN__ start
17
17
  Restart=always
18
18
  RestartSec=10