@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 +1 -1
- package/src/claude-stream-bridge.mjs +30 -0
- package/src/codex-stream-bridge.mjs +263 -15
- package/src/main.mjs +7 -0
- package/src/sibling-inject.mjs +31 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`
|
|
485
|
-
*
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
634
|
+
return false
|
|
507
635
|
}
|
|
508
|
-
|
|
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
|
-
|
|
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 のセッションピッカー/最新セッション自動選択用)。
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -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`)
|