@cocorograph/hub-agent 0.7.78 → 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.78",
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 は
@@ -177,6 +196,15 @@ function flattenThreadItems(thread, { maxItems = HISTORY_MAX_ITEMS, maxBytes = H
177
196
  return { items: kept, truncated, total }
178
197
  }
179
198
 
199
+ /** 履歴 item の概算 byte 数 (flattenThreadItems の budget 判定と同じ JSON 長ベース)。 */
200
+ function estimateItemBytes(item) {
201
+ try {
202
+ return JSON.stringify(item)?.length || 0
203
+ } catch {
204
+ return 0
205
+ }
206
+ }
207
+
180
208
  /**
181
209
  * resume 応答から Codex の「権威的なターン進行状態」を取り出す。
182
210
  *
@@ -264,6 +292,8 @@ export class CodexStreamBridge extends EventEmitter {
264
292
  detachTtlMs,
265
293
  warmPoolMax,
266
294
  stuckTurnTtlMs,
295
+ deadTerminalTtlMs,
296
+ turnStartTimeoutMs,
267
297
  rolloutPresence,
268
298
  historyMaxItems,
269
299
  historyMaxBytes,
@@ -278,6 +308,10 @@ export class CodexStreamBridge extends EventEmitter {
278
308
  this._warmPoolMax = warmPoolMax ?? WARM_POOL_MAX
279
309
  // Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
280
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
281
315
  // #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
282
316
  this._rolloutPresence = rolloutPresence || codexRolloutPresence
283
317
  // T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
@@ -332,6 +366,35 @@ export class CodexStreamBridge extends EventEmitter {
332
366
  }
333
367
  }
334
368
 
369
+ // T06541 案1: attach_id が無くても、同一 (cwd, resume_thread_id) のライブ session があれば
370
+ // in-memory reattach する。frontend は attach_id を送らないため上の attachId 経路は発火せず、
371
+ // これが実質のメイン reattach 経路になる。新プロセス thread/resume を起動せず所有権を付け替え、
372
+ // **蓄積済みの高忠実度履歴を返す**(frontend は remount で白紙化するため履歴が必要。reducer は
373
+ // uuid dedup で二重描画しない)。これにより症状⑦(生成中に移動→戻ると turn_active=false)が
374
+ // 解消し、停止/完了通知も同一プロセスに揃う。同一会話 (threadId 一致) のみ対象。
375
+ if (resumeThreadId) {
376
+ const existing = this._findLiveSessionByCwd(cwd, resumeThreadId)
377
+ if (existing && existing.stream_id !== stream_id) {
378
+ const r = await this._tryReattach(existing, stream_id)
379
+ if (r.status === "reattached") {
380
+ return {
381
+ ...r.info,
382
+ // 案1: reattach は履歴を返さない設計 (r.info.history=[]) だが、cwd reattach は remount
383
+ // 由来で表示がリセットされ得るため、live セッションが保持する高忠実度履歴を返す。
384
+ history: Array.isArray(existing.historyItems) ? existing.historyItems.slice() : [],
385
+ historyTruncated: existing.historyTruncated === true,
386
+ }
387
+ }
388
+ if (r.status === "superseded") {
389
+ const err = new Error("attach superseded by a concurrent reattach")
390
+ err.attachOperation = "reattach"
391
+ err.attachReason = "superseded"
392
+ throw err
393
+ }
394
+ // r.status === "unavailable" → 通常経路 (thread/resume) へフォールスルー。
395
+ }
396
+ }
397
+
335
398
  const client = this._clientFactory({
336
399
  cwd,
337
400
  codexCmd: this.codexCmd,
@@ -343,14 +406,52 @@ export class CodexStreamBridge extends EventEmitter {
343
406
  // unhandled rejection を作らず、複数の reconnect が同じ promise を安全に await できる)。
344
407
  let settleReady
345
408
  const session = {
409
+ // primary stream_id: legacy な単一値 emit / ログ用。案B では最新購読端末へ寄せる
410
+ // (streamIds が真の購読者集合)。app-server 通知や emit は都度 session.stream_id を読む。
346
411
  stream_id,
347
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()]]),
348
422
  // #1 in-memory reattach のキー。attach 試行を跨いで安定 (WS 再接続で stream_id は変わる)。
349
423
  attachId: attachId || null,
350
424
  client,
351
425
  threadId: null,
426
+ // T06541 major#3: cwd reattach の in-flight 引き当てキー。thread/resume 完了まで threadId は
427
+ // null なので、並行 cold attach が「同一 (cwd, resumeThreadId)」を検出できるよう resume 対象の
428
+ // thread id を **client 生成前 (同期)** に持たせる。これで後続 attach は _findLiveSessionByCwd で
429
+ // この session を引き当て _tryReattach の session.ready 合流に入り、二重 app-server を作らない。
430
+ resumeThreadId: resumeThreadId || null,
352
431
  activeTurnId: null,
353
- pendingApprovals: new Map(), // request_id -> { turnId }
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,
452
+ // request_id -> { turnId, method, params }。major#1: reattach で新 stream へ未応答の承認要求を
453
+ // 再送できるよう、元の method/params も保持する (Claude の _permissionResolvers と同型)。
454
+ pendingApprovals: new Map(),
354
455
  // ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。診断・ログ用。
355
456
  // Stage 5: 回収の可否は detachRequested ではなく lastActivityMs(LRU)と activeTurnId
356
457
  // (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
@@ -368,6 +469,15 @@ export class CodexStreamBridge extends EventEmitter {
368
469
  // attach 時刻=新規セッションは「たった今作った」で上位に来る(閲覧中の古い会話に負けない)。
369
470
  lastTurnAtMs: Date.now(),
370
471
  ready: null,
472
+ // T06541 案1(高忠実度履歴の蓄積): cold attach 時の flatten 済み ThreadItem 履歴を
473
+ // 保持し、ライブ item/completed ごとに追記する。cwd reattach(新プロセスを立てない
474
+ // 復帰)時にこれを返すことで、frontend の remount 白紙化を防ぎつつ、rollout JSONL の
475
+ // 生データ(ツールカードが粗い)ではなく app-server 整形済みの高忠実度履歴を維持する。
476
+ historyItems: [],
477
+ historyTruncated: false,
478
+ // major#2: ライブ追記でも cold flatten と同じ「item 数+概算 byte」二重上限を課すため、
479
+ // 蓄積済み履歴の概算 byte 合計を保持する (再計算を避け O(1) で trim 判定)。
480
+ historyBytes: 0,
371
481
  }
372
482
  session.ready = new Promise((resolve) => {
373
483
  settleReady = resolve
@@ -398,6 +508,12 @@ export class CodexStreamBridge extends EventEmitter {
398
508
  : { items: [], truncated: false, total: 0 }
399
509
  const history = flat.items
400
510
  const historyTruncated = flat.truncated
511
+ // T06541 案1: cold attach で得た高忠実度履歴をセッションに保持する。以降ライブ item
512
+ // 完了ごとに追記し、cwd reattach 時にこれを返す(新プロセス thread/resume を再実行しない)。
513
+ // major#2: byte 予算判定を O(1) にするため概算 byte 合計も持つ (flatten は既に byte 上限適用済み)。
514
+ session.historyItems = history.slice()
515
+ session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
516
+ session.historyTruncated = historyTruncated
401
517
  // 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
402
518
  // これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
403
519
  // 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
@@ -461,39 +577,105 @@ export class CodexStreamBridge extends EventEmitter {
461
577
  return null
462
578
  }
463
579
 
580
+ /**
581
+ * T06541: 同一 (cwd, threadId) のライブ session を返す (cwd ベース in-memory reattach 用)。
582
+ * frontend は attach_id を送らないため attachId 経路は発火せず、resume は毎回 thread/resume で
583
+ * 新プロセスを起動していた(別プロセスで生成中のターンを Thread.status で観測できず turn_active
584
+ * が false になる症状⑦の主機序)。resume_thread_id 一致で生きた session を引き当て所有権を付け替え
585
+ * れば、生成中ターン・停止・完了通知・高忠実度履歴が 1 本に揃う。
586
+ * ⚠️ **同一会話 (threadId 一致) のみ** を対象にし、別会話 (threadId 不一致) を誤って奪わない。
587
+ * major#3: thread/resume 完了前 (threadId=null) の in-flight session も、resume 対象 thread が
588
+ * 一致すれば引き当てる。これで並行 cold attach の後続が _tryReattach の session.ready 合流に入り、
589
+ * 同一 thread の app-server が二重生成されるのを防ぐ (Claude の同期索引登録と同じ「空の窓」対策)。
590
+ */
591
+ _findLiveSessionByCwd(cwd, threadId) {
592
+ if (!cwd || !threadId) return null
593
+ for (const s of this.sessions.values()) {
594
+ if (s.dead || s.cwd !== cwd) continue
595
+ // 確定済み thread 一致、または in-flight (未確定) で resume 対象が一致。
596
+ if (s.threadId === threadId) return s
597
+ if (s.threadId === null && s.resumeThreadId === threadId) return s
598
+ }
599
+ return null
600
+ }
601
+
602
+ /**
603
+ * T06541 案1: 完了 ThreadItem を高忠実度履歴 (session.historyItems) に追記する。
604
+ * 表示用バブル時刻 at_ms は completedAtMs (ms epoch) を採る(flattenThreadItems と同じ ms 単位。
605
+ * userMessage はライブでも completedAtMs=ターン冒頭で届くため整合する)。長い会話で無限成長させ
606
+ * ないよう cold flatten と同じ **item 数 (maxItems) + 概算 byte 数 (maxBytes) の二重上限** を課し、
607
+ * 超過分は先頭 (最古) から捨て truncated を立てる (major#2: 巨大ツール出力で reattach payload が
608
+ * 肥大化し T06499 で防いだメモリ増・WS 転送・ブラウザフリーズが再発するのを防ぐ)。
609
+ */
610
+ _appendHistoryItem(session, item, completedAtMs) {
611
+ if (!session || !item || typeof item !== "object") return
612
+ if (!Array.isArray(session.historyItems)) session.historyItems = []
613
+ if (typeof session.historyBytes !== "number") session.historyBytes = 0
614
+ const entry = typeof completedAtMs === "number" ? { ...item, at_ms: completedAtMs } : item
615
+ session.historyItems.push(entry)
616
+ session.historyBytes += estimateItemBytes(entry)
617
+ const maxItems = this._historyLimits.maxItems
618
+ const maxBytes = this._historyLimits.maxBytes
619
+ // 末尾 1 件は必ず残す (flatten と同じ方針)。item 数超過 or byte 超過の間、先頭から捨てる。
620
+ while (
621
+ session.historyItems.length > 1 &&
622
+ ((typeof maxItems === "number" && session.historyItems.length > maxItems) ||
623
+ (typeof maxBytes === "number" && session.historyBytes > maxBytes))
624
+ ) {
625
+ const dropped = session.historyItems.shift()
626
+ session.historyBytes -= estimateItemBytes(dropped)
627
+ if (session.historyBytes < 0) session.historyBytes = 0
628
+ session.historyTruncated = true
629
+ }
630
+ }
631
+
464
632
  /**
465
633
  * 既存ライブ session を新 stream_id へ付け替える (in-memory reattach)。thread が未確定
466
634
  * (thread/start in-flight) なら session.ready を await する (成功=threadId / 失敗=null)。
467
635
  * 付け替えられなければ null を返し、呼び出し側 attach() は通常経路へフォールスルーする。
468
636
  */
469
637
  async _tryReattach(session, newStreamId) {
470
- // ⚠️ 付け替え前の stream_id を **await の前に capture** する (major #1: 同じ attach_id へ
471
- // 複数 reconnect が session.ready 待ちで重なったとき、`session.stream_id` を await 後に
472
- // 読むと勝者が既に付け替えた現在値を追ってしまい「別 stream へ移動済み」を検出できず、
473
- // 敗者も成功してしまう。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 を立てず、旧端末も生かしたまま」新端末を相乗りさせる。
474
642
  // 戻り値 status:
475
- // "reattached" - この呼び出しが session を newStreamId へ付け替えた (info 同梱)。
476
- // "superseded" - 別 reattach が勝って session を奪った (session は生きている)。呼び出し側は
477
- // **通常経路に落とさず** 失敗させる (二重 thread を作らない)。
643
+ // "reattached" - newStreamId を購読者として追加した (info 同梱)。
644
+ // "superseded" - newStreamId が既に別 session を保持している異常時のみ (通常は起きない。
645
+ // attach() が :318 で has(stream_id) を弾くため)。呼び出し側は通常経路に
646
+ // 落とさず失敗させる (二重 thread を作らない)。
478
647
  // "unavailable"- session が死んだ / first の thread 確定に失敗 (ライブ session 無し)。
479
648
  // 呼び出し側は通常経路へフォールスルーしてよい。
480
- const originalStreamId = session.stream_id
481
649
  let threadId = session.threadId
482
650
  if (!threadId && session.ready) threadId = await session.ready
483
651
  // first の thread op が失敗 / session が死んだ = ライブ session はもう無い。
484
652
  if (!threadId || session.dead) return { status: "unavailable" }
485
- // await 中に別 reattach が勝って session を移動した = concurrent の敗者。
486
- if (this.sessions.get(originalStreamId) !== session) return { status: "superseded" }
487
- if (this.sessions.has(newStreamId)) return { status: "superseded" } // 新 stream 側が別 session 保持 (異常)
488
- 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)
489
661
  session.stream_id = newStreamId
490
662
  session.detachRequested = false
491
- session.lastActivityMs = Date.now()
492
- this.sessions.set(newStreamId, session)
663
+ session.lastActivityMs = now
493
664
  this.logger?.info?.(
494
- { stream_id: newStreamId, thread_id: threadId, cwd: session.cwd, reattached: true },
495
- "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)",
496
673
  )
674
+ // major#1: 新 stream へ未応答の承認要求を再送する (Claude の _replayPendingPermissions と同型)。
675
+ // 承認要求は app-server との in-flight callback で、履歴 hydrate には含まれない。旧 stream 宛に
676
+ // 出したカードは remount/端末切替で失われ、応答が来ないとターンが永久 pending になる。新 stream へ
677
+ // 再 emit してカードを復元させる (frontend は request_id で重複排除するので二重化しない)。
678
+ this._replayPendingApprovals(session)
497
679
  // 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
498
680
  // 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
499
681
  return {
@@ -602,9 +784,38 @@ export class CodexStreamBridge extends EventEmitter {
602
784
  })
603
785
  }
