@cocorograph/hub-agent 0.7.68 → 0.7.70

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.68",
3
+ "version": "0.7.70",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの tmux/pty を中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -2187,6 +2187,36 @@ export class ClaudeStreamBridge extends EventEmitter {
2187
2187
  }
2188
2188
  }
2189
2189
 
2190
+ /**
2191
+ * 診断用: 生きているチャットセッションの一覧 (sibling-inject が
2192
+ * `no_live_session_for_cwd` の理由に添える。codex-stream-bridge.mjs の同名メソッドと対)。
2193
+ *
2194
+ * ⚠️ ここに出るのは SDK チャットセッションのみ (tmux の TUI claude は対象外)。
2195
+ * @param {string} [cwd] 指定時はその cwd のみ
2196
+ * @returns {Array<{kind: string, cwd: string, session_id: string|null, idle_ms: number}>}
2197
+ */
2198
+ describeLiveSessions(cwd) {
2199
+ const now = Date.now()
2200
+ const out = []
2201
+ const seen = new Set()
2202
+ for (const session of this.sessions.values()) {
2203
+ if (seen.has(session)) continue
2204
+ seen.add(session)
2205
+ if (cwd && session.cwd !== cwd) continue
2206
+ let at = 0
2207
+ for (const t of session.lastActivityByStream?.values?.() ?? []) {
2208
+ if (t > at) at = t
2209
+ }
2210
+ out.push({
2211
+ kind: "claude",
2212
+ cwd: session.cwd,
2213
+ session_id: session.sessionId || null,
2214
+ idle_ms: at ? now - at : -1,
2215
+ })
2216
+ }
2217
+ return out
2218
+ }
2219
+
2190
2220
  /**
2191
2221
  * 指定 cwd で現在ライブな (browser 接続中、または detach 猶予内の) SDK チャット
2192
2222
  * session_id を返す。無ければ null。
@@ -11,6 +11,7 @@
11
11
  * claude.input → input() (turn/start、ターン中は turn/steer)
12
12
  * claude.interrupt → interrupt() (turn/interrupt)
13
13
  * claude.detach → detach() (TTL 付き graceful close)
14
+ * (codex 固有) → keepalive() (閲覧中の生存シグナル。pty.resize 相当)
14
15
  * claude.permission.reply → permissionReply() (client.respond/respondError)
15
16
  * claude.event (生 SDK イベント素通し) → 'event' (生 notification 素通し、同じ思想)
16
17
  * claude.permission.request → 'permission' (承認/入力要求の生 method+params 素通し)
@@ -55,6 +56,16 @@ function siblingEnvFor(cwd) {
55
56
  }
56
57
  }
57
58
 
59
+ // ⚠️ 2026-07-17 の事故 (「Codex チャットが 5 分で消え ask-sibling が届かない」) の教訓:
60
+ // この TTL は **detach 済み (= ブラウザが居なくなった) セッション専用** の猶予である。
61
+ // 旧実装は (a) detach の有無を記録せず turn/completed のたびに無条件でタイマーを張り、
62
+ // (b) タイマー発火時に activeTurnId を再チェックしなかったため、
63
+ // - ブラウザを開いたままでも「最後のターン完了から 5 分」で app-server が kill され、
64
+ // - その 5 分間に ask-sibling が新ターンを注入すると **exec 実行中に kill** され、
65
+ // rollout に output の無い custom_tool_call が残って thread が永久に汚染された
66
+ // (codex_core の `Custom tool call output is missing for call id: ...` が以後の
67
+ // 全ターンで出続ける)。
68
+ // 予約は detach 済みセッションに限り、発火時にも busy/再活性を再確認すること。
58
69
  // detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
59
70
  // Slice 2 は in-memory reattach 非対応のため、browser の WS 再接続ごとに新 stream_id で
60
71
  // 新プロセスが起動し、旧プロセスは detach で TTL 待ちに入る。7 日だと再接続チャーンの
@@ -200,6 +211,10 @@ export class CodexStreamBridge extends EventEmitter {
200
211
  activeTurnId: null,
201
212
  pendingApprovals: new Map(), // request_id -> { turnId }
202
213
  detachTimer: null,
214
+ // ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。
215
+ // アイドル TTL reap は **このフラグが立っているセッションだけ** が対象。
216
+ // ask-sibling 注入 (sendToCwd) は「新しい持ち主が付いた」とみなして false に戻す。
217
+ detachRequested: false,
203
218
  dead: false,
204
219
  // 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
205
220
  // app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
@@ -231,6 +246,20 @@ export class CodexStreamBridge extends EventEmitter {
231
246
  // Codex が「ターン実行中」と権威的に報告した場合のみ activeTurnId を復元する
232
247
  // (通常の fresh resume では null = 次入力が turn/start になり固着しない)。
233
248
  session.activeTurnId = turnState.activeTurnId
249
+ // セッションのライフサイクルは必ずログに残す (2026-07-17: attach/detach/reap/exit の
250
+ // 記録が皆無で「なぜ消えたか」を事後に辿れなかった)。同一 cwd に何本のセッションが
251
+ // 積み上がっているかも併記し、ゾンビ蓄積をログだけで検知できるようにする。
252
+ this.logger?.info?.(
253
+ {
254
+ stream_id,
255
+ thread_id: session.threadId,
256
+ cwd,
257
+ resuming,
258
+ thread_status: turnState.threadStatus,
259
+ live_for_cwd: this.describeLiveSessions(cwd).length,
260
+ },
261
+ "codex session attached",
262
+ )
234
263
  return {
235
264
  resuming,
236
265
  threadId: session.threadId,
@@ -315,7 +344,9 @@ export class CodexStreamBridge extends EventEmitter {
315
344
  case "turn/completed":
316
345
  session.activeTurnId = null
317
346
  this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
318
- this._maybeReapAfterIdle(session)
347
+ // ⚠️ detach 済みのときだけ再評価する。ブラウザが繋がったままのセッションは
348
+ // ターンが終わっても生かし続ける (_maybeReapAfterIdle 内でも二重に guard)。
349
+ this._maybeReapAfterIdle(session, "turn-completed-after-detach")
319
350
  break
320
351
  default:
321
352
  break
@@ -357,6 +388,28 @@ export class CodexStreamBridge extends EventEmitter {
357
388
  session.dead = true
358
389
  this._clearDetachTimer(session)
359
390
  this.sessions.delete(session.stream_id)
391
+ // app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
392
+ // custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
393
+ // (codex_core の `Custom tool call output is missing for call id: ...` の発生源)。
394
+ const midTurn = !!session.activeTurnId
395
+ const payload = {
396
+ stream_id: session.stream_id,
397
+ thread_id: session.threadId,
398
+ cwd: session.cwd,
399
+ code: info.code,
400
+ signal: info.signal || null,
401
+ detach_requested: !!session.detachRequested,
402
+ mid_turn: midTurn,
403
+ turn_id: session.activeTurnId || null,
404
+ }
405
+ if (midTurn) {
406
+ this.logger?.warn?.(
407
+ payload,
408
+ "codex app-server exited MID-TURN (rollout may retain a custom_tool_call without output)",
409
+ )
410
+ } else {
411
+ this.logger?.info?.(payload, "codex app-server exited")
412
+ }
360
413
  this.emit("exit", {
361
414
  stream_id: session.stream_id,
362
415
  thread_id: session.threadId,
@@ -384,6 +437,10 @@ export class CodexStreamBridge extends EventEmitter {
384
437
  async input({ stream_id, message, model, effort }) {
385
438
  const session = this._require(stream_id)
386
439
  this._touch(session)
440
+ // TTL 待ちのセッションに新ターンを流す = まだ使われている。予約を解除しないと
441
+ // ターン実行中 (exec 中) に kill され、rollout に output 欠落の custom_tool_call が
442
+ // 残って thread が汚染される (2026-07-17 の事故)。
443
+ this._cancelIdleReap(session, "input")
387
444
  const input = userInputFromMessage(message)
388
445
  if (session.activeTurnId) {
389
446
  try {
@@ -432,6 +489,7 @@ export class CodexStreamBridge extends EventEmitter {
432
489
  async interrupt({ stream_id }) {
433
490
  const session = this._require(stream_id)
434
491
  this._touch(session)
492
+ this._cancelIdleReap(session, "interrupt")
435
493
  if (!session.activeTurnId) return
436
494
  await session.client.request("turn/interrupt", {
437
495
  threadId: session.threadId,
@@ -443,6 +501,7 @@ export class CodexStreamBridge extends EventEmitter {
443
501
  permissionReply({ stream_id, request_id, result }) {
444
502
  const session = this._require(stream_id)
445
503
  this._touch(session)
504
+ this._cancelIdleReap(session, "permission-reply")
446
505
  if (!session.pendingApprovals.has(request_id)) {
447
506
  this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
448
507
  return
@@ -451,6 +510,26 @@ export class CodexStreamBridge extends EventEmitter {
451
510
  session.client.respond(request_id, result)
452
511
  }
453
512
 
513
+ /**
514
+ * ブラウザ閲覧中の生存シグナル (codex.keepalive)。frontend が Codex チャットを開いて
515
+ * いる間だけ定期送信するため、これが届く = 「人が見ている」の観測可能な証跡になる。
516
+ * gcAbandonedSessions は lastActivityMs しか見ないので、touch するだけで
517
+ * 「開いたまま無入力でも 1h で殺される」ギャップが閉じる (TTL の数字を増やす必要は無い)。
518
+ *
519
+ * ⚠️ 意図的に `_cancelIdleReap` を **呼ばない**。detach 済みセッションの 5 分 TTL は
520
+ * 「ブラウザが去った」という別事実に基づく予約であり、detach と in-flight keepalive が
521
+ * 競合しても予約を解除しない (= 明示 detach は必ず 5 分で回収される) 方が意味論が明快。
522
+ * 閲覧を再開したブラウザは必ず attach を送り直すので、再養子はそちらの経路で足りる。
523
+ *
524
+ * @returns {boolean} 生きているセッションを touch できたら true (未知 stream は false)。
525
+ */
526
+ keepalive({ stream_id }) {
527
+ const session = this.sessions.get(stream_id)
528
+ if (!session || session.dead) return false
529
+ this._touch(session)
530
+ return true
531
+ }
532
+
454
533
  /**
455
534
  * グレースフル切断。ターン実行中は完走を待ち、非 busy ならアイドル TTL 後に
456
535
  * プロセスを reap する (claude-stream-bridge.mjs の softDetach と同じ思想。
@@ -459,7 +538,46 @@ export class CodexStreamBridge extends EventEmitter {
459
538
  detach({ stream_id }) {
460
539
  const session = this.sessions.get(stream_id)
461
540
  if (!session || session.dead) return
462
- this._maybeReapAfterIdle(session)
541
+ session.detachRequested = true
542
+ this.logger?.info?.(
543
+ {
544
+ stream_id,
545
+ thread_id: session.threadId,
546
+ cwd: session.cwd,
547
+ busy: !!session.activeTurnId,
548
+ },
549
+ "codex detach requested by browser",
550
+ )
551
+ this._maybeReapAfterIdle(session, "detach")
552
+ }
553
+
554
+ /**
555
+ * 診断用: 生きているセッションの一覧 (sibling-inject の失敗理由に載せる / ログ用)。
556
+ * 「TUI codex は hub-agent の管理外なので ask-sibling の宛先にならない」ことを
557
+ * 呼び出し側が説明できるよう、ここが返すのは **hub-agent が spawn した chat
558
+ * セッションだけ** である点に注意 (2026-07-17: TUI codex が生きているのを見て
559
+ * 「codex は動いているのに届かない」と誤判断した事故の再発防止)。
560
+ * @param {string} [cwd] 指定時はその cwd のみ
561
+ * @returns {Array<{kind: string, cwd: string, stream_id: string, thread_id: string|null,
562
+ * busy: boolean, detached: boolean, idle_ms: number}>}
563
+ */
564
+ describeLiveSessions(cwd) {
565
+ const now = Date.now()
566
+ const out = []
567
+ for (const session of this.sessions.values()) {
568
+ if (session.dead) continue
569
+ if (cwd && session.cwd !== cwd) continue
570
+ out.push({
571
+ kind: "codex",
572
+ cwd: session.cwd,
573
+ stream_id: session.stream_id,
574
+ thread_id: session.threadId || null,
575
+ busy: !!session.activeTurnId,
576
+ detached: !!session.detachRequested,
577
+ idle_ms: now - (session.lastActivityMs ?? now),
578
+ })
579
+ }
580
+ return out
463
581
  }
