@cocorograph/hub-agent 0.7.67 → 0.7.69

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/bin/hub-agent.mjs CHANGED
@@ -92,6 +92,11 @@ program
92
92
  "ローカルで検出された CLI 全て (claude / codex) を既定で同期する",
93
93
  )
94
94
  .option("--cli-kind <kind>", "'claude' または 'codex' のみ同期する (省略時は検出された全 CLI)")
95
+ .option(
96
+ "--auto",
97
+ "非対話(自動同期)モード。codex mcp add / OAuth / ブラウザ起動を一切行わず、" +
98
+ "既存登録済み MCP の承認モード補完のみを行う (SessionStart フォールバック / daemon 用)",
99
+ )
95
100
  .action(async (opts) => {
96
101
  try {
97
102
  const cfg = await readConfig()
@@ -122,6 +127,9 @@ program
122
127
  hubUrl: cfg.hub_url,
123
128
  agentId: cfg.agent_id,
124
129
  agentToken: cfg.agent_token,
130
+ // --auto(非対話): SessionStart フォールバック / daemon 由来の自動同期で使う。
131
+ // codex mcp add / OAuth を呼ばず、既存テーブルの承認モード補完のみ行う。
132
+ nonInteractive: Boolean(opts.auto),
125
133
  })
126
134
  for (const cliKind of targets) {
127
135
  const r = results[cliKind]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.7.67",
3
+ "version": "0.7.69",
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。
@@ -55,6 +55,16 @@ function siblingEnvFor(cwd) {
55
55
  }
56
56
  }
57
57
 
58
+ // ⚠️ 2026-07-17 の事故 (「Codex チャットが 5 分で消え ask-sibling が届かない」) の教訓:
59
+ // この TTL は **detach 済み (= ブラウザが居なくなった) セッション専用** の猶予である。
60
+ // 旧実装は (a) detach の有無を記録せず turn/completed のたびに無条件でタイマーを張り、
61
+ // (b) タイマー発火時に activeTurnId を再チェックしなかったため、
62
+ // - ブラウザを開いたままでも「最後のターン完了から 5 分」で app-server が kill され、
63
+ // - その 5 分間に ask-sibling が新ターンを注入すると **exec 実行中に kill** され、
64
+ // rollout に output の無い custom_tool_call が残って thread が永久に汚染された
65
+ // (codex_core の `Custom tool call output is missing for call id: ...` が以後の
66
+ // 全ターンで出続ける)。
67
+ // 予約は detach 済みセッションに限り、発火時にも busy/再活性を再確認すること。
58
68
  // detach 後、非 busy なセッションを生かしておく猶予。claude 側 (7 日) と違い短くする:
59
69
  // Slice 2 は in-memory reattach 非対応のため、browser の WS 再接続ごとに新 stream_id で
60
70
  // 新プロセスが起動し、旧プロセスは detach で TTL 待ちに入る。7 日だと再接続チャーンの
@@ -200,6 +210,10 @@ export class CodexStreamBridge extends EventEmitter {
200
210
  activeTurnId: null,
201
211
  pendingApprovals: new Map(), // request_id -> { turnId }
202
212
  detachTimer: null,
213
+ // ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。
214
+ // アイドル TTL reap は **このフラグが立っているセッションだけ** が対象。
215
+ // ask-sibling 注入 (sendToCwd) は「新しい持ち主が付いた」とみなして false に戻す。
216
+ detachRequested: false,
203
217
  dead: false,
204
218
  // 放棄セッション reaper (gcAbandonedSessions) 用の最終活性時刻。browser 入力と
205
219
  // app-server 通知 (ターン進捗) の両方で更新する。Date.now() は hub-agent 実行環境で利用可。
@@ -231,6 +245,20 @@ export class CodexStreamBridge extends EventEmitter {
231
245
  // Codex が「ターン実行中」と権威的に報告した場合のみ activeTurnId を復元する
232
246
  // (通常の fresh resume では null = 次入力が turn/start になり固着しない)。
233
247
  session.activeTurnId = turnState.activeTurnId
248
+ // セッションのライフサイクルは必ずログに残す (2026-07-17: attach/detach/reap/exit の
249
+ // 記録が皆無で「なぜ消えたか」を事後に辿れなかった)。同一 cwd に何本のセッションが
250
+ // 積み上がっているかも併記し、ゾンビ蓄積をログだけで検知できるようにする。
251
+ this.logger?.info?.(
252
+ {
253
+ stream_id,
254
+ thread_id: session.threadId,
255
+ cwd,
256
+ resuming,
257
+ thread_status: turnState.threadStatus,
258
+ live_for_cwd: this.describeLiveSessions(cwd).length,
259
+ },
260
+ "codex session attached",
261
+ )
234
262
  return {
235
263
  resuming,
236
264
  threadId: session.threadId,
@@ -315,7 +343,9 @@ export class CodexStreamBridge extends EventEmitter {
315
343
  case "turn/completed":
316
344
  session.activeTurnId = null
317
345
  this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
318
- this._maybeReapAfterIdle(session)
346
+ // ⚠️ detach 済みのときだけ再評価する。ブラウザが繋がったままのセッションは
347
+ // ターンが終わっても生かし続ける (_maybeReapAfterIdle 内でも二重に guard)。
348
+ this._maybeReapAfterIdle(session, "turn-completed-after-detach")
319
349
  break
320
350
  default:
321
351
  break
@@ -357,6 +387,28 @@ export class CodexStreamBridge extends EventEmitter {
357
387
  session.dead = true
358
388
  this._clearDetachTimer(session)
359
389
  this.sessions.delete(session.stream_id)
390
+ // app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
391
+ // custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
392
+ // (codex_core の `Custom tool call output is missing for call id: ...` の発生源)。
393
+ const midTurn = !!session.activeTurnId
394
+ const payload = {
395
+ stream_id: session.stream_id,
396
+ thread_id: session.threadId,
397
+ cwd: session.cwd,
398
+ code: info.code,
399
+ signal: info.signal || null,
400
+ detach_requested: !!session.detachRequested,
401
+ mid_turn: midTurn,
402
+ turn_id: session.activeTurnId || null,
403
+ }
404
+ if (midTurn) {
405
+ this.logger?.warn?.(
406
+ payload,
407
+ "codex app-server exited MID-TURN (rollout may retain a custom_tool_call without output)",
408
+ )
409
+ } else {
410
+ this.logger?.info?.(payload, "codex app-server exited")
411
+ }
360
412
  this.emit("exit", {
361
413
  stream_id: session.stream_id,
362
414
  thread_id: session.threadId,
@@ -384,6 +436,10 @@ export class CodexStreamBridge extends EventEmitter {
384
436
  async input({ stream_id, message, model, effort }) {
385
437
  const session = this._require(stream_id)
386
438
  this._touch(session)
439
+ // TTL 待ちのセッションに新ターンを流す = まだ使われている。予約を解除しないと
440
+ // ターン実行中 (exec 中) に kill され、rollout に output 欠落の custom_tool_call が
441
+ // 残って thread が汚染される (2026-07-17 の事故)。
442
+ this._cancelIdleReap(session, "input")
387
443
  const input = userInputFromMessage(message)
388
444
  if (session.activeTurnId) {
389
445
  try {
@@ -432,6 +488,7 @@ export class CodexStreamBridge extends EventEmitter {
432
488
  async interrupt({ stream_id }) {
433
489
  const session = this._require(stream_id)
434
490
  this._touch(session)
491
+ this._cancelIdleReap(session, "interrupt")
435
492
  if (!session.activeTurnId) return
436
493
  await session.client.request("turn/interrupt", {
437
494
  threadId: session.threadId,
@@ -443,6 +500,7 @@ export class CodexStreamBridge extends EventEmitter {
443
500
  permissionReply({ stream_id, request_id, result }) {
444
501
  const session = this._require(stream_id)
445
502
  this._touch(session)
503
+ this._cancelIdleReap(session, "permission-reply")
446
504
  if (!session.pendingApprovals.has(request_id)) {
447
505
  this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
448
506
  return
@@ -459,7 +517,46 @@ export class CodexStreamBridge extends EventEmitter {
459
517
  detach({ stream_id }) {
460
518
  const session = this.sessions.get(stream_id)
461
519
  if (!session || session.dead) return
462
- this._maybeReapAfterIdle(session)
520
+ session.detachRequested = true
521
+ this.logger?.info?.(
522
+ {
523
+ stream_id,
524
+ thread_id: session.threadId,
525
+ cwd: session.cwd,
526
+ busy: !!session.activeTurnId,
527
+ },
528
+ "codex detach requested by browser",
529
+ )
530
+ this._maybeReapAfterIdle(session, "detach")
531
+ }
532
+
533
+ /**
534
+ * 診断用: 生きているセッションの一覧 (sibling-inject の失敗理由に載せる / ログ用)。
535
+ * 「TUI codex は hub-agent の管理外なので ask-sibling の宛先にならない」ことを
536
+ * 呼び出し側が説明できるよう、ここが返すのは **hub-agent が spawn した chat
537
+ * セッションだけ** である点に注意 (2026-07-17: TUI codex が生きているのを見て
538
+ * 「codex は動いているのに届かない」と誤判断した事故の再発防止)。
539
+ * @param {string} [cwd] 指定時はその cwd のみ
540
+ * @returns {Array<{kind: string, cwd: string, stream_id: string, thread_id: string|null,
541
+ * busy: boolean, detached: boolean, idle_ms: number}>}
542
+ */
543
+ describeLiveSessions(cwd) {
544
+ const now = Date.now()
545
+ const out = []
546
+ for (const session of this.sessions.values()) {
547
+ if (session.dead) continue
548
+ if (cwd && session.cwd !== cwd) continue
549
+ out.push({
550
+ kind: "codex",
551
+ cwd: session.cwd,
552
+ stream_id: session.stream_id,
553
+ thread_id: session.threadId || null,
554
+ busy: !!session.activeTurnId,
555
+ detached: !!session.detachRequested,
556
+ idle_ms: now - (session.lastActivityMs ?? now),
557
+ })
558
+ }
559
+ return out
463
560
  }
464
561
 
465
562
  /**
@@ -481,8 +578,13 @@ export class CodexStreamBridge extends EventEmitter {
481
578
 
482
579
  /**
483
580
  * 指定 cwd でライブなセッションへメッセージを直接注入する (sibling-inject 用、
484
- * 2026-07-04)。Claude 版 `sendToCwd` の対。同一 cwd に複数ライブセッションがある
485
- * 場合は `threadIdForCwd` と同じく先に見つかったものを使う。
581
+ * 2026-07-04)。Claude 版 `sendToCwd` の対。
582
+ *
583
+ * ⚠️ 同一 cwd に複数ライブセッションがあるとき (ブラウザの WS 再接続ごとに新しい
584
+ * stream_id でセッションが増えるため頻繁に起きる) は **最終活性が最新のもの** を
585
+ * 選ぶ。旧実装は Map の挿入順 = 最古を選んでいたため、ブラウザが既に居ない
586
+ * ゾンビセッションへ注入して「ok なのに画面に出ない」事故が起きた (2026-07-17)。
587
+ * claude-stream-bridge.mjs の同名メソッドと同じ選択規則に揃えてある。
486
588
  *
487
589
  * ⚠️ このメソッド自体は認証を行わない (呼び出し元の sibling-inject.mjs が
488
590
  * src/sibling-token.mjs の capability トークンで入力元を検証する前提)。
@@ -493,29 +595,128 @@ export class CodexStreamBridge extends EventEmitter {
493
595
  */
494
596
  sendToCwd(cwd, text) {
495
597
  if (!cwd || !text) return false
598
+ let best = null
599
+ let bestAt = -1
496
600
  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
- },
601
+ if (session.dead || session.cwd !== cwd) continue
602
+ const at = session.lastActivityMs ?? 0
603
+ if (at >= bestAt) {
604
+ bestAt = at
605
+ best = session
606
+ }
607
+ }
608
+ if (!best) {
609
+ this.logger?.warn?.(
610
+ { cwd, live_sessions: this.describeLiveSessions() },
611
+ "codex sendToCwd: no live session for cwd (ask-sibling will fail)",
505
612
  )
506
- return true
613
+ return false
507
614
  }
508
- return false
615
+ // TTL 待ち (browser detach 済み) のセッションでも、ask-sibling が使うなら
616
+ // 「新しい持ち主が付いた」とみなして回収予約を解除する。これをしないと
617
+ // 注入したターンの実行中に kill され、thread が汚染される (2026-07-17)。
618
+ this._cancelIdleReap(best, "sendToCwd (ask-sibling injection)", { readopt: true })
619
+ this.logger?.info?.(
620
+ {
621
+ cwd,
622
+ stream_id: best.stream_id,
623
+ thread_id: best.threadId,
624
+ busy: !!best.activeTurnId,
625
+ candidates: this.describeLiveSessions(cwd).length,
626
+ },
627
+ "codex sendToCwd: injecting into most-recently-active session",
628
+ )
629
+ this.input({ stream_id: best.stream_id, message: { role: "user", content: text } }).catch(
630
+ (err) => {
631
+ this.logger?.error(
632
+ { cwd, stream_id: best.stream_id, err: err?.message },
633
+ "sendToCwd: input threw unexpectedly",
634
+ )
635
+ },
636
+ )
637
+ return true
509
638
  }
510
639
 
511
- _maybeReapAfterIdle(session) {
640
+ /**
641
+ * detach 済み・非 busy なセッションに「アイドル TTL 後の回収」を予約する。
642
+ *
643
+ * ⚠️ 予約は `detachRequested` が立っているセッションのみ。ブラウザが繋がったままの
644
+ * セッションを turn/completed 契機で殺してはいけない (2026-07-17 の事故)。
645
+ * @param {object} session
646
+ * @param {string} trigger 予約理由 (ログ用: 'detach' | 'turn-completed-after-detach')
647
+ */
648
+ _maybeReapAfterIdle(session, trigger = "detach") {
649
+ if (!session.detachRequested) return // ブラウザが居る → 回収対象外
512
650
  if (session.activeTurnId) return // 完走まで待つ (turn/completed で再評価される)
513
651
  this._clearDetachTimer(session)
514
652
  session.detachTimer = setTimeout(() => {
515
653
  if (session.dead) return
654
+ // 発火時の再確認: TTL 中に ask-sibling 注入 (sendToCwd) で再活性化した、または
655
+ // 新しいターンが走り出したセッションを **実行中に kill しない** (rollout に
656
+ // output 欠落の custom_tool_call を残さないため)。
657
+ if (!session.detachRequested) {
658
+ this.logger?.info?.(
659
+ { stream_id: session.stream_id, thread_id: session.threadId, cwd: session.cwd },
660
+ "codex idle reap canceled at fire time (session re-adopted)",
661
+ )
662
+ session.detachTimer = null
663
+ return
664
+ }
665
+ if (session.activeTurnId) {
666
+ this.logger?.info?.(
667
+ {
668
+ stream_id: session.stream_id,
669
+ thread_id: session.threadId,
670
+ cwd: session.cwd,
671
+ turn_id: session.activeTurnId,
672
+ },
673
+ "codex idle reap deferred (turn in progress; will re-arm on turn/completed)",
674
+ )
675
+ session.detachTimer = null
676
+ return
677
+ }
678
+ this.logger?.info?.(
679
+ {
680
+ stream_id: session.stream_id,
681
+ thread_id: session.threadId,
682
+ cwd: session.cwd,
683
+ ttl_ms: this._detachTtlMs,
684
+ reason: "idle-detach-ttl",
685
+ },
686
+ "codex session reaped (detached and idle past TTL)",
687
+ )
516
688
  session.client.stop()
517
689
  }, this._detachTtlMs)
518
690
  session.detachTimer.unref?.()
691
+ this.logger?.info?.(
692
+ {
693
+ stream_id: session.stream_id,
694
+ thread_id: session.threadId,
695
+ cwd: session.cwd,
696
+ ttl_ms: this._detachTtlMs,
697
+ trigger,
698
+ },
699
+ "codex idle reap armed (browser detached)",
700
+ )
701
+ }
702
+
703
+ /**
704
+ * 予約済みのアイドル回収を取り消す。ブラウザ入力 / ask-sibling 注入 / 承認応答など
705
+ * 「このセッションはまだ使われている」証跡が出たら必ず呼ぶ。
706
+ * @param {object} session
707
+ * @param {string} reason ログ用
708
+ * @param {boolean} readopt true なら detachRequested も解除 (新しい持ち主が付いた)
709
+ */
710
+ _cancelIdleReap(session, reason, { readopt = false } = {}) {
711
+ const had = !!session.detachTimer
712
+ this._clearDetachTimer(session)
713
+ if (readopt) session.detachRequested = false
714
+ if (had) {
715
+ this.logger?.info?.(
716
+ { stream_id: session.stream_id, thread_id: session.threadId, cwd: session.cwd, reason },
717
+ "codex idle reap canceled (session active again)",
718
+ )
719
+ }
519
720
  }
520
721
 
521
722
  _clearDetachTimer(session) {
@@ -543,6 +744,18 @@ export class CodexStreamBridge extends EventEmitter {
543
744
  * 進行中の正常なターンは app-server 通知で都度 touch されるので回収されない。
544
745
  * main.mjs の state loop から claudeBridge.gcDeadTerminals と並べて定期実行する想定。
545
746
  *
747
+ * ⚠️ 既知のギャップ (2026-07-17、次段の keepalive PR で解消予定): この reaper は
748
+ * `detachRequested` を参照せず、`_touch()` は input() と app-server 通知でしか呼ばれない。
749
+ * そのため **ブラウザで Codex チャットを開いたままでも、1 時間まったく入力も生成も
750
+ * 無ければ回収される**。アイドル TTL(5 分) の誤爆を直しても、この 1h が次の上限になる。
751
+ * TTL の数字を大きくするのではなく「見ている」を観測可能にするのが正しい解:
752
+ * frontend/components/cockpit/CockpitTerminal.tsx の PTY keepalive (閲覧中は定期的に
753
+ * pty.resize で lastSeenAt を触り、ブラウザが離れれば keepalive も止まるので orphan GC が
754
+ * 正しく効く) と同じ構造を codex チャットへ移植する (codex.keepalive を新設し、backend
755
+ * consumers.py の中継テーブルと main.mjs の dispatch をセットで変更する)。そうすれば
756
+ * 「ブラウザ開 = 無期限 / 明示 detach = 5 分 / ブラウザ落ち = 1h reaper」になり、
757
+ * 「N 時間放置しても生きていてほしい」という数字合わせ自体が不要になる。
758
+ *
546
759
  * @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
547
760
  * @returns {number} 回収したセッション数。
548
761
  */
@@ -581,6 +794,20 @@ export class CodexStreamBridge extends EventEmitter {
581
794
  for (const session of this.sessions.values()) {
582
795
  this._clearDetachTimer(session)
583
796
  session.dead = true
797
+ // hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
798
+ // (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
799
+ this.logger?.[session.activeTurnId ? "warn" : "info"]?.(
800
+ {
801
+ stream_id: session.stream_id,
802
+ thread_id: session.threadId,
803
+ cwd: session.cwd,
804
+ mid_turn: !!session.activeTurnId,
805
+ reason: "agent-shutdown",
806
+ },
807
+ session.activeTurnId
808
+ ? "codex session killed MID-TURN by agent shutdown"
809
+ : "codex session stopped by agent shutdown",
810
+ )
584
811
  session.client.stop()
585
812
  }
586
813
  this.sessions.clear()