@cocorograph/hub-agent 0.8.53 → 0.8.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.53",
3
+ "version": "0.8.55",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -40,13 +40,19 @@ import { nextSessionSeq } from "./session-seq.mjs"
40
40
  * この cwd の capability トークンを乗せる。ディスクには書かず、プロセス環境変数
41
41
  * としてのみ渡す (src/sibling-token.mjs のコメント参照)。
42
42
  * @param {string} cwd
43
- * @returns {{COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string}}
43
+ * @returns {NodeJS.ProcessEnv & {COCKPIT_SIBLING_TOKEN: string, COCKPIT_SIBLING_CWD: string,
44
+ * COCKPIT_DELEGATE_EVENT_ACK: string}}
44
45
  */
45
46
  function siblingEnvFor(cwd) {
46
47
  return {
47
48
  ...process.env,
48
49
  COCKPIT_SIBLING_TOKEN: getOrCreateSiblingToken(cwd),
49
50
  COCKPIT_SIBLING_CWD: cwd,
51
+ // hub-agent が spawn した Claude 配下 producer だけが delegate-event の受理 ACK を
52
+ // 要求するための capability。能力は spawn 時点で固定されるため、hub-agent を旧版へ
53
+ // ロールバックしても既存 producer は ACK を要求し続け、timeout し得る。
54
+ // 旧 bundle はこの値を無視し、従来どおり fire-and-forget のまま動く。
55
+ COCKPIT_DELEGATE_EVENT_ACK: "1",
50
56
  // ask-sibling スキル (cockpit_orch.py) が「自分は claude/codex どちらか」を
51
57
  // 環境変数から確実に判定できるようにする (ヒューリスティック不要)。
52
58
  COCKPIT_SIBLING_SELF_KIND: "claude",
@@ -235,6 +235,18 @@ const IDLE_DETACH_TTL_MS = readPositiveIntEnv(
235
235
  7 * 24 * 60 * 60 * 1000,
236
236
  )
237
237
 
238
+ // browser が最後に detach してから app-server を warm 保持する猶予。lastActivityMs は
239
+ // sibling inject や app-server 通知でも更新されるため、「画面から離れてどれだけ経ったか」の
240
+ // 判定には使えない。browserDetachedAtMs を独立して持ち、既存の protected 判定が解けた後だけ
241
+ // app-server(MCP 子孫を含む)を回収する。Codex は app-server 配下で MCP 子を eager 起動し、
242
+ // 子 thread / MCP 単体の unload RPC も無いため、画面不在 session を app-server 単位で cold 化する
243
+ // 必要がある。Claude SDK session にはこの eager MCP 子構造が無いので Codex 固有 env とする。
244
+ // 既存の 7 日 idle TTL / warm bounds は別軸で維持する。
245
+ const DETACHED_REAP_TTL_MS = readPositiveIntEnv(
246
+ "HUB_AGENT_CODEX_DETACHED_REAP_TTL_MS",
247
+ 30 * 60 * 1000,
248
+ )
249
+
238
250
  // 子⑤ (T06544): warm 保持の上限を「マシン全体の総セッション数」から「各 cwd K 本 × 同時 warm
239
251
  // 保持する cwd 数 N」へ再定義(親 T06539「1 cwd 1 本化」に整合)。旧 WARM_POOL_MAX(総数 LRU=5)は
240
252
  // 廃止し、境界を cwd 単位で定義する。1 セッション ≈ 120-140MB(node ラッパー + 本体 + MCP 子)
@@ -333,6 +345,16 @@ const DELIVERY_ECHO_TIMEOUT_MS = readPositiveIntEnv(
333
345
  8 * 1000,
334
346
  )
335
347
 
348
+ // sibling delivery が「受理済みで再送不要」と判断できる status。delivered は echo 確認済み、
349
+ // delivery_queued は FIFO 受理済み、delivery_unconfirmed は RPC 受理後に echo だけ未確認であり、
350
+ // いずれも画面不在中の実 traffic として cold 化窓を更新する。turn RPC 前に失敗して再送可能な
351
+ // delivery_failed は含めない。
352
+ const DELIVERY_WINDOW_REFRESH_STATUSES = new Set([
353
+ "delivered",
354
+ "delivery_queued",
355
+ "delivery_unconfirmed",
356
+ ])
357
+
336
358
  const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
337
359
  const WORKSPACE_DEPENDENCIES_CACHE_TTL_MS = 30_000
338
360
  const WORKSPACE_DEPENDENCIES_FAILURE_TTL_MS = readPositiveIntEnv(
@@ -601,6 +623,7 @@ export class CodexStreamBridge extends EventEmitter {
601
623
  logger,
602
624
  clientFactory,
603
625
  detachTtlMs,
626
+ detachedReapTtlMs,
604
627
  warmCwdMax,
605
628
  warmPerCwdMax,
606
629
  stuckTurnTtlMs,
@@ -692,6 +715,8 @@ export class CodexStreamBridge extends EventEmitter {
692
715
  || preflightCodexArtifactTemplatePicker
693
716
  // Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
694
717
  this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
718
+ // browser の最終 detach を起点にした短期 warm TTL(既定 30 分)。内部配送の活性では解除しない。
719
+ this._detachedReapTtlMs = detachedReapTtlMs ?? DETACHED_REAP_TTL_MS
695
720
  // 子⑤ (T06544): 同時 warm 保持する cwd 数の上限 N(既定 20)。各 cwd は K 本に畳む。
696
721
  // テストから小さい値を注入可能。⚠️ 正の整数以外(1.5 / Infinity / NaN)は既定へ戻して warn する。
697
722
  // 1.5 が for の開始添字に入ると `list[1.5]` が undefined になり `_isProtected(undefined)` で
@@ -760,8 +785,10 @@ export class CodexStreamBridge extends EventEmitter {
760
785
  *
761
786
  * @returns {Promise<{resuming: boolean, threadId: string, history: object[],
762
787
  * historyTruncated: boolean, historyStart?: number, historyTotal?: number,
788
+ * subagents?: object[],
763
789
  * threadStatus: string|null, lastTurnStatus: string|null,
764
790
  * turnActive: boolean}>}
791
+ * `subagents` の省略は snapshot 未提供(既存 UI 状態を維持)を意味し、空配列は権威的に0件を意味する。
765
792
  */
766
793
  async attach({ stream_id, cwd, model, effort, approvalPolicy, sandbox, resumeThreadId, attachId }) {
767
794
  if (this.sessions.has(stream_id)) {
@@ -791,6 +818,7 @@ export class CodexStreamBridge extends EventEmitter {
791
818
  const r = await this._tryReattach(existing, stream_id)
792
819
  timing.reattach_wait_ms = (timing.reattach_wait_ms ?? 0) + lap()
793
820
  if (r.status === "reattached") {
821
+ const snapshot = this._warmReattachSnapshot(existing, r.info)
794
822
  this._logAttachTiming({
795
823
  stream_id,
796
824
  cwd,
@@ -800,11 +828,12 @@ export class CodexStreamBridge extends EventEmitter {
800
828
  ok: true,
801
829
  resuming: true,
802
830
  thread_op: null,
803
- history_items: 0,
831
+ history_items: snapshot.history.length,
832
+ history_truncated: snapshot.historyTruncated,
804
833
  total_ms: Date.now() - attachStartedAt,
805
834
  timing,
806
835
  })
807
- return r.info
836
+ return snapshot
808
837
  }
809
838
  if (r.status === "superseded") {
810
839
  this._logAttachTiming({
@@ -848,7 +877,7 @@ export class CodexStreamBridge extends EventEmitter {
848
877
  const r = await this._tryReattach(existing, stream_id)
849
878
  timing.reattach_wait_ms = (timing.reattach_wait_ms ?? 0) + lap()
850
879
  if (r.status === "reattached") {
851
- const history = Array.isArray(existing.historyItems) ? existing.historyItems.slice() : []
880
+ const snapshot = this._warmReattachSnapshot(existing, r.info)
852
881
  this._logAttachTiming({
853
882
  stream_id,
854
883
  cwd,
@@ -858,27 +887,12 @@ export class CodexStreamBridge extends EventEmitter {
858
887
  ok: true,
859
888
  resuming: true,
860
889
  thread_op: null,
861
- history_items: history.length,
862
- history_truncated: existing.historyTruncated === true,
890
+ history_items: snapshot.history.length,
891
+ history_truncated: snapshot.historyTruncated,
863
892
  total_ms: Date.now() - attachStartedAt,
864
893
  timing,
865
894
  })
866
- return {
867
- ...r.info,
868
- // 案1: reattach は履歴を返さない設計 (r.info.history=[]) だが、cwd reattach は remount
869
- // 由来で表示がリセットされ得るため、live セッションが保持する高忠実度履歴を返す。
870
- history,
871
- historyTruncated: existing.historyTruncated === true,
872
- ...(history.length > 0
873
- ? {
874
- historyStart: Number.isInteger(existing.historyStart) ? existing.historyStart : 0,
875
- historyTotal: Number.isInteger(existing.historyTotal)
876
- ? existing.historyTotal
877
- : history.length,
878
- }
879
- : {}),
880
- subagents: this._subagentSnapshots(existing, history),
881
- }
895
+ return snapshot
882
896
  }
883
897
  if (r.status === "superseded") {
884
898
  this._logAttachTiming({
@@ -983,6 +997,13 @@ export class CodexStreamBridge extends EventEmitter {
983
997
  // (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
984
998
  // ask-sibling 注入 (sendToCwd) は「新しい持ち主が付いた」とみなして false に戻す。
985
999
  detachRequested: false,
1000
+ // 最後の browser subscriber が detach した時刻。実 browser reattach だけが null へ戻す。
1001
+ // sibling inject 等の内部配送では残し、画面不在の app-server を短期 TTL 後に回収できるようにする。
1002
+ browserDetachedAtMs: null,
1003
+ // 明示 codex.detach 後に同 stream の古い interval callback が到着しても、実 attach 前に
1004
+ // browser 復帰扱いへ戻さないための最小フラグ。死端末 GC 起因では false のままなので、
1005
+ // 本当に生存していた browser の keepalive は復帰シグナルとして使える。
1006
+ explicitBrowserDetachPendingAttach: false,
986
1007
  dead: false,
987
1008
  // Stage 5 reconcile(LRU / idle TTL / stuck)用の最終活性時刻。browser 入力と app-server
988
1009
  // 通知 (ターン進捗) の両方で更新する。LRU は「最終活性が最も古い非 protected」を退避する。
@@ -1180,6 +1201,8 @@ export class CodexStreamBridge extends EventEmitter {
1180
1201
  // T06541 案1: cold attach で得た高忠実度履歴をセッションに保持する。以降ライブ item
1181
1202
  // 完了ごとに追記し、cwd reattach 時にこれを返す(新プロセス thread/resume を再実行しない)。
1182
1203
  // major#2: byte 予算判定を O(1) にするため概算 byte 合計も持つ (flatten は既に byte 上限適用済み)。
1204
+ // app-server reap 後の cold resume は rollout が正本になるため、未 rollout の空 fresh session や
1205
+ // in-memory の sibling source タグは復元できない。空 session の滞留を避けるため fresh 例外は作らない。
1183
1206
  session.historyItems = history.slice()
1184
1207
  session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
1185
1208
  session.historyTruncated = historyTruncated
@@ -1347,6 +1370,39 @@ export class CodexStreamBridge extends EventEmitter {
1347
1370
  return null
1348
1371
  }
1349
1372
 
1373
+ /** attach-id / cwd の warm reattach ready payload を同じ shape で組み立てる。 */
1374
+ _warmReattachSnapshot(session, baseInfo) {
1375
+ // historyItems 自体が既存の item/byte 上限内なので、attach-id reconnect でも payload 上限を
1376
+ // 新たに広げない。frontend reducer は uuid で重複排除し、hidden 中の item 欠損だけを補う。
1377
+ const history = Array.isArray(session.historyItems) ? session.historyItems.slice() : []
1378
+ return {
1379
+ ...baseInfo,
1380
+ history,
1381
+ historyTruncated: session.historyTruncated === true,
1382
+ ...(history.length > 0
1383
+ ? {
1384
+ historyStart: Number.isInteger(session.historyStart) ? session.historyStart : 0,
1385
+ historyTotal: Number.isInteger(session.historyTotal)
1386
+ ? session.historyTotal
1387
+ : history.length,
1388
+ }
1389
+ : {}),
1390
+ ...this._completeSubagentSnapshotField(session, history),
1391
+ }
1392
+ }
1393
+
1394
+ /**
1395
+ * warm ready の subagents 三値契約。session.subagents が表示台帳の正本で、空なら権威的な []。
1396
+ * 1件でも started anchor が trim 等で history 外なら部分 snapshot を返さず、キー自体を省略して
1397
+ * frontend の既存表示を維持させる。全 record を表現できる場合だけ配列を返す。
1398
+ */
1399
+ _completeSubagentSnapshotField(session, history) {
1400
+ const recordCount = session?.subagents?.size ?? 0
1401
+ if (recordCount === 0) return { subagents: [] }
1402
+ const snapshots = this._subagentSnapshots(session, history)
1403
+ return snapshots.length === recordCount ? { subagents: snapshots } : {}
1404
+ }
1405
+
1350
1406
  /**
1351
1407
  * T06541 案1: 完了 ThreadItem を高忠実度履歴 (session.historyItems) に追記する。
1352
1408
  * 表示用バブル時刻 at_ms は completedAtMs (ms epoch) を採る(flattenThreadItems と同じ ms 単位。
@@ -1777,6 +1833,8 @@ export class CodexStreamBridge extends EventEmitter {
1777
1833
  this.sessions.set(newStreamId, session)
1778
1834
  session.stream_id = newStreamId
1779
1835
  session.detachRequested = false
1836
+ session.browserDetachedAtMs = null
1837
+ session.explicitBrowserDetachPendingAttach = false
1780
1838
  session.lastActivityMs = now
1781
1839
  this.logger?.info?.(
1782
1840
  {
@@ -1793,15 +1851,16 @@ export class CodexStreamBridge extends EventEmitter {
1793
1851
  // 出したカードは remount/端末切替で失われ、応答が来ないとターンが永久 pending になる。新 stream へ
1794
1852
  // 再 emit してカードを復元させる (frontend は request_id で重複排除するので二重化しない)。
1795
1853
  this._replayPendingApprovals(session)
1796
- // 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
1797
- // 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
1854
+ // ここでは購読・権威状態だけを返す。呼び出し元 attach() が attach-id / cwd のどちらにも
1855
+ // _warmReattachSnapshot() を適用し、保持履歴・subagent snapshot を同じ ready shape へ揃える。
1856
+ // turnActive は session が保持する activeTurnId から権威的に決める。
1798
1857
  return {
1799
1858
  status: "reattached",
1800
1859
  info: {
1801
1860
  resuming: true,
1802
1861
  threadId,
1803
1862
  history: [],
1804
- // reconnect の in-memory reattach は表示を保持しているので履歴を返さない = 常に非 truncated。
1863
+ // placeholder。保持履歴と cursor metadata は呼び出し元の共通 snapshot helper が上書きする。
1805
1864
  historyTruncated: false,
1806
1865
  threadStatus: session.activeTurnId ? "active" : "idle",
1807
1866
  lastTurnStatus: null,
@@ -3760,19 +3819,36 @@ export class CodexStreamBridge extends EventEmitter {
3760
3819
  /**
3761
3820
  * ブラウザ閲覧中の生存シグナル (codex.keepalive)。frontend が Codex チャットを開いて
3762
3821
  * いる間だけ定期送信するため、これが届く = 「人が見ている」の観測可能な証跡になる。
3822
+ * frontend は ready 後に codex.keepalive の interval を開始する(現行60秒。WS pingの25秒とは別)。
3763
3823
  * Stage 5: touch で lastActivityMs を更新すると LRU 先頭に留まるため、**閲覧中のセッションは
3764
3824
  * 上限超過の LRU 退避対象にならず、長 idle TTL でも殺されない**(= 実質無期限)。ブラウザを
3765
3825
  * 閉じれば keepalive も止まり、以降は detach 済み warm として LRU / 7 日 idle TTL に従う。
3766
3826
  *
3767
- * @returns {boolean} 生きているセッションを touch できたら true (未知 stream は false)。
3827
+ * 明示 detach 後は実 attach まで同 stream の残留 keepalive を false で無視する。一方、死端末 GC
3828
+ * 由来の detachRequested は live keepalive を復帰シグナルとして受け入れる。
3829
+ * @returns {boolean} セッションを touch できたら true(未知/dead/明示detach待ちは false)。
3768
3830
  */
3769
3831
  keepalive({ stream_id }) {
3770
3832
  const session = this.sessions.get(stream_id)
3771
3833
  if (!session || session.dead) return false
3834
+ // 明示 detach の直前に既に timer queue へ入った callback は、detach 後にも同じ stream_id で
3835
+ // 到着し得る。クライアント契約では再表示時に必ず attach するため、その attach がこのフラグを
3836
+ // 解除するまでは古い keepalive として無視する。GC 起因の detach はフラグを立てない。
3837
+ if (session.explicitBrowserDetachPendingAttach) return false
3838
+ // keepalive は実 browser が閲覧中という証跡なので、detach 不達/遅延で残った warm 状態も解除する。
3839
+ // app-server 通知や sibling inject が通る _touch() ではこの browser 専用状態を解除しない。
3840
+ session.detachRequested = false
3841
+ session.browserDetachedAtMs = null
3772
3842
  this._touch(session, stream_id)
3773
3843
  return true
3774
3844
  }
3775
3845
 
3846
+ /** accepted済みの新規 sibling traffic だけ browser-detached の cold 化窓を更新する。 */
3847
+ _shouldRefreshDetachWindow(result) {
3848
+ return result?.accepted_via !== "duplicate"
3849
+ && DELIVERY_WINDOW_REFRESH_STATUSES.has(result?.status)
3850
+ }
3851
+
3776
3852
  /**
3777
3853
  * グレースフル切断(Stage 5 + 案B T06542)。**すぐには reap しない**。
3778
3854
  *
@@ -3812,6 +3888,8 @@ export class CodexStreamBridge extends EventEmitter {
3812
3888
  // 最後の 1 台が外れた: key / streamIds は残したまま(reconcile 到達性のため)warm 保持し、
3813
3889
  // detachRequested を立てる。以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)が reap を判断する。
3814
3890
  session.detachRequested = true
3891
+ session.explicitBrowserDetachPendingAttach = true
3892
+ if (session.browserDetachedAtMs == null) session.browserDetachedAtMs = Date.now()
3815
3893
  this.logger?.info?.(
3816
3894
  {
3817
3895
  stream_id,
@@ -3826,10 +3904,10 @@ export class CodexStreamBridge extends EventEmitter {
3826
3904
  /**
3827
3905
  * 案B (T06542): 死端末 GC。多端末購読で、クラッシュ / タブ強制終了で codex.detach を送れずに
3828
3906
  * 消えた購読端末(一定期間 input/permission/interrupt/keepalive の活性が無い stream_id)を購読集合
3829
- * から掃除する。claude-stream-bridge.mjs の gcDeadTerminals と同型。複数端末が購読するセッション
3830
- * のみ対象(1 台しか居なければ reconcile の idle TTL / LRU に任せる)。全端末が死端末で空になったら
3831
- * detachRequested を立て warm 保持へ落とす。reconcileSessions の先頭から呼ばれる(main.mjs の
3832
- * state loop 経由)ほか、テストから直接も呼べる。
3907
+ * から掃除する。claude-stream-bridge.mjs の gcDeadTerminals と同型。最後の 1 key は routing / reconcile
3908
+ * 到達性のため消さず、単端末を含め stale なら detachRequested と browserDetachedAtMs を立てる。
3909
+ * codex.detach 不達時の実効回収時間は dead-terminal TTL + detached reap TTL になる。
3910
+ * reconcileSessions の先頭から呼ばれる(main.mjs の state loop 経由)ほか、テストから直接も呼べる。
3833
3911
  * @param {number} [ttlMs] 死端末とみなす無活性閾値(テスト用に上書き可)。
3834
3912
  * @returns {number} GC した端末キー数。
3835
3913
  */
@@ -3837,8 +3915,6 @@ export class CodexStreamBridge extends EventEmitter {
3837
3915
  const now = Date.now()
3838
3916
  let removed = 0
3839
3917
  for (const session of this._liveSessions()) {
3840
- // 複数端末が購読しているセッションのみ対象(1 台なら reconcile の idle TTL / LRU に任せる)。
3841
- if (session.streamIds.size <= 1) continue
3842
3918
  for (const sid of Array.from(session.streamIds)) {
3843
3919
  // ⚠️ 不変条件: live session は常に stream key を 1 つ以上残す(reconcile 到達性)。全端末が
3844
3920
  // 死端末でも最後の 1 個は消さず、下で detachRequested を立てて idle TTL reap に委ねる。
@@ -3862,7 +3938,10 @@ export class CodexStreamBridge extends EventEmitter {
3862
3938
  if (session.streamIds.size === 1) {
3863
3939
  const [onlySid] = session.streamIds
3864
3940
  const last = session.lastActivityByStream.get(onlySid) ?? 0
3865
- if (now - last >= ttlMs) session.detachRequested = true
3941
+ if (now - last >= ttlMs) {
3942
+ session.detachRequested = true
3943
+ if (session.browserDetachedAtMs == null) session.browserDetachedAtMs = now
3944
+ }
3866
3945
  }
3867
3946
  }
3868
3947
  return removed
@@ -4002,17 +4081,23 @@ export class CodexStreamBridge extends EventEmitter {
4002
4081
  return { status: "ambiguous", candidates }
4003
4082
  }
4004
4083
  const best = tier[0]
4005
- // detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
4006
- // detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
4007
- // 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
4084
+ // detachRequested=false は「内部配送先として現役」の routing 状態。browser 不在は
4085
+ // browserDetachedAtMs で別管理する。過去の 5 分 reap と違い、成功した sibling traffic ごとに
4086
+ // 30 分窓を更新し、その間の root/FIFO/delivery/command/subagent は _isProtected で守る。
4087
+ // 完全無活動が 30 分続いた後だけ app-server を cold 化する。not_found 時の cold 自動起動はしない。
4008
4088
  best.detachRequested = false
4009
4089
  this._touch(best)
4010
- return this._deliverInjection(best, text, source, {
4090
+ const result = await this._deliverInjection(best, text, source, {
4011
4091
  cwd,
4012
4092
  selection: "sole-candidate",
4013
4093
  candidates: this.describeLiveSessions(cwd).length,
4014
4094
  request_id: requestId,
4015
4095
  })
4096
+ // await 中に実 browser attach が入れば timestamp は null になる。その場合は再刻印しない。
4097
+ if (best.browserDetachedAtMs != null && this._shouldRefreshDetachWindow(result)) {
4098
+ best.browserDetachedAtMs = Date.now()
4099
+ }
4100
+ return result
4016
4101
  }
4017
4102
 
4018
4103
  /**
@@ -4066,16 +4151,21 @@ export class CodexStreamBridge extends EventEmitter {
4066
4151
  target = byThread[0] || null
4067
4152
  }
4068
4153
  if (!target) return { status: "not_found" }
4069
- // detach 済み warm セッションでも、明示指定で使うなら「新しい持ち主が付いた」とみなす
4070
- // (sendToCwd と同じ扱い)。
4154
+ // detachRequested は内部配送の routing 状態、browserDetachedAtMs は画面不在の cold 化窓。
4155
+ // 成功した sibling traffic だけ後者を更新する(sendToCwd と同じ扱い)。
4071
4156
  target.detachRequested = false
4072
4157
  this._touch(target)
4073
- return this._deliverInjection(target, text, source, {
4158
+ const result = await this._deliverInjection(target, text, source, {
4074
4159
  cwd,
4075
4160
  requested_id: id,
4076
4161
  selection: bySid ? "stream_id" : "thread_id",
4077
4162
  request_id: requestId,
4078
4163
  })
4164
+ // await 中に実 browser attach が入れば timestamp は null になる。その場合は再刻印しない。
4165
+ if (target.browserDetachedAtMs != null && this._shouldRefreshDetachWindow(result)) {
4166
+ target.browserDetachedAtMs = Date.now()
4167
+ }
4168
+ return result
4079
4169
  }
4080
4170
 
4081
4171
  /**
@@ -4451,6 +4541,9 @@ export class CodexStreamBridge extends EventEmitter {
4451
4541
  cwd: session.cwd,
4452
4542
  reason,
4453
4543
  idle_ms: now - (session.lastActivityMs ?? now),
4544
+ browser_detached_for_ms: session.browserDetachedAtMs == null
4545
+ ? null
4546
+ : Math.max(0, now - session.browserDetachedAtMs),
4454
4547
  live_after: this._liveSessions().length,
4455
4548
  },
4456
4549
  "codex session reaped",
@@ -4572,13 +4665,16 @@ export class CodexStreamBridge extends EventEmitter {
4572
4665
  * Stage 5: セッションの周期 reconcile(state loop から ~5s 毎に実行)。以下を 1 パスで行う:
4573
4666
  * 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
4574
4667
  * (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
4575
- * 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
4576
- * 3. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd **K 本(現行の既定 3)**
4668
+ * 2. **browser detached**: 最後の browser 離脱(または死端末 GC)後、短期 TTL(既定30分)を
4669
+ * 超えた非 protected session → app-server ごと reap。成功した sibling traffic は窓を更新する。
4670
+ * 3. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
4671
+ * 4. **warm 境界**(子⑤ T06544): `_reconcileWarmBounds` で「各 cwd **K 本(現行の既定 3)**
4577
4672
  * × 同時 warm cwd 数 N(既定 20)」に有界化。cwd 内で K 本を超えたぶんは最終活性が古い方から
4578
4673
  * reap し、cwd 数超過は最古 cwd から reap する。
4579
4674
  * (**旧仕様**は per-cwd=1 =「同一 cwd の重複は最新 1 本へ畳む」だった。1 cwd で複数会話を
4580
4675
  * 同時に生かすため K へ一般化し、総量は総 live 上限 M(warm-bounds-total.mjs)で押さえる。)
4581
- * busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
4676
+ * protected session は 1〜4 いずれの通常回収対象にもならない。ただし stuck turn は保護失効後、
4677
+ * browser-detached TTL より先に stuck-turn 理由で回収し、既存の診断意味を維持する。
4582
4678
  *
4583
4679
  * 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
4584
4680
  * 殺していた挙動を廃し、上限+長 TTL に一本化した。main.mjs の state loop 呼び出しは
@@ -4597,15 +4693,21 @@ export class CodexStreamBridge extends EventEmitter {
4597
4693
  for (const session of this._liveSessions()) {
4598
4694
  if (this._isProtected(session, now)) continue
4599
4695
  const idle = now - (session.lastActivityMs ?? now)
4696
+ const browserDetachedFor = session.browserDetachedAtMs == null
4697
+ ? null
4698
+ : now - session.browserDetachedAtMs
4600
4699
  if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
4601
4700
  this._reapSession(session, now, "stuck-turn")
4602
4701
  reaped += 1
4702
+ } else if (browserDetachedFor !== null && browserDetachedFor > this._detachedReapTtlMs) {
4703
+ this._reapSession(session, now, "browser-detached-ttl")
4704
+ reaped += 1
4603
4705
  } else if (!session.activeTurnId && idle > this._detachTtlMs) {
4604
4706
  this._reapSession(session, now, "idle-ttl")
4605
4707
  reaped += 1
4606
4708
  }
4607
4709
  }
4608
- // 3: warm 境界(per-cwd=K / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
4710
+ // 4: warm 境界(per-cwd=K / N-cwd LRU)を適用(stuck/idle reap 後の生存数で判定)。
4609
4711
  reaped += this._reconcileWarmBounds(now, "warm-bounds")
4610
4712
  return reaped
4611
4713
  }
package/src/main.mjs CHANGED
@@ -4702,7 +4702,8 @@ export async function dispatch(msg, ctx) {
4702
4702
  case "codex.keepalive":
4703
4703
  // ブラウザが Codex チャットを開いている間の生存シグナル。
4704
4704
  // 閲覧中に gcAbandonedSessions (既定 1h 無通信) が
4705
- // 誤爆するのを防ぐ。未知 stream_id は false が返るだけ (無害な no-op)。
4705
+ // 誤爆するのを防ぐ。未知/dead stream_id と、明示 detach 後に残った旧 interval callback は
4706
+ // attach まで false(無害な no-op)。死端末 GC 起因の detach は live keepalive で復帰できる。
4706
4707
  if (!ctx.codexBridge) return
4707
4708
  ctx.codexBridge.keepalive({ stream_id: msg.stream_id })
4708
4709
  return
@@ -37,10 +37,13 @@
37
37
  * - 安全な再起動: `{op:"restart"}` → `{ok:true, restart_pending:true}`。本文と token を持たず、
38
38
  * 所有者限定の IPC directory/request file (0700/0600) だけを境界として扱う。
39
39
  * 処理後は必ず削除し (成否問わず)、通常 IPC は呼び出し側が待ち受けられるよう
40
- * `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?,
40
+ * `<request_id>.result.json` = `{ok, error?, duplicate?, delivered_to?, candidates?, live_sessions?, hint?,
41
41
  * candidates_nameable?}` を書く (`candidates_nameable` は曖昧系エラーのときのみ。CLI が
42
42
  * 「候補を実行可能な `--to` として並べてよいか」を hint の文言に依存せず判定するための boolean)。
43
- * `delegate-event` だけは fire-and-forget のため応答ファイルを書かない。
43
+ * 同一 request_id の再投入は二重処理防止で無視するため、意味上の再送は新しい transport ID を使う。
44
+ * `delegate-event` の `request_ack` は省略または literal true だけが正規入力。true のとき受理結果を
45
+ * 書き、不正値でも property があれば明示エラーを書く。未指定の旧 producer は fire-and-forget の
46
+ * ままなので、新旧 bundle / hub-agent のローリング互換を保つ。
44
47
  *
45
48
  * ⚠️ codex の `delivery_state` は 4 値ある (2026-08-05 に `queued` を追加):
46
49
  * - `delivered` … userMessage echo まで確認済み (ok:true)
@@ -82,6 +85,7 @@ const SIBLING_SWEEP_DEBOUNCE_MS = 25
82
85
 
83
86
  export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
84
87
  export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
88
+ export const SIBLING_RESULT_RETENTION_MS = 24 * 60 * 60 * 1000
85
89
  const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
86
90
  const DELEGATE_PROMPT_MAX_LENGTH = 64_000
87
91
  const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
@@ -491,8 +495,8 @@ export class SiblingInjectBridge {
491
495
  }
492
496
  }
493
497
 
494
- _recordDelegateEvent(cwd, sessionId, event) {
495
- this._pruneDelegations()
498
+ _recordDelegateEvent(cwd, sessionId, event, { prune = true } = {}) {
499
+ if (prune) this._pruneDelegations()
496
500
  const sessionKey = this._delegationSessionKey(cwd, sessionId)
497
501
  if (event.phase !== "started") {
498
502
  const activeEntry = this._activeDelegations.get(sessionKey)?.get(event.delegation_id)
@@ -542,11 +546,40 @@ export class SiblingInjectBridge {
542
546
  }
543
547
  }
544
548
 
545
- _acceptsDelegateEvent(cwd, sessionId, event) {
549
+ _delegateEventAcceptance(cwd, sessionId, event) {
546
550
  this._pruneDelegations()
547
- if (event.phase !== "started") return true
548
551
  const sessionKey = this._delegationSessionKey(cwd, sessionId)
549
- return !this._terminalDelegations.get(sessionKey)?.has(event.delegation_id)
552
+ const terminal = this._terminalDelegations.get(sessionKey)?.get(event.delegation_id)
553
+ if (event.phase === "started") {
554
+ if (terminal) return "stale"
555
+ return this._activeDelegations.get(sessionKey)?.has(event.delegation_id)
556
+ ? "duplicate"
557
+ : "accept"
558
+ }
559
+ if (!terminal) return "accept"
560
+ // terminal 同士は後から届いた実イベントを最新終端として上書きできる。
561
+ // 例: replay が PID 消失から stopped を合成した後に producer の completed が届く。
562
+ return terminal.terminalEvent?.phase === event.phase ? "duplicate" : "accept"
563
+ }
564
+
565
+ _liveClaudeSession(cwd, sessionId) {
566
+ const matches = describeLiveSessionsUnion(this._bridges(), {
567
+ cwd,
568
+ cliKind: "claude",
569
+ }).filter((session) => session.session_id === sessionId)
570
+ if (matches.length === 1) return { status: "delivered", session: matches[0] }
571
+ return {
572
+ status: matches.length > 1 ? "ambiguous" : "not_found",
573
+ ...(matches.length ? { candidates: matches } : {}),
574
+ }
575
+ }
576
+
577
+ _delegateTargetFailure(targetResult) {
578
+ return {
579
+ ok: false,
580
+ error: AMBIGUOUS_TARGET_ERROR,
581
+ ...(targetResult?.candidates ? { candidates: targetResult.candidates } : {}),
582
+ }
550
583
  }
551
584
 
552
585
  async _delegateProcessAlive(event) {
@@ -699,7 +732,26 @@ export class SiblingInjectBridge {
699
732
  return
700
733
  }
701
734
  for (const n of names) {
702
- if (n.endsWith(".json") && !n.endsWith(".result.json")) {
735
+ if (n.endsWith(".result.json")) {
736
+ const fp = path.join(this.dir, n)
737
+ try {
738
+ const info = await lstat(fp)
739
+ const uid = process.getuid?.()
740
+ if (
741
+ !info.isSymbolicLink() &&
742
+ info.isFile() &&
743
+ uid != null &&
744
+ info.uid === uid &&
745
+ // 壁時計が前へ step すると保持期間が短くなるが、result は再配送の正本ではなく
746
+ // 呼び出し側が回収する一時 ACK なので、24時間経過扱いでの掃除を許容する。
747
+ info.mtimeMs < this._now() - SIBLING_RESULT_RETENTION_MS
748
+ ) {
749
+ await unlink(fp)
750
+ }
751
+ } catch {
752
+ /* 同時に呼び出し側が回収済みなら no-op */
753
+ }
754
+ } else if (n.endsWith(".json")) {
703
755
  await this._ingest(n, { startup }).catch((err) => {
704
756
  this.logger?.warn({ err: err?.message, filename: n }, "sibling-inject sweep failed")
705
757
  })
@@ -709,7 +761,8 @@ export class SiblingInjectBridge {
709
761
 
710
762
  /**
711
763
  * 要求ファイル 1 件を読み、トークン検証後に対象 cwd のセッションへ注入する。
712
- * 成否によらず要求ファイルは削除する。delegate-event 以外は結果ファイルも書く。
764
+ * 成否によらず要求ファイルは削除する。delegate-event は request_ack:true または
765
+ * malformed な request_ack property がある場合だけ結果ファイルも書く。
713
766
  * @param {string} filename `<request_id>.json`
714
767
  */
715
768
  async _ingest(filename, { startup = false } = {}) {
@@ -729,8 +782,8 @@ export class SiblingInjectBridge {
729
782
  // ⚠️ **結果ファイルを必ず書く**。ここで書かずに戻ると、結果をポーリングしている
730
783
  // 呼び出し側 (cockpit_orch.py) には「タイムアウト」としか見えない。タイムアウトは
731
784
  // 「不達とは限らない」と扱う契約なので、拒否した事実が呼び出し側で不達に化ける
732
- // (=黙って捨てたのと同じになる)。delegate-event の送り手は結果を読まないため、
733
- // op を判別できないまま無条件に書いて構わない (余分な結果ファイルは stale 掃除の対象)。
785
+ // (=黙って捨てたのと同じになる)。ここでは本文を parse できず op / request_ack を
786
+ // 判別できないため無条件に書く。旧 producer 向けの余分な結果は stale 掃除の対象。
734
787
  await this._writeResult(request_id, {
735
788
  ok: false,
736
789
  error: "request_too_large",
@@ -778,6 +831,7 @@ export class SiblingInjectBridge {
778
831
  timestamp,
779
832
  pid,
780
833
  pid_started_at,
834
+ request_ack,
781
835
  } = body || {}
782
836
  // 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
783
837
  // 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
@@ -787,6 +841,7 @@ export class SiblingInjectBridge {
787
841
  const isList = op === "list"
788
842
  const isExplicit = op === "inject"
789
843
  const isDelegateEvent = op === "delegate-event"
844
+ const hasRequestAck = Object.prototype.hasOwnProperty.call(body || {}, "request_ack")
790
845
  const isRestart = op === "restart"
791
846
  let result
792
847
  const badKind = target_cli_kind !== "claude" && target_cli_kind !== "codex"
@@ -803,6 +858,7 @@ export class SiblingInjectBridge {
803
858
  (prompt !== undefined && typeof prompt !== "string") ||
804
859
  (pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
805
860
  (pid_started_at !== undefined && typeof pid_started_at !== "string") ||
861
+ (request_ack !== undefined && request_ack !== true) ||
806
862
  (timestamp !== undefined &&
807
863
  (typeof timestamp !== "string" || Number.isNaN(Date.parse(timestamp))))
808
864
  const badBody = isDelegateEvent
@@ -892,21 +948,26 @@ export class SiblingInjectBridge {
892
948
  }
893
949
  // 墓標確認から配送成功時の台帳反映まで await を挟まない同期区間に閉じる。
894
950
  // 配送失敗した started は台帳へ残さず、終端後に遅着した started は配送前に拒否する。
895
- if (!this._acceptsDelegateEvent(cwd, target_session_id, event)) {
951
+ const acceptance = this._delegateEventAcceptance(cwd, target_session_id, event)
952
+ if (acceptance === "duplicate") {
953
+ // ACK は「過去に受理した」だけでなく、対象 stream が現在も live であることも示す。
954
+ // 台帳だけで成功を返すと、宛先消失後の retry を受理済みと誤認させる。
955
+ const live = this._liveClaudeSession(cwd, target_session_id)
956
+ result = live.status === "delivered"
957
+ ? { ok: true, duplicate: true }
958
+ : this._delegateTargetFailure(live)
959
+ } else if (acceptance === "stale") {
896
960
  result = { ok: false, error: "stale_delegate_event" }
897
961
  } else {
898
962
  const r = this.claudeBridge?.emitToStream
899
963
  ? this.claudeBridge.emitToStream(cwd, target_session_id, event)
900
964
  : { status: "not_found" }
901
965
  if (r.status === "delivered") {
902
- this._recordDelegateEvent(cwd, target_session_id, event)
966
+ // acceptance 直前に prune 済み。同じ同期区間で二重 prune しない。
967
+ this._recordDelegateEvent(cwd, target_session_id, event, { prune: false })
903
968
  result = { ok: true, delivered_to: r.session }
904
969
  } else {
905
- result = {
906
- ok: false,
907
- error: AMBIGUOUS_TARGET_ERROR,
908
- ...(r.candidates ? { candidates: r.candidates } : {}),
909
- }
970
+ result = this._delegateTargetFailure(r)
910
971
  }
911
972
  }
912
973
  }
@@ -1068,6 +1129,7 @@ export class SiblingInjectBridge {
1068
1129
  delivery_state: result.delivery_state,
1069
1130
  durable_state: result.durable_state,
1070
1131
  reason: result.reason,
1132
+ duplicate: result.duplicate,
1071
1133
  }
1072
1134
  : {
1073
1135
  error: result.error,
@@ -1077,7 +1139,9 @@ export class SiblingInjectBridge {
1077
1139
  }),
1078
1140
  },
1079
1141
  result.ok
1080
- ? result.delivery_state === "delivered"
1142
+ ? result.duplicate
1143
+ ? "sibling-inject duplicate acknowledged"
1144
+ : result.delivery_state === "delivered"
1081
1145
  ? "sibling-inject delivered"
1082
1146
  : result.delivery_state === "queued"
1083
1147
  ? "sibling-inject queued"
@@ -1085,7 +1149,11 @@ export class SiblingInjectBridge {
1085
1149
  : "sibling-inject failed",
1086
1150
  )
1087
1151
  // startup sweep 由来の restart には待受者がいない。孤児 result を残さない。
1088
- if (!isDelegateEvent && !(isRestart && startup)) await this._writeResult(request_id, result)
1152
+ if ((!isDelegateEvent || hasRequestAck) && !(isRestart && startup)) {
1153
+ // delegate ACK は「対象 Claude stream の内部 event 経路へ受理」までの保証。
1154
+ // ブラウザ側がイベントを登録・描画したことまでは保証しない。
1155
+ await this._writeResult(request_id, result)
1156
+ }
1089
1157
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
1090
1158
  try {
1091
1159
  await unlink(fp)