464
582
 
465
583
  /**
@@ -481,8 +599,13 @@ export class CodexStreamBridge extends EventEmitter {
481
599
 
482
600
  /**
483
601
  * 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
484
- * 2026-07-04)。Claude 版 `sendToCwd` の対。同一 cwd に複数ライブセッションがある
485
- * 場合は `threadIdForCwd` と同じく先に見つかったものを使う。
602
+ * 2026-07-04)。Claude 版 `sendToCwd` の対。
603
+ *
604
+ * ⚠️ 同一 cwd に複数ライブセッションがあるとき (ブラウザの WS 再接続ごとに新しい
605
+ * stream_id でセッションが増えるため頻繁に起きる) は **最終活性が最新のもの** を
606
+ * 選ぶ。旧実装は Map の挿入順 = 最古を選んでいたため、ブラウザが既に居ない
607
+ * ゾンビセッションへ注入して「ok なのに画面に出ない」事故が起きた (2026-07-17)。
608
+ * claude-stream-bridge.mjs の同名メソッドと同じ選択規則に揃えてある。
486
609
  *
487
610
  * ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
488
611
  * src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
@@ -493,29 +616,128 @@ export class CodexStreamBridge extends EventEmitter {
493
616
  */
494
617
  sendToCwd(cwd, text) {
495
618
  if (!cwd || !text) return false
619
+ let best = null
620
+ let bestAt = -1
496
621
  for (const session of this.sessions.values()) {
497
- if (session.cwd !== cwd) continue
498
- this.input({ stream_id: session.stream_id, message: { role: "user", content: text } }).catch(
499
- (err) => {
500
- this.logger?.error(
501
- { cwd, err: err?.message },
502
- "sendToCwd: input threw unexpectedly",
503
- )
504
- },
622
+ if (session.dead || session.cwd !== cwd) continue
623
+ const at = session.lastActivityMs ?? 0
624
+ if (at >= bestAt) {
625
+ bestAt = at
626
+ best = session
627
+ }
628
+ }
629
+ if (!best) {
630
+ this.logger?.warn?.(
631
+ { cwd, live_sessions: this.describeLiveSessions() },
632
+ "codex sendToCwd: no live session for cwd (ask-sibling will fail)",
505
633
  )
506
- return true
634
+ return false
507
635
  }
508
- return false
636
+ // TTL 待ち (browser detach 済み) のセッションでも、ask-sibling が使うなら
637
+ // 「新しい持ち主が付いた」とみなして回収予約を解除する。これをしないと
638
+ // 注入したターンの実行中に kill され、thread が汚染される (2026-07-17)。
639
+ this._cancelIdleReap(best, "sendToCwd (ask-sibling injection)", { readopt: true })
640
+ this.logger?.info?.(
641
+ {
642
+ cwd,
643
+ stream_id: best.stream_id,
644
+ thread_id: best.threadId,
645
+ busy: !!best.activeTurnId,
646
+ candidates: this.describeLiveSessions(cwd).length,
647
+ },
648
+ "codex sendToCwd: injecting into most-recently-active session",
649
+ )
650
+ this.input({ stream_id: best.stream_id, message: { role: "user", content: text } }).catch(
651
+ (err) => {
652
+ this.logger?.error(
653
+ { cwd, stream_id: best.stream_id, err: err?.message },
654
+ "sendToCwd: input threw unexpectedly",
655
+ )
656
+ },
657
+ )
658
+ return true
509
659
  }
510
660
 
511
- _maybeReapAfterIdle(session) {
661
+ /**
662
+ * detach 済み・非 busy なセッションに「アイドル TTL 後の回収」を予約する。
663
+ *
664
+ * ⚠️ 予約は `detachRequested` が立っているセッションのみ。ブラウザが繋がったままの
665
+ * セッションを turn/completed 契機で殺してはいけない (2026-07-17 の事故)。
666
+ * @param {object} session
667
+ * @param {string} trigger 予約理由 (ログ用: 'detach' | 'turn-completed-after-detach')
668
+ */
669
+ _maybeReapAfterIdle(session, trigger = "detach") {
670
+ if (!session.detachRequested) return // ブラウザが居る → 回収対象外
512
671
  if (session.activeTurnId) return // 完走まで待つ (turn/completed で再評価される)
513
672
  this._clearDetachTimer(session)
514
673
  session.detachTimer = setTimeout(() => {
515
674
  if (session.dead) return
675
+ // 発火時の再確認: TTL 中に ask-sibling 注入 (sendToCwd) で再活性化した、または
676
+ // 新しいターンが走り出したセッションを **実行中に kill しない** (rollout に
677
+ // output 欠落の custom_tool_call を残さないため)。
678
+ if (!session.detachRequested) {
679
+ this.logger?.info?.(
680
+ { stream_id: session.stream_id, thread_id: session.threadId, cwd: session.cwd },
681
+ "codex idle reap canceled at fire time (session re-adopted)",
682
+ )
683
+ session.detachTimer = null
684
+ return
685
+ }
686
+ if (session.activeTurnId) {
687
+ this.logger?.info?.(
688
+ {
689
+ stream_id: session.stream_id,
690
+ thread_id: session.threadId,
691
+ cwd: session.cwd,
692
+ turn_id: session.activeTurnId,
693
+ },
694
+ "codex idle reap deferred (turn in progress; will re-arm on turn/completed)",
695
+ )
696
+ session.detachTimer = null
697
+ return
698
+ }
699
+ this.logger?.info?.(
700
+ {
701
+ stream_id: session.stream_id,
702
+ thread_id: session.threadId,
703
+ cwd: session.cwd,
704
+ ttl_ms: this._detachTtlMs,
705
+ reason: "idle-detach-ttl",
706
+ },
707
+ "codex session reaped (detached and idle past TTL)",
708
+ )
516
709
  session.client.stop()
517
710
  }, this._detachTtlMs)
518
711
  session.detachTimer.unref?.()
712
+ this.logger?.info?.(
713
+ {
714
+ stream_id: session.stream_id,
715
+ thread_id: session.threadId,
716
+ cwd: session.cwd,
717
+ ttl_ms: this._detachTtlMs,
718
+ trigger,
719
+ },
720
+ "codex idle reap armed (browser detached)",
721
+ )
722
+ }
723
+
724
+ /**
725
+ * 予約済みのアイドル回収を取り消す。ブラウザ入力 / ask-sibling 注入 / 承認応答など
726
+ * 「このセッションはまだ使われている」証跡が出たら必ず呼ぶ。
727
+ * @param {object} session
728
+ * @param {string} reason ログ用
729
+ * @param {boolean} readopt true なら detachRequested も解除 (新しい持ち主が付いた)
730
+ */
731
+ _cancelIdleReap(session, reason, { readopt = false } = {}) {
732
+ const had = !!session.detachTimer
733
+ this._clearDetachTimer(session)
734
+ if (readopt) session.detachRequested = false
735
+ if (had) {
736
+ this.logger?.info?.(
737
+ { stream_id: session.stream_id, thread_id: session.threadId, cwd: session.cwd, reason },
738
+ "codex idle reap canceled (session active again)",
739
+ )
740
+ }
519
741
  }
520
742
 
521
743
  _clearDetachTimer(session) {
@@ -543,6 +765,18 @@ export class CodexStreamBridge extends EventEmitter {
543
765
  * 進行中の正常なターンは app-server 通知で都度 touch されるので回収されない。
544
766
  * main.mjs の state loop から claudeBridge.gcDeadTerminals と並べて定期実行する想定。
545
767
  *
768
+ * この reaper は `detachRequested` を **意図的に参照しない**。detach を送れずに消えた
769
+ * ブラウザ (タブごとクラッシュ / 端末スリープ) は detachRequested が立たないまま残るため、
770
+ * ここでフラグを見ると回収不能になりリークする。「無通信が続いた事実」だけで回収する。
771
+ *
772
+ * 閲覧中の誤爆は keepalive で防ぐ (2026-07-17 に解消。旧: 開いたままでも 1h 無入力で
773
+ * 回収されていた)。frontend は Codex チャットを開いている間 codex.keepalive を定期送信し、
774
+ * keepalive() が lastActivityMs を touch する。ブラウザが閉じれば keepalive も止まるので
775
+ * この reaper は本来の orphan GC として正しく効く。CockpitTerminal.tsx の PTY keepalive
776
+ * (pty.resize で lastSeenAt を触る) と同じ構造。結果として TTL の意味は
777
+ * 「ブラウザ開 = 無期限 / 明示 detach = 5 分 / ブラウザ落ち = 1h reaper」に整理され、
778
+ * 「N 時間放置しても生きていてほしい」という数字合わせ自体が不要になった。
779
+ *
546
780
  * @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
547
781
  * @returns {number} 回収したセッション数。
548
782
  */
@@ -581,6 +815,20 @@ export class CodexStreamBridge extends EventEmitter {
581
815
  for (const session of this.sessions.values()) {
582
816
  this._clearDetachTimer(session)
583
817
  session.dead = true
818
+ // hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
819
+ // (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
820
+ this.logger?.[session.activeTurnId ? "warn" : "info"]?.(
821
+ {
822
+ stream_id: session.stream_id,
823
+ thread_id: session.threadId,
824
+ cwd: session.cwd,
825
+ mid_turn: !!session.activeTurnId,
826
+ reason: "agent-shutdown",
827
+ },
828
+ session.activeTurnId
829
+ ? "codex session killed MID-TURN by agent shutdown"
830
+ : "codex session stopped by agent shutdown",
831
+ )
584
832
  session.client.stop()
585
833
  }
586
834
  this.sessions.clear()
package/src/main.mjs CHANGED
@@ -4335,6 +4335,13 @@ async function dispatch(msg, ctx) {
4335
4335
  if (!ctx.codexBridge) return
4336
4336
  ctx.codexBridge.detach({ stream_id: msg.stream_id })
4337
4337
  return
4338
+ case "codex.keepalive":
4339
+ // ブラウザが Codex チャットを開いている間の生存シグナル (CockpitTerminal.tsx の
4340
+ // PTY keepalive と同じ構造)。閲覧中に gcAbandonedSessions (既定 1h 無通信) が
4341
+ // 誤爆するのを防ぐ。未知 stream_id は false が返るだけ (無害な no-op)。
4342
+ if (!ctx.codexBridge) return
4343
+ ctx.codexBridge.keepalive({ stream_id: msg.stream_id })
4344
+ return
4338
4345
  case "codex.sessions.request": {
4339
4346
  // cwd 配下の Codex thread 一覧を返す (claude.sessions.request と対称。
4340
4347
  // Cockpit のセッションピッカー/最新セッション自動選択用)。
@@ -112,9 +112,13 @@ export class SiblingInjectBridge {
112
112
  const sent = bridge?.sendToCwd?.(cwd, text) ?? false
113
113
  result = sent
114
114
  ? { ok: true }
115
- : { ok: false, error: "no_live_session_for_cwd" }
115
+ : { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
116
116
  }
117
117
 
118
+ this.logger?.[result.ok ? "info" : "warn"]?.(
119
+ { request_id, cwd, target_cli_kind, ...(result.ok ? {} : { error: result.error, live_sessions: result.live_sessions }) },
120
+ result.ok ? "sibling-inject delivered" : "sibling-inject failed",
121
+ )
118
122
  await this._writeResult(request_id, result)
119
123
  // 要求ファイルは処理後に必ず削除する (成否問わず、再処理させないため)。
120
124
  try {
@@ -124,6 +128,32 @@ export class SiblingInjectBridge {
124
128
  }
125
129
  }
126
130
 
131
+ /**
132
+ * `no_live_session_for_cwd` に「一時的か恒久的か」「TUI と混同していないか」を
133
+ * 切り分ける材料を添える (2026-07-17: エラー文字列だけでは (a) 1 回失敗 → 再送で成功
134
+ * (b) 3 回失敗 → 本当に消滅 の区別が付かず誤判断した)。
135
+ *
136
+ * ⚠️ ここに出るのは hub-agent が spawn した **チャットセッションのみ**。tmux 配下で
137
+ * 動く TUI codex は ask-sibling の宛先にならないため、一覧が空でも「codex は画面上
138
+ * 動いている」ことは普通に起きる。その旨を hint に明記する。
139
+ * @param {string} cwd
140
+ * @param {string} targetKind
141
+ */
142
+ _diagnose(cwd, targetKind) {
143
+ const all = [
144
+ ...(this.claudeBridge?.describeLiveSessions?.() ?? []),
145
+ ...(this.codexBridge?.describeLiveSessions?.() ?? []),
146
+ ]
147
+ const sameCwd = all.filter((s) => s.cwd === cwd)
148
+ const hint = sameCwd.length
149
+ ? `この cwd には ${sameCwd.map((s) => s.kind).join("/")} のチャットセッションはあるが ${targetKind} は無い。`
150
+ : `この cwd には hub-agent 管理下のチャットセッションが 1 本も無い。` +
151
+ `tmux の TUI codex は ask-sibling の宛先にならない (hub-agent が spawn した` +
152
+ ` Cockpit のチャットセッションのみが対象)。Cockpit のブラウザで対象 cwd の` +
153
+ ` ${targetKind} チャットを開き直してから再送すること。`
154
+ return { live_sessions: all, hint }
155
+ }
156
+
127
157
  /** 結果ファイルを atomic write する (呼び出し側 CLI がポーリングする)。 */
128
158
  async _writeResult(request_id, result) {
129
159
  const fp = path.join(this.dir, `${request_id}.result.json`)