604
786
 
605
- /** gcAbandonedSessions 用の最終活性更新。browser 入力 / app-server 通知の両方で呼ぶ。 */
606
- _touch(session) {
607
- 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)
608
819
  }
609
820
 
610
821
  _onNotification(session, msg) {
@@ -616,16 +827,28 @@ export class CodexStreamBridge extends EventEmitter {
616
827
  switch (msg.method) {
617
828
  case "turn/started":
618
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)
619
834
  // 会話ターン境界で「新しさ」を更新(閲覧 keepalive では更新しない)。
620
835
  session.lastTurnAtMs = Date.now()
621
836
  break
622
837
  case "turn/completed":
623
838
  session.activeTurnId = null
839
+ // round3 major#2: turn/started を取りこぼしたまま turn/completed が来た異常時も barrier が
840
+ // 固着しないよう解除する (pump が次 item を fresh に turn/start できる)。
841
+ this._settleTurnActiveWaiter(session)
624
842
  session.lastTurnAtMs = Date.now()
625
843
  this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
626
844
  // Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
627
845
  // 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
628
846
  break
847
+ case "item/completed":
848
+ // T06541 案1: 完了した ThreadItem を高忠実度履歴に追記する(cwd reattach で返す用)。
849
+ // ライブ表示は下の emit がそのまま担う(本 case は履歴蓄積を足すだけで emit は変えない)。
850
+ this._appendHistoryItem(session, msg.params?.item, msg.params?.completedAtMs)
851
+ break
629
852
  default:
630
853
  break
631
854
  }
@@ -639,7 +862,12 @@ export class CodexStreamBridge extends EventEmitter {
639
862
 
640
863
  _onServerRequest(session, msg) {
641
864
  const { stream_id } = session
642
- session.pendingApprovals.set(msg.id, { turnId: session.activeTurnId })
865
+ // major#1: reattach 時に新 stream へ再送できるよう method/params も保持する。
866
+ session.pendingApprovals.set(msg.id, {
867
+ turnId: session.activeTurnId,
868
+ method: msg.method,
869
+ params: msg.params,
870
+ })
643
871
  this.emit("permission", {
644
872
  stream_id,
645
873
  thread_id: session.threadId,
@@ -650,6 +878,25 @@ export class CodexStreamBridge extends EventEmitter {
650
878
  })
651
879
  }
652
880
 
881
+ /**
882
+ * major#1: 未応答の承認要求を現在の stream へ再送する (reattach から呼ぶ)。Claude の
883
+ * _replayPendingPermissions と同型。frontend は request_id で重複排除するため、既に表示済みの
884
+ * 端末でも二重化しない。method/params は _onServerRequest 受信時に保存済み。
885
+ */
886
+ _replayPendingApprovals(session) {
887
+ if (!session || session.pendingApprovals.size === 0) return
888
+ for (const [request_id, info] of session.pendingApprovals) {
889
+ this.emit("permission", {
890
+ stream_id: session.stream_id,
891
+ thread_id: session.threadId,
892
+ cwd: session.cwd,
893
+ request_id,
894
+ method: info.method,
895
+ params: info.params,
896
+ })
897
+ }
898
+ }
899
+
653
900
  // ターン完了時、そのターンに紐づく未解決の承認要求は自動 decline する
654
901
  // (browser が応答する前にターンが打ち切られた/クラッシュ相当のケース。
655
902
  // claude-stream-bridge.mjs の「ターン終了時に未解決分は自動 deny」と同じ設計)。
@@ -664,7 +911,12 @@ export class CodexStreamBridge extends EventEmitter {
664
911
  _onClientExit(session, info) {
665
912
  if (session.dead) return
666
913
  session.dead = true
667
- 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)
668
920
  // app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
669
921
  // custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
670
922
  // (codex_core の `Custom tool call output is missing for call id: ...` の発生源)。
@@ -715,55 +967,177 @@ export class CodexStreamBridge extends EventEmitter {
715
967
  const session = this._require(stream_id)
716
968
  // Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
717
969
  // 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
718
- this._touch(session)
970
+ // 案B: stream_id を渡し端末別活性も更新(死端末 GC が入力継続端末を掃除しない)。
971
+ this._touch(session, stream_id)
719
972
  const input = userInputFromMessage(message)
720
- if (session.activeTurnId) {
721
- try {
722
- await session.client.request("turn/steer", {
723
- threadId: session.threadId,
724
- expectedTurnId: session.activeTurnId,
725
- input,
726
- })
727
- return
728
- } catch (err) {
729
- // S3 Codex 堅牢化 (2026-07-05): turn/steer が reject される最大の原因は
730
- // expectedTurnId 不一致 (turn/completed 通知の取りこぼしで session.activeTurnId
731
- // が古いまま残るケース) や request() タイムアウト。回復キューが無いと、以降の
732
- // 入力が全て turn/steer として送られ続けて無反応になる (報告症状②の原因)。
733
- // ここで activeTurnId を捨てて「busy ではない」扱いに戻し、同一メッセージを
734
- // 1 回だけ turn/start として自動リトライすることで自己修復する。
735
- session.activeTurnId = null
736
- try {
737
- const retryParams = { threadId: session.threadId, input }
738
- if (model) retryParams.model = model
739
- if (effort) retryParams.effort = effort
740
- 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" })
741
998
  return
742
- } catch (retryErr) {
743
- // 呼び出し元 (main.mjs の codex.input dispatch) が既存の .catch() で
744
- // codex.error を送出する経路に reason を乗せて届ける (bridge 側で二重に
745
- // 'error' イベントを emit すると frontend へ codex.error が二重送出される
746
- // ため、ここでは throw のみに留める)。
747
- const reason = "turn_mismatch_retry_failed"
748
- throw Object.assign(
749
- new Error(
750
- `turn/steer failed (${err.message}) and turn/start retry also failed: ${retryErr.message}`,
751
- ),
752
- { reason, cause: retryErr },
753
- )
754
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)
1069
+ }
1070
+ }
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)
755
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
+ )
756
1134
  }
757
- const params = { threadId: session.threadId, input }
758
- if (model) params.model = model
759
- if (effort) params.effort = effort
760
- await session.client.request("turn/start", params)
761
1135
  }
762
1136
 
763
1137
  /** @returns {Promise<void>} */
764
1138
  async interrupt({ stream_id }) {
765
1139
  const session = this._require(stream_id)
766
- this._touch(session)
1140
+ this._touch(session, stream_id)
767
1141
  if (!session.activeTurnId) return
768
1142
  await session.client.request("turn/interrupt", {
769
1143
  threadId: session.threadId,
@@ -774,7 +1148,8 @@ export class CodexStreamBridge extends EventEmitter {
774
1148
  /** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
775
1149
  permissionReply({ stream_id, request_id, result }) {
776
1150
  const session = this._require(stream_id)
777
- this._touch(session)
1151
+ // 案B エッジ②: 先着 1 応答で解決。stream_id を渡し応答端末の活性を更新(死端末 GC 対象外に)。
1152
+ this._touch(session, stream_id)
778
1153
  if (!session.pendingApprovals.has(request_id)) {
779
1154
  this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
780
1155
  return
@@ -795,18 +1170,48 @@ export class CodexStreamBridge extends EventEmitter {
795
1170
  keepalive({ stream_id }) {
796
1171
  const session = this.sessions.get(stream_id)
797
1172
  if (!session || session.dead) return false
798
- this._touch(session)
1173
+ this._touch(session, stream_id)
799
1174
  return true
800
1175
  }
801
1176
 
802
1177
  /**
803
- * グレースフル切断(Stage 5)。**すぐには reap しない**。detachRequested を立てて
804
- * warm 保持し、以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)に委ねる。これにより
805
- * 「別 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 タブ閉じても残りの端末のライブ表示が切れない。
806
1185
  */
807
1186
  detach({ stream_id }) {
808
1187
  const session = this.sessions.get(stream_id)
809
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 を判断する。
810
1215
  session.detachRequested = true
811
1216
  this.logger?.info?.(
812
1217
  {
@@ -815,10 +1220,55 @@ export class CodexStreamBridge extends EventEmitter {
815
1220
  cwd: session.cwd,
816
1221
  busy: !!session.activeTurnId,
817
1222
  },
818
- "codex detach requested by browser (kept warm; reconcile handles reap)",
1223
+ "codex detach requested by browser (last terminal; kept warm; reconcile handles reap)",
819
1224
  )
820
1225
  }
821
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
+
822
1272
  /**
823
1273
  * 診断用: 生きているセッションの一覧 (sibling-inject の失敗理由に載せる / ログ用)。
824
1274
  * 「TUI codex は hub-agent の管理外なので ask-sibling の宛先にならない」ことを
@@ -832,8 +1282,8 @@ export class CodexStreamBridge extends EventEmitter {
832
1282
  describeLiveSessions(cwd) {
833
1283
  const now = Date.now()
834
1284
  const out = []
835
- for (const session of this.sessions.values()) {
836
- if (session.dead) continue
1285
+ // 案B: sessions は多対一なので distinct session で走査する(購読端末数ぶん重複させない)。
1286
+ for (const session of this._liveSessions()) {
837
1287
  if (cwd && session.cwd !== cwd) continue
838
1288
  out.push({
839
1289
  kind: "codex",
@@ -888,8 +1338,9 @@ export class CodexStreamBridge extends EventEmitter {
888
1338
  if (!cwd || !text) return false
889
1339
  let best = null
890
1340
  let bestAt = -1
891
- for (const session of this.sessions.values()) {
892
- 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
893
1344
  // 宛先選定の「最新」は会話ターン境界時刻で判定する(閲覧 keepalive で動く
894
1345
  // lastActivityMs を使うと「見ているだけの古い会話」が本命を追い越す)。
895
1346
  const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
@@ -963,9 +1414,11 @@ export class CodexStreamBridge extends EventEmitter {
963
1414
  if (!cwd || !id || !text) return { status: "not_found" }
964
1415
  let bySid = null
965
1416
  const byThread = []
966
- for (const session of this.sessions.values()) {
967
- if (session.dead || session.cwd !== cwd) continue
968
- 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)) {
969
1422
  bySid = session
970
1423
  break // stream_id は一意なので即確定
971
1424
  }
@@ -1023,7 +1476,11 @@ export class CodexStreamBridge extends EventEmitter {
1023
1476
  _reapSession(session, now, reason) {
1024
1477
  if (session.dead) return
1025
1478
  session.dead = true
1026
- 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)
1027
1484
  try {
1028
1485
  session.client.stop()
1029
1486
  } catch (err) {
@@ -1036,7 +1493,7 @@ export class CodexStreamBridge extends EventEmitter {
1036
1493
  cwd: session.cwd,
1037
1494
  reason,
1038
1495
  idle_ms: now - (session.lastActivityMs ?? now),
1039
- live_after: this.sessions.size,
1496
+ live_after: this._liveSessions().length,
1040
1497
  },
1041
1498
  "codex session reaped",
1042
1499
  )
@@ -1057,7 +1514,8 @@ export class CodexStreamBridge extends EventEmitter {
1057
1514
  * @returns {number} 退避した数
1058
1515
  */
1059
1516
  _evictWarmPoolOverflow(now, reason = "lru-evict") {
1060
- const live = Array.from(this.sessions.values()).filter((s) => !s.dead)
1517
+ // 案B: warm pool 上限は「distinct な生存 session 数」で判定する(購読端末数ではない)。
1518
+ const live = this._liveSessions()
1061
1519
  const overflow = live.length - this._warmPoolMax
1062
1520
  if (overflow <= 0) return 0
1063
1521
  const evictable = live
@@ -1098,9 +1556,12 @@ export class CodexStreamBridge extends EventEmitter {
1098
1556
  reconcileSessions() {
1099
1557
  const now = Date.now()
1100
1558
  let reaped = 0
1559
+ // 案B: 0. 死端末 GC を先に回す(多端末購読で応答が途絶えた端末を購読集合から掃除)。これで
1560
+ // 「全端末が死んで空になったセッション」が下の idle TTL 経路で正しく reap 対象になる。
1561
+ this.gcDeadTerminals()
1101
1562
  // 1〜2: stuck turn / long idle。protected(healthy running turn)はスキップ。
1102
- for (const session of Array.from(this.sessions.values())) {
1103
- if (session.dead) continue
1563
+ // 案B: distinct session で走査(多対一 Map の重複 reap を避ける)。
1564
+ for (const session of this._liveSessions()) {
1104
1565
  if (this._isProtected(session, now)) continue
1105
1566
  const idle = now - (session.lastActivityMs ?? now)
1106
1567
  if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
@@ -1123,8 +1584,12 @@ export class CodexStreamBridge extends EventEmitter {
1123
1584
 
1124
1585
  /** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
1125
1586
  shutdown() {
1126
- for (const session of this.sessions.values()) {
1587
+ // 案B: distinct session で走査(多対一 Map の重複 stop を避ける)。
1588
+ for (const session of this._liveSessions()) {
1127
1589
  session.dead = true
1590
+ // round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
1591
+ this._settleTurnActiveWaiter(session)
1592
+ this._drainRejectQueue(session, { reason: "queue_dead" })
1128
1593
  // hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
1129
1594
  // (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
1130
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