@cocorograph/hub-agent 0.7.98 → 0.7.100

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.98",
3
+ "version": "0.7.100",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -2442,27 +2442,62 @@ export class ClaudeStreamBridge extends EventEmitter {
2442
2442
  * @returns {Array<{kind: string, cwd: string, session_id: string|null, idle_ms: number}>}
2443
2443
  */
2444
2444
  describeLiveSessions(cwd) {
2445
- const now = Date.now()
2445
+ return this._liveCandidatesForCwd(cwd, false).map(({ session, attached }) =>
2446
+ this._describeCandidate(session, attached),
2447
+ )
2448
+ }
2449
+
2450
+ /**
2451
+ * cwd 内の live セッション候補を `{session, attached}` の配列で返す共通スキャン。
2452
+ * this.sessions (attached=browser 接続中) と _liveBySession (attached=false=soft-detached の warm)
2453
+ * の **union** を走査し、両 Map に載り得る同一セッションは seen で重複排除する。attached は
2454
+ * 「this.sessions に在籍しているか」で決める。
2455
+ *
2456
+ * ⚠️ describeLiveSessions(計数) と sendToCwd/sendToStream(配送) をこの同一集合に揃えることで、
2457
+ * 従来「this.sessions のみ計数 / union 配送」の非対称が生んでいた undercount 由来のサイレント
2458
+ * 誤配送 (detached-busy な旧セッションへ配送しつつ計数上は1本に見える TOCTOU) を排除する。
2459
+ *
2460
+ * @param {string} [cwd] 指定時はその cwd のみ
2461
+ * @param {boolean} [requireSessionId=false] sessionId 未確定の fresh を除外するか
2462
+ * @returns {Array<{session: object, attached: boolean}>}
2463
+ */
2464
+ _liveCandidatesForCwd(cwd, requireSessionId = false) {
2446
2465
  const out = []
2447
2466
  const seen = new Set()
2448
- for (const session of this.sessions.values()) {
2449
- if (seen.has(session)) continue
2450
- seen.add(session)
2451
- if (cwd && session.cwd !== cwd) continue
2452
- let at = 0
2453
- for (const t of session.lastActivityByStream?.values?.() ?? []) {
2454
- if (t > at) at = t
2467
+ const collect = (iter, attached) => {
2468
+ for (const session of iter) {
2469
+ if (seen.has(session)) continue
2470
+ // reap 済み (_closed) セッションは _liveBySession に残留し得るため除外する。union 走査で
2471
+ // これを拾うと、reconcileWarmBounds で畳まれた古い session が宛先として復活してしまう。
2472
+ if (session._closed) continue
2473
+ if (cwd && session.cwd !== cwd) continue
2474
+ if (requireSessionId && !session.sessionId) continue
2475
+ seen.add(session)
2476
+ out.push({ session, attached })
2455
2477
  }
2456
- out.push({
2457
- kind: "claude",
2458
- cwd: session.cwd,
2459
- session_id: session.sessionId || null,
2460
- idle_ms: at ? now - at : -1,
2461
- })
2462
2478
  }
2479
+ // attached を先に走査して attached=true で登録、その後 warm を attached=false で補完する。
2480
+ collect(this.sessions.values(), true)
2481
+ collect(this._liveBySession.values(), false)
2463
2482
  return out
2464
2483
  }
2465
2484
 
2485
+ /** describeLiveSessions と同形の 1 セッション記述子 (sendToCwd/sendToStream の結果整形に共用)。 */
2486
+ _describeCandidate(session, attached) {
2487
+ const now = Date.now()
2488
+ let at = 0
2489
+ for (const t of session.lastActivityByStream?.values?.() ?? []) {
2490
+ if (t > at) at = t
2491
+ }
2492
+ return {
2493
+ kind: "claude",
2494
+ cwd: session.cwd,
2495
+ session_id: session.sessionId || null,
2496
+ idle_ms: at ? now - at : -1,
2497
+ attached,
2498
+ }
2499
+ }
2500
+
2466
2501
  /**
2467
2502
  * 指定 cwd で現在ライブな (browser 接続中、または detach 猶予内の) SDK チャット
2468
2503
  * session_id を返す。無ければ null。
@@ -2563,24 +2598,40 @@ export class ClaudeStreamBridge extends EventEmitter {
2563
2598
  * ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
2564
2599
  * src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
2565
2600
  *
2601
+ * ⚠️ 宛先解決 (2026-07-23, 統一再設計 1-1): 従来の「最終活性が最も新しいものを選ぶ」
2602
+ * ヒューリスティックを廃止し、**attached (前面=browser 接続中) 優先 + 曖昧は fail-safe** に
2603
+ * 変更した。union 候補のうち attached があれば attached 層、無ければ warm 層から選び、層内が
2604
+ * 1 本なら配送・複数なら推測せず ambiguous を返す。これにより「detached-busy な旧セッション A と
2605
+ * 同 cwd の新規 attached B が併存するとき、計数上は B 1本に見えつつ配送は活性の新しい A に
2606
+ * 落ちる」サイレント誤配送を、B (前面) への確定配送へ是正する (真に曖昧な稀ケースのみ
2607
+ * ambiguous で fail-safe。誤配送はしない)。返り値は sendToStream と同形の status オブジェクト。
2608
+ *
2566
2609
  * @param {string} cwd
2567
2610
  * @param {string} text
2568
- * @returns {boolean} 送信できたか (対象セッションが見つからなければ false)
2611
+ * @returns {{status:"delivered", session:object} | {status:"not_found"} | {status:"ambiguous", candidates:object[]}}
2569
2612
  */
2570
2613
  sendToCwd(cwd, text) {
2571
- if (!cwd || !text) return false
2572
- // P5 (T06570): warm (soft-detached) セッションも対象にするため union 走査へ委譲する
2573
- // (従来は this.sessions のみで、detach 済みだが生きているセッションへ sibling-inject できず
2574
- // no_live_session_for_cwd になっていた)。sessionId 未確定の fresh も対象にするため false。
2575
- const best = this._liveSessionForCwd(cwd, false)
2576
- if (!best) return false
2577
- best.sendMessage({ role: "user", content: text }).catch((err) => {
2614
+ if (!cwd || !text) return { status: "not_found" }
2615
+ // sessionId 未確定の fresh も配送対象にするため requireSessionId=false。
2616
+ const cands = this._liveCandidatesForCwd(cwd, false)
2617
+ if (cands.length === 0) return { status: "not_found" }
2618
+ const attached = cands.filter((c) => c.attached)
2619
+ // attached があれば attached 層のみ、無ければ warm 層で解決する (前面優先)。
2620
+ const tier = attached.length ? attached : cands
2621
+ if (tier.length > 1) {
2622
+ return {
2623
+ status: "ambiguous",
2624
+ candidates: tier.map((c) => this._describeCandidate(c.session, c.attached)),
2625
+ }
2626
+ }
2627
+ const { session, attached: att } = tier[0]
2628
+ session.sendMessage({ role: "user", content: text }).catch((err) => {
2578
2629
  this.logger?.error(
2579
2630
  { cwd, err: err?.message },
2580
2631
  "sendToCwd: sendMessage threw unexpectedly",
2581
2632
  )
2582
2633
  })
2583
- return true
2634
+ return { status: "delivered", session: this._describeCandidate(session, att) }
2584
2635
  }
2585
2636
 
2586
2637
  /**
@@ -2608,34 +2659,28 @@ export class ClaudeStreamBridge extends EventEmitter {
2608
2659
  */
2609
2660
  sendToStream(cwd, sessionId, text) {
2610
2661
  if (!cwd || !sessionId || !text) return { status: "not_found" }
2611
- const now = Date.now()
2612
- const seen = new Set()
2613
- const matches = []
2614
- for (const session of this.sessions.values()) {
2615
- if (seen.has(session)) continue
2616
- seen.add(session)
2617
- if (session.cwd !== cwd || session.sessionId !== sessionId) continue
2618
- matches.push(session)
2619
- }
2662
+ // union 走査 (2026-07-23, 統一再設計 1-1): warm (soft-detached) セッションも session_id
2663
+ // ピンの対象にする。従来は this.sessions のみで、detach 済みだが生きているセッションを
2664
+ // 明示 session_id で指しても not_found になっていた (describeLiveSessions/sendToCwd と非対称)。
2665
+ const matches = this._liveCandidatesForCwd(cwd, false).filter(
2666
+ (c) => c.session.sessionId === sessionId,
2667
+ )
2620
2668
  if (matches.length === 0) return { status: "not_found" }
2621
- const describe = (s) => {
2622
- let at = 0
2623
- for (const t of s.lastActivityByStream?.values?.() ?? []) {
2624
- if (t > at) at = t
2625
- }
2626
- return { kind: "claude", cwd: s.cwd, session_id: s.sessionId, idle_ms: at ? now - at : -1 }
2627
- }
2628
2669
  if (matches.length > 1) {
2629
2670
  // 同一 session_id の別 session が複数 = resume 競合。推測せず ambiguous (誤爆防止)。
2630
- return { status: "ambiguous", candidates: matches.map(describe) }
2671
+ return {
2672
+ status: "ambiguous",
2673
+ candidates: matches.map((c) => this._describeCandidate(c.session, c.attached)),
2674
+ }
2631
2675
  }
2632
- matches[0].sendMessage({ role: "user", content: text }).catch((err) => {
2676
+ const { session, attached } = matches[0]
2677
+ session.sendMessage({ role: "user", content: text }).catch((err) => {
2633
2678
  this.logger?.error(
2634
2679
  { session_id: sessionId, err: err?.message },
2635
2680
  "sendToStream: sendMessage threw unexpectedly",
2636
2681
  )
2637
2682
  })
2638
- return { status: "delivered", session: describe(matches[0]) }
2683
+ return { status: "delivered", session: this._describeCandidate(session, attached) }
2639
2684
  }
2640
2685
 
2641
2686
  /**
@@ -1407,19 +1407,30 @@ export class CodexStreamBridge extends EventEmitter {
1407
1407
  * ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
1408
1408
  * src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
1409
1409
  *
1410
+ * ⚠️ 返り値 (2026-07-23, 統一再設計 1-1): claude 版と契約を揃えるため boolean から status
1411
+ * オブジェクトへ変更した。codex は計数(describeLiveSessions)と配送が同一集合 (_liveSessions())
1412
+ * を見るため claude のような undercount 由来のサイレント誤配送は構造的に起きず、宛先選定は
1413
+ * 従来どおり「会話ターン境界時刻が最新」のヒューリスティックを維持する (挙動不変)。
1414
+ *
1410
1415
  * @param {string} cwd
1411
1416
  * @param {string} text
1412
- * @returns {boolean} 送信できたか (対象セッションが見つからなければ false)
1417
+ * @returns {{status:"delivered", session:object} | {status:"not_found"}}
1413
1418
  */
1414
1419
  sendToCwd(cwd, text) {
1415
- if (!cwd || !text) return false
1420
+ if (!cwd || !text) return { status: "not_found" }
1421
+ // 案B: distinct session で走査(多対一 Map の重複を除く)。
1422
+ const cands = this._liveSessions().filter((s) => s.cwd === cwd)
1423
+ // attached (前面=detach 未要求) 優先 (2026-07-23 統一再設計 1-1, cross-review 指摘4):
1424
+ // detach 済み warm セッションが lastTurnAtMs 上たまたま新しくても、attached な現行セッションが
1425
+ // あればそちらへ配送する (browser を離れた/畳まれかけの旧会話 ghost への誤配送を防ぐ)。
1426
+ // claude と違い codex は同一 cwd に複数 attached が併存し得る (別スレッドの新会話 / ターン
1427
+ // 実行中) ため fail-safe ambiguous にはせず、tier 内は会話ターン境界時刻が最新のものを選ぶ
1428
+ // (閲覧 keepalive で動く lastActivityMs は使わない = 見ているだけの古い会話が本命を追い越さない)。
1429
+ const attached = cands.filter((s) => !s.detachRequested)
1430
+ const tier = attached.length ? attached : cands
1416
1431
  let best = null
1417
1432
  let bestAt = -1
1418
- // 案B: distinct session で走査(多対一 Map の重複を除く)。
1419
- for (const session of this._liveSessions()) {
1420
- if (session.cwd !== cwd) continue
1421
- // 宛先選定の「最新」は会話ターン境界時刻で判定する(閲覧 keepalive で動く
1422
- // lastActivityMs を使うと「見ているだけの古い会話」が本命を追い越す)。
1433
+ for (const session of tier) {
1423
1434
  const at = session.lastTurnAtMs ?? session.lastActivityMs ?? 0
1424
1435
  if (at >= bestAt) {
1425
1436
  bestAt = at
@@ -1431,7 +1442,7 @@ export class CodexStreamBridge extends EventEmitter {
1431
1442
  { cwd, live_sessions: this.describeLiveSessions() },
1432
1443
  "codex sendToCwd: no live session for cwd (ask-sibling will fail)",
1433
1444
  )
1434
- return false
1445
+ return { status: "not_found" }
1435
1446
  }
1436
1447
  // detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
1437
1448
  // detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
@@ -1456,7 +1467,7 @@ export class CodexStreamBridge extends EventEmitter {
1456
1467
  )
1457
1468
  },
1458
1469
  )
1459
- return true
1470
+ return { status: "delivered", session: this._describe(best) }
1460
1471
  }
1461
1472
 
1462
1473
  /**
package/src/main.mjs CHANGED
@@ -2861,9 +2861,12 @@ const STREAMS_SYNC_GRACE_MS = 45_000
2861
2861
  * Plan ε: `agent.streams.sync.response` を受けて backend ↔ agent の現存
2862
2862
  * stream_id を能動同期する。
2863
2863
  *
2864
- * - local にあって backend にない → ptyBridge.detach で即時 kill (孤児)
2865
- * - backend にあって local にない → backend に pty.error を通知 (browser に
2866
- * "pty 死んでます" を表示するため)
2864
+ * - PTY: local にあって backend にない → ptyBridge.detach、逆差分 → pty.error
2865
+ * - Claude/Codex: mode 別 snapshot から外れた古い subscriber を grace 付きで掃除
2866
+ *
2867
+ * active_stream_ids だけを全件 PTY とみなすと、正常な Claude/Codex に
2868
+ * pty.error(stream_not_found_on_agent) を返して再 attach storm を誘発するため、
2869
+ * 新 backend の active_streams を優先する。
2867
2870
  *
2868
2871
  * dispatch から呼ぶ用のヘルパーだが、テストから直接叩けるよう export している。
2869
2872
  */
@@ -2871,6 +2874,32 @@ export function handleStreamsSyncResponse(msg, ctx) {
2871
2874
  const activeIds = new Set(
2872
2875
  Array.isArray(msg?.active_stream_ids) ? msg.active_stream_ids : [],
2873
2876
  )
2877
+ const activeByMode = { pty: new Set(), claude: new Set(), codex: new Set() }
2878
+ const classifiedIds = new Set()
2879
+ if (Array.isArray(msg?.active_streams)) {
2880
+ for (const item of msg.active_streams) {
2881
+ const streamId = item?.stream_id
2882
+ const mode = item?.mode
2883
+ const modeSet = activeByMode[mode]
2884
+ if (typeof streamId !== "string" || !streamId || !(modeSet instanceof Set)) continue
2885
+ modeSet.add(streamId)
2886
+ classifiedIds.add(streamId)
2887
+ }
2888
+ }
2889
+ // Blue-Green ローリング中に旧 BrowserConsumer の mode 無し応答が混ざった場合は
2890
+ // typed snapshot を authoritative とみなさない。既知 chat stream だけ除外して
2891
+ // legacy 互換を維持し、正常な Claude/Codex に ghost PTY error を返さない。
2892
+ const haveCompleteModeSnapshot =
2893
+ Array.isArray(msg?.active_streams)
2894
+ && classifiedIds.size === activeIds.size
2895
+ && Array.from(classifiedIds).every((id) => activeIds.has(id))
2896
+ const knownChatIds = new Set([
2897
+ ...Array.from(ctx.claudeBridge?.sessions?.keys?.() ?? []),
2898
+ ...Array.from(ctx.codexBridge?.sessions?.keys?.() ?? []),
2899
+ ])
2900
+ const activePtyIds = haveCompleteModeSnapshot
2901
+ ? activeByMode.pty
2902
+ : new Set(Array.from(activeIds).filter((id) => !knownChatIds.has(id)))
2874
2903
  const localIds = new Set(ctx.ptyBridge.list())
2875
2904
  // churn 退化スナップショット保護 (ack_lost 根治 / 2026-06-17 根因 rank3)。
2876
2905
  // backend は browser 群へ _STREAMS_SYNC_TIMEOUT_S(0.5s) だけ問い合わせて active set を
@@ -2881,7 +2910,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
2881
2910
  // 空 set は信用せず kill を一切行わない (真の孤児は 10min GC + maxStreams 上限が回収する)。
2882
2911
  const haveAuthoritativeActiveSet = activeIds.size > 0
2883
2912
  for (const sid of localIds) {
2884
- if (activeIds.has(sid)) continue
2913
+ if (activePtyIds.has(sid)) continue
2885
2914
  if (!haveAuthoritativeActiveSet) continue
2886
2915
  // grace guard: 直近 STREAMS_SYNC_GRACE_MS 以内に touch (attach/write/resize/pty 出力) された
2887
2916
  // stream は「browser が再接続中で 0.5s 集計に間に合わなかっただけ」の可能性が高い。猶予内は
@@ -2901,7 +2930,7 @@ export function handleStreamsSyncResponse(msg, ctx) {
2901
2930
  )
2902
2931
  ctx.ptyBridge.detach({ stream_id: sid })
2903
2932
  }
2904
- for (const sid of activeIds) {
2933
+ for (const sid of activePtyIds) {
2905
2934
  if (!localIds.has(sid)) {
2906
2935
  ctx.logger?.warn?.(
2907
2936
  { stream_id: sid, request_id: msg?.request_id },
@@ -2914,6 +2943,51 @@ export function handleStreamsSyncResponse(msg, ctx) {
2914
2943
  })
2915
2944
  }
2916
2945
  }
2946
+
2947
+ if (haveCompleteModeSnapshot) {
2948
+ // reconnect 集計は browser ごとの復帰順で一時的に部分集合になり得る。
2949
+ // 同じ mode の現役購読を1件も確認できない段階では、その mode 全体を掃除しない。
2950
+ if (activeByMode.claude.size > 0) {
2951
+ pruneInactiveChatSubscribers(
2952
+ ctx.claudeBridge,
2953
+ activeByMode.claude,
2954
+ "claude",
2955
+ ctx.logger,
2956
+ )
2957
+ }
2958
+ if (activeByMode.codex.size > 0) {
2959
+ pruneInactiveChatSubscribers(
2960
+ ctx.codexBridge,
2961
+ activeByMode.codex,
2962
+ "codex",
2963
+ ctx.logger,
2964
+ )
2965
+ }
2966
+ }
2967
+ }
2968
+
2969
+ /** backend の mode-aware snapshot に存在しない死 browser subscriber を除去する。
2970
+ * 再接続が 0.5s 集計に間に合わなかった場合に備え、直近活性が grace 内の stream は保護する。 */
2971
+ export function pruneInactiveChatSubscribers(bridge, activeIds, mode, logger, now = Date.now()) {
2972
+ if (!bridge?.sessions || typeof bridge.detach !== "function") return 0
2973
+ let removed = 0
2974
+ for (const session of new Set(bridge.sessions.values())) {
2975
+ for (const streamId of Array.from(session?.streamIds ?? [])) {
2976
+ if (activeIds.has(streamId)) continue
2977
+ const lastSeen = session?.lastActivityByStream?.get?.(streamId) ?? 0
2978
+ if (lastSeen > 0 && now - lastSeen < STREAMS_SYNC_GRACE_MS) continue
2979
+ bridge.detach({ stream_id: streamId })
2980
+ // Codex は live session の索引を最低1件残す不変条件があり、最後の1件では
2981
+ // detachRequested を立てるだけで streamIds からは消さない。実際に消えた件だけ数える。
2982
+ if (session.streamIds?.has?.(streamId)) continue
2983
+ removed += 1
2984
+ logger?.info?.(
2985
+ { stream_id: streamId, mode, idle_ms: lastSeen > 0 ? now - lastSeen : null },
2986
+ "inactive chat subscriber pruned after streams sync",
2987
+ )
2988
+ }
2989
+ }
2990
+ return removed
2917
2991
  }
2918
2992
 
2919
2993
  /**
@@ -3522,6 +3596,9 @@ async function dispatch(msg, ctx) {
3522
3596
  // 5s タイムアウトのような明示的な timer は持たず、再接続時に再度 request
3523
3597
  // が飛ぶので無害。
3524
3598
  handleStreamsSyncResponse(msg, ctx)
3599
+ // buffered pty/control event は browser の購読集合が確定してから再送する。
3600
+ // request_id は WsClient 側で現 reconnect 世代と照合され、古い応答は無視される。
3601
+ ctx.client.completeStreamsSync?.(msg)
3525
3602
  return
3526
3603
  case "claude.attach": {
3527
3604
  // Sprint G: stream-json モードで Claude Code を起動。
@@ -10,10 +10,15 @@
10
10
  * ファイル IPC 前例 (tui-permission-bridge.mjs 等、`/tmp/cockpit_*` + fs.watch +
11
11
  * atomic write) と同じパターンに揃える。
12
12
  *
13
- * ⚠️ セキュリティ設計 (2026-07-04 セッションでユーザー承認済み): この機構は
13
+ * ⚠️ セキュリティ設計 (2026-07-04 承認 / 2026-07-23 統一再設計 1-2 で緩和): この機構は
14
14
  * 「生きている自律エージェント (シェル/API/MCP ツールへのフルアクセス権を持つ)
15
15
  * へ外部プロセスから新規指示を注入する」という強い書き込み権限を持つため、
16
- * `src/sibling-token.mjs` の cwd スコープ capability トークンによる検証を必須とする。
16
+ * `src/sibling-token.mjs` の capability トークンによる検証を必須とする。
17
+ * 認可粒度は当初「ターゲット cwd 所有」だったが、claude↔claude / codex↔codex を含む
18
+ * クロス cwd 連携を可能にするため「**いずれかの cwd の有効トークン所有 (= 同一ユーザーの
19
+ * spawn 済みセッション)**」へ緩和した (`verifySiblingTokenAny`)。無認証プロセス (npm
20
+ * postinstall 等) はどの cwd のトークンも持たないため排除される核心は維持。ターゲット
21
+ * 解決は引き続き要求の cwd でスコープする (誤 cwd 着弾・別 workspace 情報漏洩を防ぐ)。
17
22
  * トークンが一致しない要求は無視し (実行せず削除)、warn ログのみ残す。
18
23
  *
19
24
  * 契約: request file `<request_id>.json` は op で 3 形態 (2026-07-19 / T06271):
@@ -25,6 +30,8 @@
25
30
  * 新 agent だけが処理する (probe を挟まず単一リクエストで TOCTOU を排除)。
26
31
  * 宛先は session_id(claude) / stream_id・thread_id(codex) を cwd 一致下に確定解決。
27
32
  * - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
33
+ * - 全一覧: `{op:"list-all", token}` → `{ok, live_sessions}` (全 cwd・両 CLI。クロス cwd
34
+ * 宛先探索用、2026-07-23。cwd/target_cli_kind 不要、token のみ必須)。
28
35
  * 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
29
36
  * `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?}` を書く。
30
37
  */
@@ -33,7 +40,7 @@ import { watch } from "node:fs"
33
40
  import { mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
34
41
  import path from "node:path"
35
42
 
36
- import { verifySiblingToken } from "./sibling-token.mjs"
43
+ import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
37
44
 
38
45
  export const SIBLING_REQUESTS_DIR =
39
46
  process.env.COCKPIT_SIBLING_REQUESTS_DIR || "/tmp/cockpit_sibling_requests"
@@ -117,6 +124,7 @@ export class SiblingInjectBridge {
117
124
  // 未知フィールドを無視して sendToCwd 推測注入する事故が **単一リクエストで原理的に
118
125
  // 起きない** (probe→注入の TOCTOU を排除)。
119
126
  const { op, cwd, target_cli_kind, text, message, token, target_session_id } = body || {}
127
+ const isListAll = op === "list-all"
120
128
  const isList = op === "list"
121
129
  const isExplicit = op === "inject"
122
130
  let result
@@ -126,12 +134,47 @@ export class SiblingInjectBridge {
126
134
  : isExplicit
127
135
  ? !target_session_id || !message // 明示は id と message 必須 (空指定 fail-open を防ぐ)
128
136
  : !text // 通常は top-level text 必須 (=旧契約)
129
- if (!cwd || badKind || badBody) {
137
+ if (isListAll) {
138
+ // op:"list-all" (2026-07-23 統一再設計 1-2: クロス cwd 宛先探索): 全 cwd・両 CLI の
139
+ // ライブセッションを列挙する。cwd / target_cli_kind / 本文は不要 (token のみ必須)。
140
+ // 認可は verifySiblingTokenAny (同一ユーザーの spawn 済みセッションであれば全 cwd を
141
+ // 見渡せる。orchestrate リーダーが別プロジェクトの sub の宛先アドレスを得るため)。
142
+ if (!token) {
143
+ result = { ok: false, error: "invalid_request" }
144
+ } else if (!verifySiblingTokenAny(token)) {
145
+ this.logger?.warn({ request_id }, "sibling-inject: invalid sibling token (list-all)")
146
+ result = { ok: false, error: "invalid_token" }
147
+ } else {
148
+ result = {
149
+ ok: true,
150
+ live_sessions: [
151
+ ...(this.claudeBridge?.describeLiveSessions?.() ?? []),
152
+ ...(this.codexBridge?.describeLiveSessions?.() ?? []),
153
+ ],
154
+ }
155
+ }
156
+ } else if (!cwd || badKind || badBody) {
130
157
  result = { ok: false, error: "invalid_request" }
131
- } else if (!verifySiblingToken(cwd, token)) {
132
- this.logger?.warn({ cwd, request_id }, "sibling-inject: token mismatch, request denied")
158
+ } else if (!verifySiblingTokenAny(token)) {
159
+ // 2026-07-23 統一再設計 1-2: 認可を「ターゲット cwd 所有」→「いずれかの cwd の有効トークン
160
+ // 所有 (= 同一ユーザーの spawn 済みセッション)」へ緩和し、クロス cwd 連携を解禁する。
161
+ // cwd は引き続きターゲット解決のスコープとして使う (誤 cwd 着弾・情報漏洩は解決側で担保)。
162
+ this.logger?.warn({ cwd, request_id }, "sibling-inject: invalid sibling token, request denied")
133
163
  result = { ok: false, error: "invalid_token" }
134
164
  } else {
165
+ // クロス cwd 監査 (2026-07-23, cross-review round2 minor): 認可は同一ユーザーの any-cwd
166
+ // トークンで通るが、注入先の cwd がトークン発行元の cwd と異なる (= クロス cwd 注入) 場合は
167
+ // 「どの cwd のセッションから来たか」を info ログに残す (認可判定には使わない、追跡用)。
168
+ // op:list / list-all は非破壊なので対象外、注入系 (op:inject / 通常) のみ記録する。
169
+ if (!isList) {
170
+ const fromCwd = siblingTokenCwd(token)
171
+ if (fromCwd && fromCwd !== cwd) {
172
+ this.logger?.info?.(
173
+ { request_id, from_cwd: fromCwd, to_cwd: cwd, target_cli_kind, op: op || "normal" },
174
+ "sibling-inject: cross-cwd injection (audit)",
175
+ )
176
+ }
177
+ }
135
178
  const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
136
179
  if (isList) {
137
180
  // 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
@@ -177,21 +220,27 @@ export class SiblingInjectBridge {
177
220
  }
178
221
  }
179
222
  } else {
180
- // 宛先省略 (通常): 同一 cwd の生きたセッション数で分岐。
181
- // 0 本 → no_live_session / 1 本 → 注入 / 複数 → 黙って推測せず ambiguous_target。
182
- const sameCwd = bridge?.describeLiveSessions?.(cwd) ?? []
183
- if (sameCwd.length > 1) {
223
+ // 宛先省略 (通常): sendToCwd が union 候補から attached 優先で 1 本に確定解決する。
224
+ // status:"delivered" → 注入 / "ambiguous" → 複数候補で fail-safe / "not_found" → 0 本。
225
+ //
226
+ // ⚠️ アトミック化 (2026-07-23, 統一再設計 1-1): 旧実装は describeLiveSessions.length の
227
+ // 計数ゲートと sendToCwd の配送を分離しており、warm セッションを取りこぼす計数
228
+ // (claude: this.sessions のみ) をすり抜けて配送だけが warm へ落ちる TOCTOU
229
+ // (サイレント誤配送 + delivered_to に実配送先でない sameCwd[0] を報告する成功偽装) が
230
+ // あった。sendToCwd を status 返却にして解決と配送を 1 呼び出しに統合し、delivered_to は
231
+ // **実配送先 (r.session)** を返す。
232
+ const r = bridge?.sendToCwd?.(cwd, text) ?? { status: "not_found" }
233
+ if (r.status === "delivered") {
234
+ result = { ok: true, delivered_to: r.session ?? null }
235
+ } else if (r.status === "ambiguous") {
184
236
  result = {
185
237
  ok: false,
186
238
  error: "ambiguous_target",
187
- candidates: sameCwd,
188
- live_sessions: sameCwd,
239
+ candidates: r.candidates,
240
+ live_sessions: r.candidates,
189
241
  }
190
242
  } else {
191
- const sent = bridge?.sendToCwd?.(cwd, text) ?? false
192
- result = sent
193
- ? { ok: true, delivered_to: sameCwd[0] ?? null }
194
- : { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
243
+ result = { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
195
244
  }
196
245
  }
197
246
  }
@@ -56,6 +56,50 @@ export function verifySiblingToken(cwd, token) {
56
56
  return typeof expected === "string" && expected === token
57
57
  }
58
58
 
59
+ /**
60
+ * 提示されたトークンが **いずれかの cwd** の有効な sibling トークンと一致するか検証する
61
+ * (2026-07-23, 統一再設計 1-2: クロス cwd 連携の認可緩和)。
62
+ *
63
+ * 従来の `verifySiblingToken(cwd, token)` は「ターゲット cwd 自身のトークン」を要求するため
64
+ * 同一 cwd 内の相互依頼しか通せなかった。claude↔claude / codex↔codex を含むクロス cwd 連携
65
+ * (orchestrate のリーダー→別プロジェクトのサブ等) を可能にするため、認可の粒度を
66
+ * 「**ターゲット cwd 所有**」から「**この hub-agent が spawn したいずれかのセッション所有**
67
+ * (= 同一ユーザー)」へ緩和する。
68
+ *
69
+ * ⚠️ トレードオフ (ユーザー承認済み 2026-07-23): 注入先の cwd 境界を越えられるようになるが、
70
+ * hub-agent は 1 ユーザー専用デーモンであり、全 cwd のセッションは同一ユーザー自身のもの
71
+ * なので信頼境界を跨がない。守るべき核心 = 「hub-agent が env にのみ渡したトークンを持たない
72
+ * 無認証プロセス (npm postinstall 等) の注入を防ぐ」は、**いずれかの cwd の有効トークン一致**を
73
+ * 必須にすることで維持される (無関係プロセスはどの cwd のトークンも持てない)。ターゲット
74
+ * セッションの解決自体は呼び出し側が渡す cwd で引き続きスコープされる (誤 cwd 着弾防止・
75
+ * 別 workspace の情報漏洩防止は sendToStream/_diagnose 側で担保)。
76
+ *
77
+ * @param {string} token
78
+ * @returns {boolean}
79
+ */
80
+ export function verifySiblingTokenAny(token) {
81
+ if (!token || typeof token !== "string") return false
82
+ for (const expected of tokensByCwd.values()) {
83
+ if (expected === token) return true
84
+ }
85
+ return false
86
+ }
87
+
88
+ /**
89
+ * 提示されたトークンがどの cwd に発行されたものかを逆引きする (2026-07-23, クロス cwd 監査用)。
90
+ * 一致する cwd が無ければ null。verifySiblingTokenAny でクロス cwd 注入を許した際、
91
+ * 「どの cwd のセッションから来たか」を監査ログに残すために使う (認可の可否判定には使わない)。
92
+ * @param {string} token
93
+ * @returns {string|null}
94
+ */
95
+ export function siblingTokenCwd(token) {
96
+ if (!token || typeof token !== "string") return null
97
+ for (const [cwd, expected] of tokensByCwd.entries()) {
98
+ if (expected === token) return cwd
99
+ }
100
+ return null
101
+ }
102
+
59
103
  /** テスト用: トークンストアを全クリアする。 */
60
104
  export function _resetSiblingTokens() {
61
105
  tokensByCwd.clear()
package/src/ws-client.mjs CHANGED
@@ -66,8 +66,9 @@ const PTY_BUFFER_MAX_AGE_MS = 30_000
66
66
  // `claude.jsonl.event ... "ws send skipped (not open)"` のドロップを観測)。
67
67
  // - pty.input.ack: 喪失すると browser の pendingAcks が永久滞留し「送信待ち(未配信)」が
68
68
  // 消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
69
- // - claude.jsonl.event / claude.event / session.event: 喪失すると新規セッションの
70
- // 楽観バブルが本物へ昇格できず「生成は始まったがバブルが出ない」(S5)。
69
+ // - claude/codex の event / ready / exit / error / permission / input ack,nack:
70
+ // 喪失すると新規セッションの楽観バブルが本物へ昇格できず、再 attach も固着する。
71
+ // - claude.jsonl.event / session.event: TUI と一覧の確定状態を復元する。
71
72
  // - pty.exit / pty.ready / pty.error: stream ライフサイクルの節目。落とすと browser の
72
73
  // 再 attach/復帰判断が狂う。
73
74
  // pty.data とは別バッファにするのは、高頻度な pty.data の洪水で低頻度な ack/event が
@@ -80,11 +81,29 @@ const BUFFERED_CTRL_TYPES = new Set([
80
81
  "pty.error",
81
82
  "claude.jsonl.event",
82
83
  "claude.event",
84
+ "claude.ready",
85
+ "claude.exit",
86
+ "claude.error",
87
+ "claude.permission.request",
88
+ "claude.permission.cancel",
89
+ "claude.input.ack",
90
+ "claude.input.nack",
91
+ "codex.event",
92
+ "codex.ready",
93
+ "codex.exit",
94
+ "codex.error",
95
+ "codex.permission.request",
96
+ "codex.input.ack",
97
+ "codex.input.nack",
83
98
  "session.event",
84
99
  ])
85
100
  // 制御/イベントバッファの上限。低頻度なので frames は小さめ、age は pty と揃える。
86
101
  const CTRL_BUFFER_MAX_FRAMES = 200
87
102
  const CTRL_BUFFER_MAX_AGE_MS = 30_000
103
+ // reconnect 後の buffered event は backend が browser の active stream 集合を
104
+ // 回収し終えるまで流さない。通常は 0.5s で応答する。旧 backend 互換のため 2s で
105
+ // barrier を解除するが、通常時の送受信経路には一切待ちを追加しない。
106
+ const STREAMS_SYNC_BARRIER_TIMEOUT_MS = 2_000
88
107
 
89
108
  /** SDK-P2 (2026-07-08): claude.event が「ephemeral な生成中 delta」かどうか。
90
109
  * stream_event の content_block_delta (text_delta / thinking_delta) は高頻度で流れる
@@ -97,6 +116,12 @@ function isEphemeralDeltaEvent(obj) {
97
116
  return ev.event?.type === "content_block_delta"
98
117
  }
99
118
 
119
+ /** Codex App Server の item/agentMessage/delta 等は再水和可能な高頻度delta。 */
120
+ function isEphemeralCodexDeltaEvent(obj) {
121
+ const method = obj?.event?.method
122
+ return typeof method === "string" && method.toLowerCase().endsWith("/delta")
123
+ }
124
+
100
125
  export class WsClient extends EventEmitter {
101
126
  /**
102
127
  * @param {{ hub_url: string, agent_id: string, agent_token: string }} config
@@ -149,6 +174,10 @@ export class WsClient extends EventEmitter {
149
174
  // 制御/イベント系 (ack / jsonl.event 等) の outbound buffer。pty.data とは別管理で、
150
175
  // 高頻度 pty.data の洪水に evict されないようにする (機構③)。entry: { obj, ts }
151
176
  this.ctrlOutboundBuffer = []
177
+ // reconnect 世代ごとの stream sync barrier。古い request_id の遅延応答で
178
+ // 現接続の buffer を flush しないよう request_id を照合する。
179
+ this.pendingStreamsSyncRequestId = null
180
+ this.streamsSyncBarrierTimer = null
152
181
  // 直前の close 原因が CF/origin の 5xx か。次回 backoff の下限を引き上げる。
153
182
  this.lastCloseWas5xx = false
154
183
  // 接続が STABLE_CONNECTION_MS 維持できたら backoff/5xx フラグをリセットする
@@ -190,6 +219,7 @@ export class WsClient extends EventEmitter {
190
219
  })
191
220
 
192
221
  ws.on("close", (code, reason) => {
222
+ this._clearStreamsSyncBarrier()
193
223
  this._clearStableReset()
194
224
  this._stopHeartbeat()
195
225
  this._stopBundleWatcher()
@@ -236,11 +266,8 @@ export class WsClient extends EventEmitter {
236
266
  // 1〜2 秒間隔の再接続ループに陥らない。
237
267
  this._armStableReset()
238
268
  this.logger?.info("ws open")
239
- // 切断中に積んだ pty.data を flush。hello より先に送ると backend で stream
240
- // 未登録のため unknown_stream error になる可能性があるが、hello の応答待ち
241
- // 同期は backend が承認するため hello 送信後の同期 send_json で問題ない。
242
- // ただし flush は hello 直後ではなく streams.sync.request の後に置く方が
243
- // 安全 (sync 完了で stream_id がサーバに復元される想定)。
269
+ // 切断中に積んだ frame は hello 後も即時 flush しない。streams.sync.response で
270
+ // browser の購読集合が復元されたことを確認してから、現役 stream 宛だけ再送する。
244
271
  // hello 前に最新の bundle version / CLI 別ステータスを読み直す (再接続時の鮮度確保)
245
272
  this._refreshBundleVersion()
246
273
  this._refreshBundleStatus()
@@ -260,12 +287,12 @@ export class WsClient extends EventEmitter {
260
287
  // ptyBridge.list() と差分を取って kill する。request_id は uuid で
261
288
  // 重複排除。backend が古い (= Plan ε 未対応) なら "unknown_type" error が
262
289
  // 返るだけで動作には影響しない。
290
+ const streamsSyncRequestId = randomUUID()
263
291
  this._sendJson({
264
292
  type: "agent.streams.sync.request",
265
- request_id: randomUUID(),
293
+ request_id: streamsSyncRequestId,
266
294
  })
267
- this._flushPtyBuffer()
268
- this._flushCtrlBuffer()
295
+ this._armStreamsSyncBarrier(streamsSyncRequestId)
269
296
  this._startHeartbeat()
270
297
  this._startBundleWatcher()
271
298
  this.emit("open")
@@ -278,8 +305,13 @@ export class WsClient extends EventEmitter {
278
305
  * buffer しない (warn のみ)。ack / event 系は 1 件ずつ意味を持つので別バッファに保持する。
279
306
  */
280
307
  send(obj) {
281
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
282
- const type = obj?.type
308
+ const type = obj?.type
309
+ const wsOpen = this.ws?.readyState === WebSocket.OPEN
310
+ // sync barrier 中に新しく発生した frame も同じ buffer へ積み、切断中 frame より
311
+ // 先に送って時系列を逆転させない。heartbeat 等の非 buffer 制御系は通常どおり送る。
312
+ const holdForStreamsSync = Boolean(this.pendingStreamsSyncRequestId)
313
+ && (type === "pty.data" || BUFFERED_CTRL_TYPES.has(type))
314
+ if (!wsOpen || holdForStreamsSync) {
283
315
  if (type === "pty.data") {
284
316
  this._bufferPtyData(obj)
285
317
  return false
@@ -294,11 +326,16 @@ export class WsClient extends EventEmitter {
294
326
  if (type === "claude.event" && isEphemeralDeltaEvent(obj)) {
295
327
  return false
296
328
  }
329
+ if (type === "codex.event" && isEphemeralCodexDeltaEvent(obj)) {
330
+ return false
331
+ }
297
332
  this._bufferCtrl(obj)
298
333
  return false
299
334
  }
300
- this.logger?.warn({ type }, "ws send skipped (not open)")
301
- return false
335
+ if (!wsOpen) {
336
+ this.logger?.warn({ type }, "ws send skipped (not open)")
337
+ return false
338
+ }
302
339
  }
303
340
  return this._sendJson(obj)
304
341
  }
@@ -389,9 +426,55 @@ export class WsClient extends EventEmitter {
389
426
  )
390
427
  }
391
428
 
429
+ /** reconnect stream sync の完了通知。現接続の request_id と一致し、browser の
430
+ * active stream が1件以上復元されたときだけ buffered frame を flush する。
431
+ * @returns {boolean} 現在の barrier を解除した場合 true。 */
432
+ completeStreamsSync(msg) {
433
+ const requestId = msg?.request_id
434
+ if (!requestId || requestId !== this.pendingStreamsSyncRequestId) return false
435
+
436
+ const activeIds = new Set(
437
+ Array.isArray(msg?.active_stream_ids)
438
+ ? msg.active_stream_ids.filter((id) => typeof id === "string" && id)
439
+ : [],
440
+ )
441
+ // backend の 0.5s 集計窓では、browser がまだ再接続しておらず空 snapshot が
442
+ // 返ることがある。空を authoritative として flush/drop すると live stream の
443
+ // frame を失うため、2s compatibility timeout まで barrier を維持する。
444
+ if (activeIds.size === 0) return false
445
+ this._clearStreamsSyncBarrier()
446
+ this._flushPtyBuffer()
447
+ this._flushCtrlBuffer()
448
+ return true
449
+ }
450
+
451
+ _armStreamsSyncBarrier(requestId) {
452
+ this._clearStreamsSyncBarrier()
453
+ this.pendingStreamsSyncRequestId = requestId
454
+ this.streamsSyncBarrierTimer = setTimeout(() => {
455
+ if (this.pendingStreamsSyncRequestId !== requestId) return
456
+ this.pendingStreamsSyncRequestId = null
457
+ this.streamsSyncBarrierTimer = null
458
+ this.logger?.warn?.(
459
+ { request_id: requestId },
460
+ "streams sync barrier timed out; flushing buffered frames for legacy backend",
461
+ )
462
+ this._flushPtyBuffer()
463
+ this._flushCtrlBuffer()
464
+ }, STREAMS_SYNC_BARRIER_TIMEOUT_MS)
465
+ this.streamsSyncBarrierTimer.unref?.()
466
+ }
467
+
468
+ _clearStreamsSyncBarrier() {
469
+ if (this.streamsSyncBarrierTimer) clearTimeout(this.streamsSyncBarrierTimer)
470
+ this.streamsSyncBarrierTimer = null
471
+ this.pendingStreamsSyncRequestId = null
472
+ }
473
+
392
474
  /** Reconnect を止めて切断する。 */
393
475
  stop() {
394
476
  this.stopped = true
477
+ this._clearStreamsSyncBarrier()
395
478
  this._clearStableReset()
396
479
  this._stopHeartbeat()
397
480
  this._stopBundleWatcher()