@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 +8 -0
- package/package.json +1 -1
- package/src/claude-stream-bridge.mjs +30 -0
- package/src/codex-stream-bridge.mjs +242 -15
- package/src/hub-bundle.mjs +562 -48
- package/src/main.mjs +188 -0
- package/src/sibling-inject.mjs +31 -1
- package/src/ws-client.mjs +29 -2
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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`
|
|
485
|
-
*
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
613
|
+
return false
|
|
507
614
|
}
|
|
508
|
-
|
|
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
|
-
|
|
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()
|