@cocorograph/hub-agent 0.7.101 → 0.7.103

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.101",
3
+ "version": "0.7.103",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -314,7 +314,9 @@ const STALE_IDLE_GUARD_MS = 5000
314
314
  /**
315
315
  * 1 stream に対応する Claude セッション (per-message query モデル)。
316
316
  */
317
- class ClaudeStreamSession {
317
+ // export: 単体テストから直接インスタンス化して純ロジック (連携カード source タグの
318
+ // record/remove/tag 等) を検証するため。実運用の生成は ClaudeStreamBridge が行う。
319
+ export class ClaudeStreamSession {
318
320
  constructor({
319
321
  stream_id,
320
322
  cwd,
@@ -431,6 +433,14 @@ class ClaudeStreamSession {
431
433
  /** 改修3: 直近 browser へ通知した queue 署名 (件数 + id 列)。変化時のみ emit する。
432
434
  * 空キューの署名 ("0:") で初期化し、空→空の冗長 emit を抑止する。 */
433
435
  this._lastEmittedQueueSig = "0:"
436
+
437
+ /** 連携カード種別タグ (Phase 2, 2026-07-23): ask-sibling/orchestrate 等の **別セッションからの
438
+ * 注入** を、受け手画面で「青い自分バブル」ではなく専用の連携依頼カードで表示させるため、
439
+ * 注入本文 (text) を相関キーに source ('peer'|'orchestrator') を stash する FIFO。
440
+ * sendToCwd/sendToStream が注入直前に記録し、直後に SDK が返す user echo (type:"user") の
441
+ * 本文一致で _emit が消費してタグ付けする。人間の実入力や tool_result は本 set に無いので
442
+ * タグされない (誤タグ防止)。echo が来ない稀ケースの無限成長は上限で古い順に切る。 */
443
+ this._pendingInjectSources = []
434
444
  /** ultracode (0.6.28): 常駐 query へ現在適用済みの ultracode 状態。ターン単位の
435
445
  * ワンショット適用を applyFlagSettings で reconcile する際の差分判定に使う。
436
446
  * query を (再)起動すると flag settings は既定に戻るため、_runResidentQuery 冒頭で
@@ -519,6 +529,41 @@ class ClaudeStreamSession {
519
529
 
520
530
  /** マスク済みイベントを onEvent へ流す共通経路。秘匿情報 / 非公開スキルプロンプト本文を
521
531
  * 表示・WS 配信前に伏せる。マスク失敗時は素のイベントを流す (表示が止まるより素通し優先)。 */
532
+ /** 連携カード種別 (Phase 2): 注入本文と source を FIFO に控える。sendToCwd/sendToStream が
533
+ * session.sendMessage() を呼ぶ直前に記録し、**drain 昇格時に _emitQueueState が started 本文
534
+ * 一致で消費**して startedSources として frontend へ載せる。
535
+ *
536
+ * ⚠️ claude は SDK が user メッセージを live echo しない (reducer set_queue コメント参照)。
537
+ * user バブルは queue_state の started 昇格で作られるため、当初の _emit(user echo) タグ方式は
538
+ * claude で発火せず注入が青バブルのままだった (2026-07-23 実機で判明)。消費点を _emitQueueState
539
+ * に移した。相関は「注入本文の完全一致」(footer 込みで一意性が高い)。ID メタ伝播が堅牢だが
540
+ * started は本文しか持たないため本文相関を採る。衝突リスクは①送信失敗時の stale → _removeInjectSource
541
+ * で handle 同定除去、②未 drain の無限成長 → 32 件上限、で最小化。衝突時も誤タグ止まりで安全。
542
+ * @param {string} text 注入本文 (drain 時の started 本文と一致する)
543
+ * @param {'peer'|'orchestrator'|undefined} source
544
+ * @returns {object|null} この entry の固有 handle (identity)。配送失敗時に _removeInjectSource へ
545
+ * 渡して**その entry だけ**を除去する。無効な source/text では null。 */
546
+ _recordInjectSource(text, source) {
547
+ if ((source !== "peer" && source !== "orchestrator") || !text) return null
548
+ const entry = { text, source }
549
+ this._pendingInjectSources.push(entry)
550
+ // echo が来ない稀ケース (注入直後にセッション消失等) の無限成長を防ぐ。連携注入は低頻度
551
+ // なので 32 件で十分 (古い順に切る)。
552
+ if (this._pendingInjectSources.length > 32) this._pendingInjectSources.shift()
553
+ return entry
554
+ }
555
+
556
+ /** 連携カード種別 (Phase 2, cross-review round1 major#1 / round2 major): 注入 (sendMessage) が
557
+ * 配送失敗したときに、record が返した **その entry だけ**を identity (indexOf) で取り消す。
558
+ * ⚠️ text で消してはいけない (round2 指摘): 同一本文を並行注入し後発だけ失敗すると、text 先頭
559
+ * 一致は先発の成功 entry を誤って消し、先発 echo が後発 source で誤タグされる。handle 同定なら
560
+ * 失敗した当該 entry のみ除去される。既に消費/cap 済みなら indexOf=-1 で no-op。 */
561
+ _removeInjectSource(handle) {
562
+ if (!handle) return
563
+ const idx = this._pendingInjectSources.indexOf(handle)
564
+ if (idx !== -1) this._pendingInjectSources.splice(idx, 1)
565
+ }
566
+
522
567
  _emit(event) {
523
568
  if (!this.onEvent) return
524
569
  let out = event
@@ -957,6 +1002,9 @@ class ClaudeStreamSession {
957
1002
  // 初回ターンの ultracode は起動時 options.settings に焼き込むため、起動前 reconcile
958
1003
  // (applyFlagSettings) のための空窓も不要。
959
1004
  if (!this._residentQuery || this._residentQueryReturned) {
1005
+ // Phase 2 (major#1): resident fresh start は started を出さないため、注入なら source 付きで
1006
+ // 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
1007
+ this._maybeEmitInjectStarted(prompt)
960
1008
  this._startResidentTurnWithInput(toSDKUserMessage(prompt), ultracode)
961
1009
  return { accepted: true }
962
1010
  }
@@ -969,6 +1017,11 @@ class ClaudeStreamSession {
969
1017
  ultracode,
970
1018
  )
971
1019
  if (outcome === "closed") return { accepted: false, reason: "queue_dead" }
1020
+ // Phase 2 (round2 major): 起動済み・idle な resident query への delivery は
1021
+ // _reconcileThenDeliver 内で pushed (inputQueue.push) / restarted
1022
+ // (_startResidentTurnWithInput) のどちらも started を出さない。注入なら source 付き
1023
+ // started をここで先出しして昇格バブルをカード化する (通常メッセージは no-op)。
1024
+ this._maybeEmitInjectStarted(prompt)
972
1025
  return { accepted: true }
973
1026
  }
974
1027
 
@@ -989,6 +1042,9 @@ class ClaudeStreamSession {
989
1042
  this._emitQueueState()
990
1043
  return { accepted: true }
991
1044
  }
1045
+ // Phase 2 (major#1): per-message idle 直接実行も started を出さないため、注入なら source 付きで
1046
+ // 昇格バブルを作るよう started を先出しする (通常メッセージは no-op)。
1047
+ this._maybeEmitInjectStarted(prompt)
992
1048
  // ack はターン完走を待たず「起動できたか」で返すため、フル実行の Promise を
993
1049
  // 呼び出し元へ伝播させない (fire-and-forget)。例外は _runPerMessage 内部で
994
1050
  // 握っているため通常は reject しないが、念のため warn だけ残す。
@@ -1235,6 +1291,20 @@ class ClaudeStreamSession {
1235
1291
  // force のときは再アタッチ snapshot なので重複チェックを完全にバイパスする。
1236
1292
  if (!force && started.length === 0 && sig === this._lastEmittedQueueSig) return
1237
1293
  this._lastEmittedQueueSig = sig
1294
+ // 連携カード種別 (Phase 2, 2026-07-23 修正): **claude は SDK が user メッセージを live echo
1295
+ // しない** (reducer set_queue のコメント参照)。ゆえに user バブルは queue_state の started
1296
+ // (drain 昇格) から作られる。当初 _emit の user echo にタグする実装は claude では発火せず
1297
+ // (dead code)、注入が青バブルのままだった (実機確認)。正しくは started 各テキストの source を
1298
+ // ここで _pendingInjectSources から引いて startedSources として載せ、frontend が昇格バブルへ
1299
+ // source を付ける。started は全 drain 経路 (_drainPending / resident / per-message finally) が
1300
+ // 通る funnel なのでここ 1 箇所で全経路をカバーする。注入本文 (footer 込み) の完全一致で消費。
1301
+ const startedSources = started.map((text) => {
1302
+ const idx = this._pendingInjectSources.findIndex((p) => p.text === text)
1303
+ if (idx === -1) return null
1304
+ const { source } = this._pendingInjectSources[idx]
1305
+ this._pendingInjectSources.splice(idx, 1)
1306
+ return source
1307
+ })
1238
1308
  try {
1239
1309
  // messages は全文を載せる。frontend は実行開始 (drain) 時にこれを user バブルへ
1240
1310
  // 昇格させるため、ここで切り詰めると本文が欠ける。チップの省略表示は CSS 側で行う。
@@ -1245,12 +1315,25 @@ class ClaudeStreamSession {
1245
1315
  messages: this._pendingMessages.map((m) => m.text),
1246
1316
  items: this._pendingMessages.map((m) => ({ id: m.id, text: m.text })),
1247
1317
  started,
1318
+ // started と parallel。連携注入なら 'peer'|'orchestrator'、通常は null。
1319
+ startedSources,
1248
1320
  })
1249
1321
  } catch {
1250
1322
  /* ignore */
1251
1323
  }
1252
1324
  }
1253
1325
 
1326
+ /** 連携カード種別 (Phase 2, cross-review major#1): **idle 直接送信経路**用。busy→queue→drain は
1327
+ * _emitQueueState([text]) を通るが、idle 直接実行 (resident fresh start / per-message 即時) は
1328
+ * started を出さないため、注入の source が startedSources に載らずカード化されない。ここで
1329
+ * 「その本文が注入 (source 記録済み) のときだけ」started を 1 件 emit して昇格バブルを source 付き
1330
+ * で作る。通常メッセージ (source 無し) は emit せず従来経路 (optimistic 等) に任せる = 挙動不変。 */
1331
+ _maybeEmitInjectStarted(prompt) {
1332
+ if (this._pendingInjectSources.some((p) => p.text === prompt)) {
1333
+ this._emitQueueState([prompt])
1334
+ }
1335
+ }
1336
+
1254
1337
  /** 空 InputQueue 窓封鎖 (T06118): fresh start する resume 常駐 query を「入力を先に積んで
1255
1338
  * から起動」する (push 先行)。fresh な InputQueue を作り、実ユーザーメッセージを push して
1256
1339
  * から query を起動するため、「resume + 空入力」の窓で SDK が自動継続ターン (Continue from
@@ -2610,7 +2693,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2610
2693
  * @param {string} text
2611
2694
  * @returns {{status:"delivered", session:object} | {status:"not_found"} | {status:"ambiguous", candidates:object[]}}
2612
2695
  */
2613
- sendToCwd(cwd, text) {
2696
+ sendToCwd(cwd, text, source) {
2614
2697
  if (!cwd || !text) return { status: "not_found" }
2615
2698
  // sessionId 未確定の fresh も配送対象にするため requireSessionId=false。
2616
2699
  const cands = this._liveCandidatesForCwd(cwd, false)
@@ -2625,12 +2708,25 @@ export class ClaudeStreamBridge extends EventEmitter {
2625
2708
  }
2626
2709
  }
2627
2710
  const { session, attached: att } = tier[0]
2628
- session.sendMessage({ role: "user", content: text }).catch((err) => {
2629
- this.logger?.error(
2630
- { cwd, err: err?.message },
2631
- "sendToCwd: sendMessage threw unexpectedly",
2632
- )
2633
- })
2711
+ // 連携カード種別 (Phase 2): 配送先が確定した後に記録する (ambiguous/not_found では記録しない)。
2712
+ // handle を控え、失敗時にその entry だけを除去する (round2: text 除去は並行同一本文で誤消費)。
2713
+ const injectHandle = session._recordInjectSource?.(text, source)
2714
+ session
2715
+ .sendMessage({ role: "user", content: text })
2716
+ .then((r) => {
2717
+ // Phase 2 (major#3): sendMessage は throw せず {accepted:false} で失敗を返す経路がある
2718
+ // (queue_dead / empty_message / pre-canceled)。この場合も echo/started は来ないので
2719
+ // stale source entry を掃除する (誤タグ防止)。
2720
+ if (r && r.accepted === false) session._removeInjectSource?.(injectHandle)
2721
+ })
2722
+ .catch((err) => {
2723
+ // 配送失敗 (throw) でも echo は来ないので stale な source entry を掃除する。
2724
+ session._removeInjectSource?.(injectHandle)
2725
+ this.logger?.error(
2726
+ { cwd, err: err?.message },
2727
+ "sendToCwd: sendMessage threw unexpectedly",
2728
+ )
2729
+ })
2634
2730
  return { status: "delivered", session: this._describeCandidate(session, att) }
2635
2731
  }
2636
2732
 
@@ -2657,7 +2753,7 @@ export class ClaudeStreamBridge extends EventEmitter {
2657
2753
  * @param {string} text
2658
2754
  * @returns {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
2659
2755
  */
2660
- sendToStream(cwd, sessionId, text) {
2756
+ sendToStream(cwd, sessionId, text, source) {
2661
2757
  if (!cwd || !sessionId || !text) return { status: "not_found" }
2662
2758
  // union 走査 (2026-07-23, 統一再設計 1-1): warm (soft-detached) セッションも session_id
2663
2759
  // ピンの対象にする。従来は this.sessions のみで、detach 済みだが生きているセッションを
@@ -2674,12 +2770,22 @@ export class ClaudeStreamBridge extends EventEmitter {
2674
2770
  }
2675
2771
  }
2676
2772
  const { session, attached } = matches[0]
2677
- session.sendMessage({ role: "user", content: text }).catch((err) => {
2678
- this.logger?.error(
2679
- { session_id: sessionId, err: err?.message },
2680
- "sendToStream: sendMessage threw unexpectedly",
2681
- )
2682
- })
2773
+ // 連携カード種別 (Phase 2): 宛先確定後に記録 (not_found/ambiguous では記録しない)。
2774
+ const injectHandle = session._recordInjectSource?.(text, source)
2775
+ session
2776
+ .sendMessage({ role: "user", content: text })
2777
+ .then((r) => {
2778
+ // Phase 2 (major#3): {accepted:false} (queue_dead / empty_message / pre-canceled) も
2779
+ // echo/started は来ないので stale source entry を掃除する (誤タグ防止)。
2780
+ if (r && r.accepted === false) session._removeInjectSource?.(injectHandle)
2781
+ })
2782
+ .catch((err) => {
2783
+ session._removeInjectSource?.(injectHandle)
2784
+ this.logger?.error(
2785
+ { session_id: sessionId, err: err?.message },
2786
+ "sendToStream: sendMessage threw unexpectedly",
2787
+ )
2788
+ })
2683
2789
  return { status: "delivered", session: this._describeCandidate(session, attached) }
2684
2790
  }
2685
2791
 
@@ -497,6 +497,12 @@ export class CodexStreamBridge extends EventEmitter {
497
497
  // major#2: ライブ追記でも cold flatten と同じ「item 数+概算 byte」二重上限を課すため、
498
498
  // 蓄積済み履歴の概算 byte 合計を保持する (再計算を避け O(1) で trim 判定)。
499
499
  historyBytes: 0,
500
+ // 連携カード種別タグ (Phase 2, 2026-07-23): ask-sibling/orchestrate 等の別セッションからの
501
+ // 注入本文 (text) → source ('peer'|'orchestrator') を控える FIFO。sendToCwd/sendToStream が
502
+ // 注入直前に積み、直後に app-server が返す userMessage の item/completed で本文一致により
503
+ // _onNotification が消費して params.item.source を付ける (frontend が専用カードへ分岐)。
504
+ /** @type {Array<{text: string, source: 'peer'|'orchestrator'}>} */
505
+ pendingInjectSources: [],
500
506
  }
501
507
  session.ready = new Promise((resolve) => {
502
508
  settleReady = resolve
@@ -849,6 +855,62 @@ export class CodexStreamBridge extends EventEmitter {
849
855
  return Array.from(new Set(this.sessions.values())).filter((s) => !s.dead)
850
856
  }
851
857
 
858
+ /** 連携カード種別 (Phase 2): 注入本文 → source を session の FIFO に控える。sendToCwd/
859
+ * sendToStream が input() を呼ぶ直前に記録し、直後の userMessage echo を
860
+ * _tagInjectSourceOnItem が拾う。
861
+ * @param {object} session
862
+ * @param {string} text
863
+ * @param {'peer'|'orchestrator'|undefined} source */
864
+ _recordInjectSource(session, text, source) {
865
+ if (!session || (source !== "peer" && source !== "orchestrator") || !text) return null
866
+ const list = session.pendingInjectSources || (session.pendingInjectSources = [])
867
+ const entry = { text, source }
868
+ list.push(entry)
869
+ // echo が来ない稀ケースの無限成長を防ぐ (連携注入は低頻度、32 件で十分)。
870
+ if (list.length > 32) list.shift()
871
+ return entry
872
+ }
873
+
874
+ /** 連携カード種別 (Phase 2, cross-review round1 major#1 / round2 major): 注入 (input) が配送失敗
875
+ * したときに、record が返した **その entry だけ**を identity (indexOf) で取り消す。
876
+ * ⚠️ text で消してはいけない (round2 指摘): 同一本文を並行注入し後発だけ失敗すると、text 先頭
877
+ * 一致は先発の成功 entry を誤って消し、先発 echo が後発 source で誤タグされる。handle 同定なら
878
+ * 失敗した当該 entry のみ除去される。既に消費/cap 済みなら indexOf=-1 で no-op。 */
879
+ _removeInjectSource(session, handle) {
880
+ const list = session?.pendingInjectSources
881
+ if (!list || !handle) return
882
+ const idx = list.indexOf(handle)
883
+ if (idx !== -1) list.splice(idx, 1)
884
+ }
885
+
886
+ /** userMessage の echo (item/completed) が控えた注入本文と一致すれば、params を **クローン**して
887
+ * item.source を付けたものを返す (元の app-server オブジェクトは変更しない)。一致しなければ null。
888
+ * frontend の userMessageItemToEvent がこの item.source を読み専用カードへ分岐する。 */
889
+ _tagInjectSourceOnItem(session, msg) {
890
+ const list = session?.pendingInjectSources
891
+ if (!list || !list.length) return null
892
+ if (msg?.method !== "item/completed") return null
893
+ const item = msg.params?.item
894
+ if (!item || item.type !== "userMessage") return null
895
+ const content = item.content
896
+ const text =
897
+ typeof content === "string"
898
+ ? content
899
+ : Array.isArray(content)
900
+ ? content
901
+ .map((b) =>
902
+ b && typeof b === "object" && typeof b.text === "string" ? b.text : "",
903
+ )
904
+ .join("")
905
+ : ""
906
+ if (!text) return null
907
+ const idx = list.findIndex((p) => p.text === text)
908
+ if (idx === -1) return null
909
+ const { source } = list[idx]
910
+ list.splice(idx, 1)
911
+ return { ...msg.params, item: { ...item, source } }
912
+ }
913
+
852
914
  /** 案B: この session を指す全購読 stream_id キーを sessions Map から外す(reap / exit 共通)。 */
853
915
  _dropAllStreamKeys(session) {
854
916
  const ids = session.streamIds ? Array.from(session.streamIds) : []
@@ -865,6 +927,15 @@ export class CodexStreamBridge extends EventEmitter {
865
927
  // 正常に走っているターンは reaper に回収されず、通知が途絶えたスタックターンだけが
866
928
  // 放棄セッションとして回収される。
867
929
  this._touch(session)
930
+ // 連携カード種別 (Phase 2, cross-review round1 major#2): タグ判定を switch より前に行い、
931
+ // **history にもタグ済み item を積む**。旧実装は未タグの msg.params.item を history へ入れてから
932
+ // ライブ emit だけタグしていたため、cwd reattach で historyItems が返ると連携カードが通常の青い
933
+ // 自分バブルへ戻っていた。同一プロセス内 reattach で表示が維持されるよう、以降 effectiveParams
934
+ // (タグ済み or 素) を history と emit の両方で使う。一致しなければ null → 素の params のまま。
935
+ // ※ hub-agent 再起動 (cold resume) では historyItems が揮発するため青バブルへ戻る (claude の
936
+ // jsonl at-rest と同じ許容範囲。source 永続マップは TTL/ID 再利用の複雑さに見合わないため不採用)。
937
+ const taggedParams = this._tagInjectSourceOnItem(session, msg)
938
+ const effectiveParams = taggedParams || msg.params
868
939
  switch (msg.method) {
869
940
  case "turn/started":
870
941
  session.activeTurnId = msg.params?.turn?.id || null
@@ -887,8 +958,8 @@ export class CodexStreamBridge extends EventEmitter {
887
958
  break
888
959
  case "item/completed":
889
960
  // T06541 案1: 完了した ThreadItem を高忠実度履歴に追記する(cwd reattach で返す用)。
890
- // ライブ表示は下の emit がそのまま担う(本 case は履歴蓄積を足すだけで emit は変えない)。
891
- this._appendHistoryItem(session, msg.params?.item, msg.params?.completedAtMs)
961
+ // Phase 2: タグ済み item (effectiveParams.item) を積む = reattach でも source が残る。
962
+ this._appendHistoryItem(session, effectiveParams?.item, effectiveParams?.completedAtMs)
892
963
  break
893
964
  default:
894
965
  break
@@ -902,7 +973,7 @@ export class CodexStreamBridge extends EventEmitter {
902
973
  producer_seq: session.seq,
903
974
  thread_id: session.threadId,
904
975
  cwd: session.cwd,
905
- notification: { method: msg.method, params: msg.params },
976
+ notification: { method: msg.method, params: effectiveParams },
906
977
  })
907
978
  }
908
979
 
@@ -1416,7 +1487,7 @@ export class CodexStreamBridge extends EventEmitter {
1416
1487
  * @param {string} text
1417
1488
  * @returns {{status:"delivered", session:object} | {status:"not_found"}}
1418
1489
  */
1419
- sendToCwd(cwd, text) {
1490
+ sendToCwd(cwd, text, source) {
1420
1491
  if (!cwd || !text) return { status: "not_found" }
1421
1492
  // 案B: distinct session で走査(多対一 Map の重複を除く)。
1422
1493
  const cands = this._liveSessions().filter((s) => s.cwd === cwd)
@@ -1449,6 +1520,8 @@ export class CodexStreamBridge extends EventEmitter {
1449
1520
  // 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
1450
1521
  best.detachRequested = false
1451
1522
  this._touch(best)
1523
+ // 連携カード種別 (Phase 2): 宛先確定後に記録 (not_found では記録しない)。handle を控え失敗時に除去。
1524
+ const injectHandle = this._recordInjectSource(best, text, source)
1452
1525
  this.logger?.info?.(
1453
1526
  {
1454
1527
  cwd,
@@ -1461,6 +1534,8 @@ export class CodexStreamBridge extends EventEmitter {
1461
1534
  )
1462
1535
  this.input({ stream_id: best.stream_id, message: { role: "user", content: text } }).catch(
1463
1536
  (err) => {
1537
+ // Phase 2: 配送失敗なら echo は来ないので stale な source entry を取り消す (誤タグ防止)。
1538
+ this._removeInjectSource(best, injectHandle)
1464
1539
  this.logger?.error(
1465
1540
  { cwd, stream_id: best.stream_id, err: err?.message },
1466
1541
  "sendToCwd: input threw unexpectedly",
@@ -1498,7 +1573,7 @@ export class CodexStreamBridge extends EventEmitter {
1498
1573
  * @param {string} text
1499
1574
  * @returns {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
1500
1575
  */
1501
- sendToStream(cwd, id, text) {
1576
+ sendToStream(cwd, id, text, source) {
1502
1577
  if (!cwd || !id || !text) return { status: "not_found" }
1503
1578
  let bySid = null
1504
1579
  const byThread = []
@@ -1524,8 +1599,12 @@ export class CodexStreamBridge extends EventEmitter {
1524
1599
  // (sendToCwd と同じ扱い)。
1525
1600
  target.detachRequested = false
1526
1601
  this._touch(target)
1602
+ // 連携カード種別 (Phase 2): 宛先確定後に記録 (not_found/ambiguous では記録しない)。handle を控える。
1603
+ const injectHandle = this._recordInjectSource(target, text, source)
1527
1604
  this.input({ stream_id: target.stream_id, message: { role: "user", content: text } }).catch(
1528
1605
  (err) => {
1606
+ // Phase 2: 配送失敗なら echo は来ないので stale な source entry を取り消す (誤タグ防止)。
1607
+ this._removeInjectSource(target, injectHandle)
1529
1608
  this.logger?.error(
1530
1609
  { id, stream_id: target.stream_id, err: err?.message },
1531
1610
  "sendToStream: input threw unexpectedly",
@@ -123,7 +123,11 @@ export class SiblingInjectBridge {
123
123
  // (T06271 レビュー major①): 旧 hub-agent は `!text` で invalid_request を返すため、
124
124
  // 未知フィールドを無視して sendToCwd 推測注入する事故が **単一リクエストで原理的に
125
125
  // 起きない** (probe→注入の TOCTOU を排除)。
126
- const { op, cwd, target_cli_kind, text, message, token, target_session_id } = body || {}
126
+ const { op, cwd, target_cli_kind, text, message, token, target_session_id, source } = body || {}
127
+ // 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
128
+ // 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
129
+ // frontend へ流して分岐を汚さない fail-closed)。bridge が user echo にこれを付ける。
130
+ const injectSource = source === "peer" || source === "orchestrator" ? source : undefined
127
131
  const isListAll = op === "list-all"
128
132
  const isList = op === "list"
129
133
  const isExplicit = op === "inject"
@@ -182,7 +186,9 @@ export class SiblingInjectBridge {
182
186
  } else if (isExplicit) {
183
187
  // 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
184
188
  // sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
185
- const r = bridge?.sendToStream?.(cwd, target_session_id, message) ?? { status: "not_found" }
189
+ const r = bridge?.sendToStream?.(cwd, target_session_id, message, injectSource) ?? {
190
+ status: "not_found",
191
+ }
186
192
  if (r.status === "delivered") {
187
193
  result = { ok: true, delivered_to: r.session }
188
194
  } else if (r.status === "ambiguous") {
@@ -229,7 +235,7 @@ export class SiblingInjectBridge {
229
235
  // (サイレント誤配送 + delivered_to に実配送先でない sameCwd[0] を報告する成功偽装) が
230
236
  // あった。sendToCwd を status 返却にして解決と配送を 1 呼び出しに統合し、delivered_to は
231
237
  // **実配送先 (r.session)** を返す。
232
- const r = bridge?.sendToCwd?.(cwd, text) ?? { status: "not_found" }
238
+ const r = bridge?.sendToCwd?.(cwd, text, injectSource) ?? { status: "not_found" }
233
239
  if (r.status === "delivered") {
234
240
  result = { ok: true, delivered_to: r.session ?? null }
235
241
  } else if (r.status === "ambiguous") {