@cocorograph/hub-agent 0.7.73 → 0.7.75
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 +111 -9
- package/src/codex-stream-bridge.mjs +216 -172
- package/src/main.mjs +13 -1
- package/src/sibling-inject.mjs +107 -14
package/package.json
CHANGED
|
@@ -1729,6 +1729,13 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1729
1729
|
this._mcpControlStarting = null
|
|
1730
1730
|
}
|
|
1731
1731
|
|
|
1732
|
+
/** _liveBySession の複合キー (T06271): cwd を含めることで、別 workspace の同一 session_id を
|
|
1733
|
+
* 別エントリに分離し、resume/reattach が cwd 境界を越えないことを保証する。session_id は
|
|
1734
|
+
* 本来ユニークだが、異常系 (別 cwd で同一 id) でも越境させないための機構キー。 */
|
|
1735
|
+
_liveKeyFor(cwd, sessionId) {
|
|
1736
|
+
return cwd && sessionId ? cwd + "\u0000" + sessionId : null
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1732
1739
|
/** 多端末共有: あるセッションに紐づく全端末 stream_id を sessions Map から外し、
|
|
1733
1740
|
* session_id 索引も掃除する。撤去 (reap / idle / shutdown) で 1 端末分だけ消すと
|
|
1734
1741
|
* 他端末キーが Map に残って孤児化するため、まとめて消す。 */
|
|
@@ -1741,8 +1748,9 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1741
1748
|
if (this.sessions.get(session.stream_id) === session) {
|
|
1742
1749
|
this.sessions.delete(session.stream_id)
|
|
1743
1750
|
}
|
|
1744
|
-
|
|
1745
|
-
|
|
1751
|
+
const curKey = this._liveKeyFor(session.cwd, session.sessionId)
|
|
1752
|
+
if (curKey && this._liveBySession.get(curKey) === session) {
|
|
1753
|
+
this._liveBySession.delete(curKey)
|
|
1746
1754
|
}
|
|
1747
1755
|
// SDK-P0-4 (2026-07-13): session_id 回転前に登録した旧キーも外す (現 sessionId しか見ないと
|
|
1748
1756
|
// 旧 id → 死んだ session のエントリが残り、attach が孤児を引き当てる)。
|
|
@@ -1765,8 +1773,25 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1765
1773
|
stopSessionsFor({ cwd, session_id } = {}) {
|
|
1766
1774
|
const victims = new Set()
|
|
1767
1775
|
if (session_id) {
|
|
1768
|
-
|
|
1769
|
-
|
|
1776
|
+
// 複合キー化 (T06271) で session_id 単独では引けないため values を走査して拾う。
|
|
1777
|
+
// ⚠️ cwd 境界 (T06271 レビュー6): cwd 指定時は session_id 照合も **同 cwd に限定** する。
|
|
1778
|
+
// 複合キー化後も別 cwd に同一 session_id の session が併存し得るため、全 cwd を union
|
|
1779
|
+
// すると A の cwd bind で B (別 cwd) の同一 R まで停止してしまう (実再現 stopped=2)。
|
|
1780
|
+
const idMatches = []
|
|
1781
|
+
for (const s of this._liveBySession.values()) {
|
|
1782
|
+
if (s.sessionId !== session_id) continue
|
|
1783
|
+
if (cwd && s.cwd !== cwd) continue
|
|
1784
|
+
idMatches.push(s)
|
|
1785
|
+
}
|
|
1786
|
+
if (!cwd && idMatches.length > 1) {
|
|
1787
|
+
// cwd 未指定で session_id が複数 cwd に一致 = どれを止めるべきか曖昧。推測停止しない。
|
|
1788
|
+
this.logger?.warn?.(
|
|
1789
|
+
{ session_id, matches: idMatches.length },
|
|
1790
|
+
"stopSessionsFor: session_id が複数 cwd に一致。cwd 未指定のため停止をスキップ (曖昧)",
|
|
1791
|
+
)
|
|
1792
|
+
} else {
|
|
1793
|
+
for (const s of idMatches) victims.add(s)
|
|
1794
|
+
}
|
|
1770
1795
|
}
|
|
1771
1796
|
if (cwd) {
|
|
1772
1797
|
for (const s of this._liveBySession.values()) {
|
|
@@ -1827,12 +1852,19 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1827
1852
|
if (this.sessions.has(stream_id)) {
|
|
1828
1853
|
throw new Error(`stream_id "${stream_id}" は既に attach 済みです`)
|
|
1829
1854
|
}
|
|
1855
|
+
// cwd を 1 回だけ解決し、reattach lookup / session 生成 / 索引登録で同じ値を使う
|
|
1856
|
+
// (複合キーの整合性を保つため)。
|
|
1857
|
+
const scwd = cwd || process.env.HOME || process.cwd()
|
|
1830
1858
|
// 常駐化: resume_session_id が示すセッションが (走行中 / idle 問わず) まだ生存していれば、
|
|
1831
1859
|
// 新規 query を起動せずそのセッションへ再アタッチする。これにより端末スリープ/再接続でも
|
|
1832
1860
|
// ターンは落ちず、以降のイベントは新しい stream_id 経由でライブに流れる
|
|
1833
1861
|
// (= 通常の生成中表示と同じ)。再接続前の確定分は browser の jsonl hydrate で復元する。
|
|
1834
1862
|
if (resumeSessionId) {
|
|
1835
|
-
|
|
1863
|
+
// ⚠️ cwd 境界 (T06271): _liveBySession は **複合キー (cwd+session_id)** なので、別 cwd の
|
|
1864
|
+
// 同一 session_id は別エントリに分離される。よって cwd=A の resume を cwd=B から
|
|
1865
|
+
// attach しても引き当たらず、越境 reattach が原理的に起きない (session_id は本来
|
|
1866
|
+
// ユニークだが異常系でも cwd を跨がない)。
|
|
1867
|
+
const live = this._liveBySession.get(this._liveKeyFor(scwd, resumeSessionId))
|
|
1836
1868
|
if (live && !live._closed) {
|
|
1837
1869
|
// 多端末共有 (CHAT_SHARED_ENABLED): 旧 stream_id を Map から消さない。同一セッションを
|
|
1838
1870
|
// 複数端末が購読する状態を作り、どの端末の input/permission/interrupt も解決できるように
|
|
@@ -1868,7 +1900,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1868
1900
|
}
|
|
1869
1901
|
const session = new ClaudeStreamSession({
|
|
1870
1902
|
stream_id,
|
|
1871
|
-
cwd:
|
|
1903
|
+
cwd: scwd,
|
|
1872
1904
|
model: model || null,
|
|
1873
1905
|
permissionMode: permissionMode || null,
|
|
1874
1906
|
maxTurns: typeof maxTurns === "number" ? maxTurns : null,
|
|
@@ -1894,14 +1926,17 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1894
1926
|
// 意図しない live session を引き当てる (+ Map リーク)。登録済みキーを覚えておき、
|
|
1895
1927
|
// 回転を検知したら旧キーを外してから張り替える。
|
|
1896
1928
|
if (session.sessionId) {
|
|
1929
|
+
// 複合キー (cwd+session_id)。別 cwd の同一 session_id は別キーになるため、B の onEvent が
|
|
1930
|
+
// A の索引を奪わない (T06271 レビュー5: cwd ガードだけでは onEvent の無条件 set が穴だった)。
|
|
1931
|
+
const key = this._liveKeyFor(session.cwd, session.sessionId)
|
|
1897
1932
|
const prevKey = session._liveKey
|
|
1898
|
-
if (prevKey && prevKey !==
|
|
1933
|
+
if (prevKey && prevKey !== key) {
|
|
1899
1934
|
if (this._liveBySession.get(prevKey) === session) {
|
|
1900
1935
|
this._liveBySession.delete(prevKey)
|
|
1901
1936
|
}
|
|
1902
1937
|
}
|
|
1903
|
-
session._liveKey =
|
|
1904
|
-
this._liveBySession.set(
|
|
1938
|
+
session._liveKey = key
|
|
1939
|
+
this._liveBySession.set(key, session)
|
|
1905
1940
|
}
|
|
1906
1941
|
},
|
|
1907
1942
|
onPermission: ({ tool_name, input, request_id }) => {
|
|
@@ -1955,6 +1990,18 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
1955
1990
|
},
|
|
1956
1991
|
})
|
|
1957
1992
|
this.sessions.set(stream_id, session)
|
|
1993
|
+
// ⚠️ resume 索引を attach 時に **同期登録** する (T06271 レビュー3 major①)。従来は onEvent
|
|
1994
|
+
// (SDK が session_id を確定させた後) でしか _liveBySession に載せていなかったため、input 前に
|
|
1995
|
+
// 同一 resumeSessionId で 2 回 attach すると「索引が空の窓」で上の reattach 判定 (1835) をすり抜け、
|
|
1996
|
+
// 同じ session_id の別 session が 2 本でき、sendToStream が先頭へ推測注入していた。attach は
|
|
1997
|
+
// get(reattach)→set まで await を挟まず同期なので、ここで登録すれば 2 本目の attach は必ず
|
|
1998
|
+
// reattach 経路に入り重複が生じない。session_id が回転 (clear/compaction) したら onEvent が張り替える。
|
|
1999
|
+
// ⚠️ 複合キー (cwd+session_id) で登録するため、別 cwd の同一 session_id を奪わない (T06271)。
|
|
2000
|
+
if (resumeSessionId) {
|
|
2001
|
+
const key = this._liveKeyFor(scwd, resumeSessionId)
|
|
2002
|
+
session._liveKey = key
|
|
2003
|
+
this._liveBySession.set(key, session)
|
|
2004
|
+
}
|
|
1958
2005
|
this.logger?.info(
|
|
1959
2006
|
{ stream_id, cwd: session.cwd, model: session.model, resume: !!resumeSessionId },
|
|
1960
2007
|
"claude stream attached",
|
|
@@ -2303,6 +2350,61 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2303
2350
|
return true
|
|
2304
2351
|
}
|
|
2305
2352
|
|
|
2353
|
+
/**
|
|
2354
|
+
* 指定 session_id のライブセッションへメッセージを直接注入する (sibling-inject の
|
|
2355
|
+
* **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd は「cwd 内で最終活性が最新の
|
|
2356
|
+
* もの」をヒューリスティックに選ぶため、同一 cwd に複数の生きた claude セッションが
|
|
2357
|
+
* 同居すると意図しないセッション (詰まった巨大セッション等) へ誤爆する。呼び出し側が
|
|
2358
|
+
* describeLiveSessions で得た session_id を明示すれば、この曖昧さを回避できる。
|
|
2359
|
+
*
|
|
2360
|
+
* ⚠️ セキュリティ (cwd 境界): 呼び出し元 sibling-inject.mjs のトークンは **cwd スコープ**
|
|
2361
|
+
* (`verifySiblingToken(cwd, token)`) のため、cwd=A のトークンで cwd=B の session_id を
|
|
2362
|
+
* 指定した越境注入を防ぐには、**このメソッド側で cwd 一致を必須化する**必要がある
|
|
2363
|
+
* (session_id だけで全 sessions を走査すると、別 cwd のセッションへ注入できてしまう)。
|
|
2364
|
+
* よって cwd と session_id の **両方** が一致した場合のみ注入する。
|
|
2365
|
+
*
|
|
2366
|
+
* ⚠️ 一意性 (T06271 レビュー3): session_id は「1 論理セッションに一意」なはずだが、resume
|
|
2367
|
+
* 索引 (`_liveBySession`) を **attach 時に同期登録**するようにして初めて成立する不変条件。
|
|
2368
|
+
* それでも競合の防御網として、同一 cwd で session_id が複数一致したら **推測せず ambiguous**
|
|
2369
|
+
* を返す (codex の thread_id 重複と対称)。
|
|
2370
|
+
*
|
|
2371
|
+
* @param {string} cwd 呼び出し元がトークン検証した cwd (この cwd のセッションにのみ注入)
|
|
2372
|
+
* @param {string} sessionId 宛先 session_id (describeLiveSessions の session_id)
|
|
2373
|
+
* @param {string} text
|
|
2374
|
+
* @returns {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
|
|
2375
|
+
*/
|
|
2376
|
+
sendToStream(cwd, sessionId, text) {
|
|
2377
|
+
if (!cwd || !sessionId || !text) return { status: "not_found" }
|
|
2378
|
+
const now = Date.now()
|
|
2379
|
+
const seen = new Set()
|
|
2380
|
+
const matches = []
|
|
2381
|
+
for (const session of this.sessions.values()) {
|
|
2382
|
+
if (seen.has(session)) continue
|
|
2383
|
+
seen.add(session)
|
|
2384
|
+
if (session.cwd !== cwd || session.sessionId !== sessionId) continue
|
|
2385
|
+
matches.push(session)
|
|
2386
|
+
}
|
|
2387
|
+
if (matches.length === 0) return { status: "not_found" }
|
|
2388
|
+
const describe = (s) => {
|
|
2389
|
+
let at = 0
|
|
2390
|
+
for (const t of s.lastActivityByStream?.values?.() ?? []) {
|
|
2391
|
+
if (t > at) at = t
|
|
2392
|
+
}
|
|
2393
|
+
return { kind: "claude", cwd: s.cwd, session_id: s.sessionId, idle_ms: at ? now - at : -1 }
|
|
2394
|
+
}
|
|
2395
|
+
if (matches.length > 1) {
|
|
2396
|
+
// 同一 session_id の別 session が複数 = resume 競合。推測せず ambiguous (誤爆防止)。
|
|
2397
|
+
return { status: "ambiguous", candidates: matches.map(describe) }
|
|
2398
|
+
}
|
|
2399
|
+
matches[0].sendMessage({ role: "user", content: text }).catch((err) => {
|
|
2400
|
+
this.logger?.error(
|
|
2401
|
+
{ session_id: sessionId, err: err?.message },
|
|
2402
|
+
"sendToStream: sendMessage threw unexpectedly",
|
|
2403
|
+
)
|
|
2404
|
+
})
|
|
2405
|
+
return { status: "delivered", session: describe(matches[0]) }
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2306
2408
|
/**
|
|
2307
2409
|
* `mcpServerStatus()` の結果に、needs-auth キャッシュにあるが結果へ含まれない
|
|
2308
2410
|
* サーバーを `needs-auth` として補完する。
|
|
@@ -61,35 +61,42 @@ function siblingEnvFor(cwd) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
64
|
+
// ── Stage 5 (2026-07-19): bounded warm pool + 長 idle TTL への再設計 ───────────
|
|
65
|
+
// 旧設計: detach → **5 分** の per-session タイマーで reap(`_maybeReapAfterIdle`)+
|
|
66
|
+
// 1h の無活性 reaper(`gcAbandonedSessions`)。これは codex-review のように 1 ターンが
|
|
67
|
+
// 数分〜数十分かかり、かつ **別 CLI 画面(claude チャット)から呼ぶため codex 画面が非
|
|
68
|
+
// アクティブ = detach 済み**になる用途で、「ターン完了後 5 分の idle で reaped → 次の依頼が
|
|
69
|
+
// 届かない」構造的ミスマッチを生んでいた(2026-07-19 調査)。
|
|
70
|
+
//
|
|
71
|
+
// 新設計: **周期 reconcile 一本化**(`reconcileSessions`、state loop から ~5s 毎)。
|
|
72
|
+
// - detach してもすぐ殺さず、**長 idle TTL(既定 7 日、claude softDetach と同思想)** で
|
|
73
|
+
// warm 保持する。
|
|
74
|
+
// - 際限ないプロセス蓄積は **LRU 件数上限(既定 5、マシン全体)** で防ぐ。6 個目が来たら
|
|
75
|
+
// 最も使っていない **非 protected** セッションを 1 個 reap。旧「5 分で殺す」の代わりに
|
|
76
|
+
// 「上限で天井を作る」ことで、TTL を長くしてもリークしない(T06500 の churn 削減も前提)。
|
|
77
|
+
// - **busy(healthy running turn)は絶対に reap しない**(TTL でも LRU でも)。
|
|
78
|
+
// - **stuck turn**(activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた=ハング)は
|
|
79
|
+
// protected から外れ、別途 reap 対象(プロセスリーク・固着の救済)。
|
|
80
|
+
// これにより意味論は「**閲覧中=無期限(keepalive で LRU 先頭維持)/ 直近 5 セッションは
|
|
81
|
+
// detach しても 7 日 warm / 上限超過は LRU で 1 個 reap / busy は不可侵 / stuck は救済**」。
|
|
82
|
+
|
|
83
|
+
// warm 保持の idle TTL(非 busy がこの時間 無活性なら reap の対象。上限内でも 7 日で頭打ち)。
|
|
84
|
+
const IDLE_DETACH_TTL_MS =
|
|
85
|
+
Number(process.env.HUB_AGENT_CODEX_IDLE_DETACH_TTL_MS) || 7 * 24 * 60 * 60 * 1000
|
|
86
|
+
|
|
87
|
+
// LRU warm pool の上限(マシン全体で同時に生かす codex セッション数)。超過分は最も使って
|
|
88
|
+
// いない非 protected セッションから reap。1 セッション ≈ 120-140MB(node ラッパー + 本体 +
|
|
89
|
+
// MCP 子)なので既定 5(~650MB)。env で調整可。
|
|
90
|
+
const WARM_POOL_MAX = Number(process.env.HUB_AGENT_CODEX_WARM_POOL_MAX) || 5
|
|
83
91
|
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) || 60 * 60 * 1000
|
|
92
|
+
// stuck turn 検出の閾値。activeTurnId が立っているのに app-server 通知がこの時間途絶えた
|
|
93
|
+
// セッションは「ハングしたターン」とみなし protected から外して reap する(healthy な長ターンは
|
|
94
|
+
// reasoning/output delta で都度 touch されるため該当しない)。旧 `HUB_AGENT_DEAD_TERMINAL_TTL_MS`
|
|
95
|
+
// を後方互換で受ける。既定 1h。
|
|
96
|
+
const STUCK_TURN_TTL_MS =
|
|
97
|
+
Number(process.env.HUB_AGENT_CODEX_STUCK_TURN_TTL_MS) ||
|
|
98
|
+
Number(process.env.HUB_AGENT_DEAD_TERMINAL_TTL_MS) ||
|
|
99
|
+
60 * 60 * 1000
|
|
93
100
|
|
|
94
101
|
const CLIENT_INFO = { name: "cockpit-hub-agent", title: "Cockpit", version: "1.0.0" }
|
|
95
102
|
|
|
@@ -235,6 +242,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
235
242
|
logger,
|
|
236
243
|
clientFactory,
|
|
237
244
|
detachTtlMs,
|
|
245
|
+
warmPoolMax,
|
|
246
|
+
stuckTurnTtlMs,
|
|
238
247
|
rolloutPresence,
|
|
239
248
|
historyMaxItems,
|
|
240
249
|
historyMaxBytes,
|
|
@@ -243,8 +252,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
243
252
|
this.codexCmd = codexCmd
|
|
244
253
|
this.logger = logger
|
|
245
254
|
this._clientFactory = clientFactory || ((opts) => new CodexAppServerClient(opts))
|
|
246
|
-
//
|
|
255
|
+
// Stage 5: warm 保持の idle TTL(既定 7 日)。テストから短縮できるよう注入可能。
|
|
247
256
|
this._detachTtlMs = detachTtlMs ?? IDLE_DETACH_TTL_MS
|
|
257
|
+
// Stage 5: LRU warm pool 上限(既定 5)。テストから小さい値を注入可能。
|
|
258
|
+
this._warmPoolMax = warmPoolMax ?? WARM_POOL_MAX
|
|
259
|
+
// Stage 5: stuck turn 検出 TTL(既定 1h)。テストから短縮可能。
|
|
260
|
+
this._stuckTurnTtlMs = stuckTurnTtlMs ?? STUCK_TURN_TTL_MS
|
|
248
261
|
// #4 失敗分類の rollout 存在チェック (tri-state。テストから注入可能)。
|
|
249
262
|
this._rolloutPresence = rolloutPresence || codexRolloutPresence
|
|
250
263
|
// T06499: 履歴 hydrate の上限 (テストから小さい値を注入可能)。
|
|
@@ -318,14 +331,15 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
318
331
|
threadId: null,
|
|
319
332
|
activeTurnId: null,
|
|
320
333
|
pendingApprovals: new Map(), // request_id -> { turnId }
|
|
321
|
-
|
|
322
|
-
//
|
|
323
|
-
//
|
|
334
|
+
// ブラウザから codex.detach を受け取った (= 画面の持ち主が居なくなった) か。診断・ログ用。
|
|
335
|
+
// Stage 5: 回収の可否は detachRequested ではなく lastActivityMs(LRU)と activeTurnId
|
|
336
|
+
// (busy/stuck)で決める。detach してもすぐには殺さず warm 保持する。
|
|
324
337
|
// ask-sibling 注入 (sendToCwd) は「新しい持ち主が付いた」とみなして false に戻す。
|
|
325
338
|
detachRequested: false,
|
|
326
339
|
dead: false,
|
|
327
|
-
//
|
|
328
|
-
//
|
|
340
|
+
// Stage 5 reconcile(LRU / idle TTL / stuck)用の最終活性時刻。browser 入力と app-server
|
|
341
|
+
// 通知 (ターン進捗) の両方で更新する。LRU は「最終活性が最も古い非 protected」を退避する。
|
|
342
|
+
// Date.now() は hub-agent 実行環境で利用可。
|
|
329
343
|
lastActivityMs: Date.now(),
|
|
330
344
|
ready: null,
|
|
331
345
|
}
|
|
@@ -389,6 +403,10 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
389
403
|
},
|
|
390
404
|
"codex session attached",
|
|
391
405
|
)
|
|
406
|
+
// Stage 5: genuine attach で 1 プロセス増えたので、LRU 上限の超過分を即時退避する
|
|
407
|
+
// (次の reconnect まで ~5s 待たず、6 個目の app-server が居座るのを防ぐ)。新セッションは
|
|
408
|
+
// たった今 touch 済み = LRU 先頭なので退避対象にならず、最も古い非 protected が落ちる。
|
|
409
|
+
this._evictWarmPoolOverflow(Date.now(), "lru-evict-on-attach")
|
|
392
410
|
return {
|
|
393
411
|
resuming,
|
|
394
412
|
threadId: session.threadId,
|
|
@@ -444,7 +462,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
444
462
|
this.sessions.delete(originalStreamId)
|
|
445
463
|
session.stream_id = newStreamId
|
|
446
464
|
session.detachRequested = false
|
|
447
|
-
this._cancelIdleReap(session, "reattach")
|
|
448
465
|
session.lastActivityMs = Date.now()
|
|
449
466
|
this.sessions.set(newStreamId, session)
|
|
450
467
|
this.logger?.info?.(
|
|
@@ -573,9 +590,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
573
590
|
case "turn/completed":
|
|
574
591
|
session.activeTurnId = null
|
|
575
592
|
this._autoResolveStalePendingApprovals(session, msg.params?.turn?.id)
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
this._maybeReapAfterIdle(session, "turn-completed-after-detach")
|
|
593
|
+
// Stage 5: turn 完了後の reap 予約は不要。以降 idle になれば周期 reconcile が
|
|
594
|
+
// 長 idle TTL / LRU で扱う(detach 済みでも 7 日 warm 保持)。
|
|
579
595
|
break
|
|
580
596
|
default:
|
|
581
597
|
break
|
|
@@ -615,7 +631,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
615
631
|
_onClientExit(session, info) {
|
|
616
632
|
if (session.dead) return
|
|
617
633
|
session.dead = true
|
|
618
|
-
this._clearDetachTimer(session)
|
|
619
634
|
this.sessions.delete(session.stream_id)
|
|
620
635
|
// app-server の終了は必ず記録する。ターン実行中の exit は「rollout に output 欠落の
|
|
621
636
|
// custom_tool_call を残した可能性がある」= thread 汚染の予兆なので warn で出す
|
|
@@ -665,11 +680,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
665
680
|
*/
|
|
666
681
|
async input({ stream_id, message, model, effort }) {
|
|
667
682
|
const session = this._require(stream_id)
|
|
683
|
+
// Stage 5: _touch で lastActivityMs を更新 = LRU 先頭へ。ターン開始で activeTurnId が
|
|
684
|
+
// 立ち、reconcile は healthy busy を protected 扱いにするので実行中に kill されない。
|
|
668
685
|
this._touch(session)
|
|
669
|
-
// TTL 待ちのセッションに新ターンを流す = まだ使われている。予約を解除しないと
|
|
670
|
-
// ターン実行中 (exec 中) に kill され、rollout に output 欠落の custom_tool_call が
|
|
671
|
-
// 残って thread が汚染される (2026-07-17 の事故)。
|
|
672
|
-
this._cancelIdleReap(session, "input")
|
|
673
686
|
const input = userInputFromMessage(message)
|
|
674
687
|
if (session.activeTurnId) {
|
|
675
688
|
try {
|
|
@@ -718,7 +731,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
718
731
|
async interrupt({ stream_id }) {
|
|
719
732
|
const session = this._require(stream_id)
|
|
720
733
|
this._touch(session)
|
|
721
|
-
this._cancelIdleReap(session, "interrupt")
|
|
722
734
|
if (!session.activeTurnId) return
|
|
723
735
|
await session.client.request("turn/interrupt", {
|
|
724
736
|
threadId: session.threadId,
|
|
@@ -730,7 +742,6 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
730
742
|
permissionReply({ stream_id, request_id, result }) {
|
|
731
743
|
const session = this._require(stream_id)
|
|
732
744
|
this._touch(session)
|
|
733
|
-
this._cancelIdleReap(session, "permission-reply")
|
|
734
745
|
if (!session.pendingApprovals.has(request_id)) {
|
|
735
746
|
this.logger?.warn?.({ stream_id, request_id }, "codex permissionReply: unknown request_id (ignored)")
|
|
736
747
|
return
|
|
@@ -742,13 +753,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
742
753
|
/**
|
|
743
754
|
* ブラウザ閲覧中の生存シグナル (codex.keepalive)。frontend が Codex チャットを開いて
|
|
744
755
|
* いる間だけ定期送信するため、これが届く = 「人が見ている」の観測可能な証跡になる。
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
* ⚠️ 意図的に `_cancelIdleReap` を **呼ばない**。detach 済みセッションの 5 分 TTL は
|
|
749
|
-
* 「ブラウザが去った」という別事実に基づく予約であり、detach と in-flight keepalive が
|
|
750
|
-
* 競合しても予約を解除しない (= 明示 detach は必ず 5 分で回収される) 方が意味論が明快。
|
|
751
|
-
* 閲覧を再開したブラウザは必ず attach を送り直すので、再養子はそちらの経路で足りる。
|
|
756
|
+
* Stage 5: touch で lastActivityMs を更新すると LRU 先頭に留まるため、**閲覧中のセッションは
|
|
757
|
+
* 上限超過の LRU 退避対象にならず、長 idle TTL でも殺されない**(= 実質無期限)。ブラウザを
|
|
758
|
+
* 閉じれば keepalive も止まり、以降は detach 済み warm として LRU / 7 日 idle TTL に従う。
|
|
752
759
|
*
|
|
753
760
|
* @returns {boolean} 生きているセッションを touch できたら true (未知 stream は false)。
|
|
754
761
|
*/
|
|
@@ -760,9 +767,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
760
767
|
}
|
|
761
768
|
|
|
762
769
|
/**
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
770
|
+
* グレースフル切断(Stage 5)。**すぐには reap しない**。detachRequested を立てて
|
|
771
|
+
* warm 保持し、以降は周期 reconcile(LRU 上限 / 長 idle TTL / stuck)に委ねる。これにより
|
|
772
|
+
* 「別 CLI 画面へ切替えた瞬間 5 分で殺される」問題を解消し、直近 5 セッションは 7 日 warm に。
|
|
766
773
|
*/
|
|
767
774
|
detach({ stream_id }) {
|
|
768
775
|
const session = this.sessions.get(stream_id)
|
|
@@ -775,9 +782,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
775
782
|
cwd: session.cwd,
|
|
776
783
|
busy: !!session.activeTurnId,
|
|
777
784
|
},
|
|
778
|
-
"codex detach requested by browser",
|
|
785
|
+
"codex detach requested by browser (kept warm; reconcile handles reap)",
|
|
779
786
|
)
|
|
780
|
-
this._maybeReapAfterIdle(session, "detach")
|
|
781
787
|
}
|
|
782
788
|
|
|
783
789
|
/**
|
|
@@ -862,10 +868,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
862
868
|
)
|
|
863
869
|
return false
|
|
864
870
|
}
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
|
|
871
|
+
// detach 済み warm セッションでも、ask-sibling が使うなら「新しい持ち主が付いた」とみなして
|
|
872
|
+
// detachRequested を解除し touch する(LRU 先頭へ)。this.input が改めて _touch するが、注入前に
|
|
873
|
+
// 明示しておく。ターン実行中は healthy busy = protected なので reconcile で kill されない。
|
|
874
|
+
best.detachRequested = false
|
|
875
|
+
this._touch(best)
|
|
869
876
|
this.logger?.info?.(
|
|
870
877
|
{
|
|
871
878
|
cwd,
|
|
@@ -888,92 +895,144 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
888
895
|
}
|
|
889
896
|
|
|
890
897
|
/**
|
|
891
|
-
*
|
|
898
|
+
* 指定した id (stream_id もしくは thread_id) のライブセッションへ直接注入する
|
|
899
|
+
* (sibling-inject の **明示宛先指定** 用、2026-07-19 / T06271)。sendToCwd は cwd 内で
|
|
900
|
+
* 最終活性が最新のものを選ぶヒューリスティックのため、同一 cwd に複数の生きた codex
|
|
901
|
+
* セッションが同居すると誤爆する。呼び出し側が describeLiveSessions で得た id を明示
|
|
902
|
+
* すれば曖昧さを回避できる。
|
|
892
903
|
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
*
|
|
904
|
+
* stream_id は WS 再接続 / in-memory reattach で付け替わる (不安定) 一方、thread_id は
|
|
905
|
+
* 会話に固定 (安定) なので、**両方を受け付け** どちらで指定しても解決できるようにする。
|
|
906
|
+
* Claude 版 `sendToStream` の対。
|
|
907
|
+
*
|
|
908
|
+
* ⚠️ セキュリティ (cwd 境界): 呼び出し元 sibling-inject.mjs のトークンは **cwd スコープ**
|
|
909
|
+
* のため、cwd=A のトークンで cwd=B の stream_id/thread_id を指定した越境注入を防ぐには、
|
|
910
|
+
* **このメソッド側で cwd 一致を必須化する** (id だけで全 sessions を走査すると別 cwd の
|
|
911
|
+
* セッションへ注入できてしまう)。cwd と id の **両方** が一致した場合のみ注入する。
|
|
912
|
+
*
|
|
913
|
+
* ⚠️ 一意性 (T06271 レビュー major③): stream_id は各 live app-server で一意だが、
|
|
914
|
+
* **thread_id は一意でない** — 同一 thread を複数回 resume/handover すると同じ thread_id を
|
|
915
|
+
* 持つ live セッションが同一 cwd に複数併存し得る。そのため:
|
|
916
|
+
* 1. まず stream_id の完全一致を優先 (常に一意に定まる)
|
|
917
|
+
* 2. stream_id で当たらなければ thread_id で照合し、**複数一致なら注入せず ambiguous を返す**
|
|
918
|
+
* (Map 先頭を選ぶと明示指定が再び推測になるため)
|
|
919
|
+
*
|
|
920
|
+
* @param {string} cwd 呼び出し元がトークン検証した cwd (この cwd のセッションにのみ注入)
|
|
921
|
+
* @param {string} id 宛先 stream_id (一意・推奨) または thread_id
|
|
922
|
+
* @param {string} text
|
|
923
|
+
* @returns {{status: "delivered", session: object} | {status: "not_found"} | {status: "ambiguous", candidates: object[]}}
|
|
897
924
|
*/
|
|
898
|
-
|
|
899
|
-
if (!
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
session
|
|
903
|
-
if (session.dead)
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if (!session.detachRequested) {
|
|
908
|
-
this.logger?.info?.(
|
|
909
|
-
{ stream_id: session.stream_id, thread_id: session.threadId, cwd: session.cwd },
|
|
910
|
-
"codex idle reap canceled at fire time (session re-adopted)",
|
|
911
|
-
)
|
|
912
|
-
session.detachTimer = null
|
|
913
|
-
return
|
|
925
|
+
sendToStream(cwd, id, text) {
|
|
926
|
+
if (!cwd || !id || !text) return { status: "not_found" }
|
|
927
|
+
let bySid = null
|
|
928
|
+
const byThread = []
|
|
929
|
+
for (const session of this.sessions.values()) {
|
|
930
|
+
if (session.dead || session.cwd !== cwd) continue
|
|
931
|
+
if (session.stream_id === id) {
|
|
932
|
+
bySid = session
|
|
933
|
+
break // stream_id は一意なので即確定
|
|
914
934
|
}
|
|
915
|
-
if (session.
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
turn_id: session.activeTurnId,
|
|
922
|
-
},
|
|
923
|
-
"codex idle reap deferred (turn in progress; will re-arm on turn/completed)",
|
|
924
|
-
)
|
|
925
|
-
session.detachTimer = null
|
|
926
|
-
return
|
|
935
|
+
if (session.threadId === id) byThread.push(session)
|
|
936
|
+
}
|
|
937
|
+
let target = bySid
|
|
938
|
+
if (!target) {
|
|
939
|
+
if (byThread.length > 1) {
|
|
940
|
+
return { status: "ambiguous", candidates: byThread.map((s) => this._describe(s)) }
|
|
927
941
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
942
|
+
target = byThread[0] || null
|
|
943
|
+
}
|
|
944
|
+
if (!target) return { status: "not_found" }
|
|
945
|
+
// detach 済み warm セッションでも、明示指定で使うなら「新しい持ち主が付いた」とみなす
|
|
946
|
+
// (sendToCwd と同じ扱い)。
|
|
947
|
+
target.detachRequested = false
|
|
948
|
+
this._touch(target)
|
|
949
|
+
this.input({ stream_id: target.stream_id, message: { role: "user", content: text } }).catch(
|
|
950
|
+
(err) => {
|
|
951
|
+
this.logger?.error(
|
|
952
|
+
{ id, stream_id: target.stream_id, err: err?.message },
|
|
953
|
+
"sendToStream: input threw unexpectedly",
|
|
954
|
+
)
|
|
955
|
+
},
|
|
956
|
+
)
|
|
957
|
+
return { status: "delivered", session: this._describe(target) }
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** describeLiveSessions と同形の 1 セッション記述子 (sendToStream の結果整形に共用)。 */
|
|
961
|
+
_describe(session) {
|
|
962
|
+
return {
|
|
963
|
+
kind: "codex",
|
|
964
|
+
cwd: session.cwd,
|
|
965
|
+
stream_id: session.stream_id,
|
|
966
|
+
thread_id: session.threadId || null,
|
|
967
|
+
busy: !!session.activeTurnId,
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Stage 5: セッションが「保護対象(reap 不可)」か。= **healthy な running turn**。
|
|
973
|
+
* activeTurnId が立っており、かつ app-server 通知が STUCK_TTL 以内に届いている(=生きている
|
|
974
|
+
* ターン)。activeTurnId が固着したまま通知が STUCK_TTL 途絶えたもの(stuck/ハング)は
|
|
975
|
+
* protected ではなく reap 対象にする(プロセスリーク・固着の救済)。
|
|
976
|
+
* @param {object} session
|
|
977
|
+
* @param {number} now
|
|
978
|
+
* @returns {boolean}
|
|
979
|
+
*/
|
|
980
|
+
_isProtected(session, now) {
|
|
981
|
+
if (!session.activeTurnId) return false
|
|
982
|
+
return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** 実際に app-server を止めてセッションを撤去し exit を emit する(reconcile 共通処理)。 */
|
|
986
|
+
_reapSession(session, now, reason) {
|
|
987
|
+
if (session.dead) return
|
|
988
|
+
session.dead = true
|
|
989
|
+
this.sessions.delete(session.stream_id)
|
|
990
|
+
try {
|
|
938
991
|
session.client.stop()
|
|
939
|
-
}
|
|
940
|
-
|
|
992
|
+
} catch (err) {
|
|
993
|
+
this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "codex reap: stop threw")
|
|
994
|
+
}
|
|
941
995
|
this.logger?.info?.(
|
|
942
996
|
{
|
|
943
997
|
stream_id: session.stream_id,
|
|
944
998
|
thread_id: session.threadId,
|
|
945
999
|
cwd: session.cwd,
|
|
946
|
-
|
|
947
|
-
|
|
1000
|
+
reason,
|
|
1001
|
+
idle_ms: now - (session.lastActivityMs ?? now),
|
|
1002
|
+
live_after: this.sessions.size,
|
|
948
1003
|
},
|
|
949
|
-
"codex
|
|
1004
|
+
"codex session reaped",
|
|
950
1005
|
)
|
|
1006
|
+
this.emit("exit", {
|
|
1007
|
+
stream_id: session.stream_id,
|
|
1008
|
+
thread_id: session.threadId,
|
|
1009
|
+
code: 0,
|
|
1010
|
+
reason: "idle-reaped",
|
|
1011
|
+
})
|
|
951
1012
|
}
|
|
952
1013
|
|
|
953
1014
|
/**
|
|
954
|
-
*
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
* @param {
|
|
1015
|
+
* Stage 5: LRU warm pool 上限の超過分を退避する。生存(非 dead)セッションが上限を超えたら、
|
|
1016
|
+
* **非 protected の中で最終活性が最も古いもの**から順に上限まで reap する。protected(healthy
|
|
1017
|
+
* running turn)は退避対象にしないため、全部が busy のときは一時的に上限を超えたまま許容する
|
|
1018
|
+
* (走行中ターンを絶対に殺さない)。genuine attach 直後と reconcile の両方から呼ぶ。
|
|
1019
|
+
* @param {number} now
|
|
1020
|
+
* @returns {number} 退避した数
|
|
959
1021
|
*/
|
|
960
|
-
|
|
961
|
-
const
|
|
962
|
-
this.
|
|
963
|
-
if (
|
|
964
|
-
|
|
965
|
-
this.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
_clearDetachTimer(session) {
|
|
973
|
-
if (session.detachTimer) {
|
|
974
|
-
clearTimeout(session.detachTimer)
|
|
975
|
-
session.detachTimer = null
|
|
1022
|
+
_evictWarmPoolOverflow(now, reason = "lru-evict") {
|
|
1023
|
+
const live = Array.from(this.sessions.values()).filter((s) => !s.dead)
|
|
1024
|
+
const overflow = live.length - this._warmPoolMax
|
|
1025
|
+
if (overflow <= 0) return 0
|
|
1026
|
+
const evictable = live
|
|
1027
|
+
.filter((s) => !this._isProtected(s, now))
|
|
1028
|
+
.sort((a, b) => (a.lastActivityMs ?? 0) - (b.lastActivityMs ?? 0)) // 古い順
|
|
1029
|
+
let evicted = 0
|
|
1030
|
+
for (const s of evictable) {
|
|
1031
|
+
if (evicted >= overflow) break
|
|
1032
|
+
this._reapSession(s, now, reason)
|
|
1033
|
+
evicted += 1
|
|
976
1034
|
}
|
|
1035
|
+
return evicted
|
|
977
1036
|
}
|
|
978
1037
|
|
|
979
1038
|
_require(stream_id) {
|
|
@@ -985,64 +1044,49 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
985
1044
|
}
|
|
986
1045
|
|
|
987
1046
|
/**
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
992
|
-
*
|
|
1047
|
+
* Stage 5: セッションの周期 reconcile(state loop から ~5s 毎に実行)。以下を 1 パスで行う:
|
|
1048
|
+
* 1. **stuck turn**: activeTurnId 固着だが app-server 通知が STUCK_TTL 途絶えた(ハング)→ reap
|
|
1049
|
+
* (healthy な長ターンは reasoning/output delta で touch されるため該当しない)。
|
|
1050
|
+
* 2. **long idle**: 非 busy が idle TTL(既定 7 日)無活性 → reap(warm 保持の期限切れ)。
|
|
1051
|
+
* 3. **LRU 上限**: 生存数が warmPoolMax(既定 5)を超えたら、非 protected の最終活性が
|
|
1052
|
+
* 最も古いものから上限まで reap。
|
|
1053
|
+
* busy(healthy running turn)は 1〜3 いずれの対象にもならない(走行中ターンを絶対に殺さない)。
|
|
993
1054
|
*
|
|
994
|
-
*
|
|
995
|
-
* main.mjs の state loop
|
|
1055
|
+
* 旧 `gcAbandonedSessions`(1h 無活性一律 reap)を置換。detach 済み warm セッションを 1h で
|
|
1056
|
+
* 殺していた挙動を廃し、上限+長 TTL に一本化した。main.mjs の state loop 呼び出しは
|
|
1057
|
+
* 後方互換のため `gcAbandonedSessions` エイリアスを残す。
|
|
996
1058
|
*
|
|
997
|
-
* この reaper は `detachRequested` を **意図的に参照しない**。detach を送れずに消えた
|
|
998
|
-
* ブラウザ (タブごとクラッシュ / 端末スリープ) は detachRequested が立たないまま残るため、
|
|
999
|
-
* ここでフラグを見ると回収不能になりリークする。「無通信が続いた事実」だけで回収する。
|
|
1000
|
-
*
|
|
1001
|
-
* 閲覧中の誤爆は keepalive で防ぐ (2026-07-17 に解消。旧: 開いたままでも 1h 無入力で
|
|
1002
|
-
* 回収されていた)。frontend は Codex チャットを開いている間 codex.keepalive を定期送信し、
|
|
1003
|
-
* keepalive() が lastActivityMs を touch する。ブラウザが閉じれば keepalive も止まるので
|
|
1004
|
-
* この reaper は本来の orphan GC として正しく効く。CockpitTerminal.tsx の PTY keepalive
|
|
1005
|
-
* (pty.resize で lastSeenAt を触る) と同じ構造。結果として TTL の意味は
|
|
1006
|
-
* 「ブラウザ開 = 無期限 / 明示 detach = 5 分 / ブラウザ落ち = 1h reaper」に整理され、
|
|
1007
|
-
* 「N 時間放置しても生きていてほしい」という数字合わせ自体が不要になった。
|
|
1008
|
-
*
|
|
1009
|
-
* @param {number} [ttlMs] 無通信とみなす閾値 (テスト用に上書き可)。
|
|
1010
1059
|
* @returns {number} 回収したセッション数。
|
|
1011
1060
|
*/
|
|
1012
|
-
|
|
1061
|
+
reconcileSessions() {
|
|
1013
1062
|
const now = Date.now()
|
|
1014
1063
|
let reaped = 0
|
|
1064
|
+
// 1〜2: stuck turn / long idle。protected(healthy running turn)はスキップ。
|
|
1015
1065
|
for (const session of Array.from(this.sessions.values())) {
|
|
1016
1066
|
if (session.dead) continue
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
this.
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
this.logger?.warn?.({ stream_id: session.stream_id, err: err?.message }, "gcAbandonedSessions: stop threw")
|
|
1067
|
+
if (this._isProtected(session, now)) continue
|
|
1068
|
+
const idle = now - (session.lastActivityMs ?? now)
|
|
1069
|
+
if (session.activeTurnId && idle > this._stuckTurnTtlMs) {
|
|
1070
|
+
this._reapSession(session, now, "stuck-turn")
|
|
1071
|
+
reaped += 1
|
|
1072
|
+
} else if (!session.activeTurnId && idle > this._detachTtlMs) {
|
|
1073
|
+
this._reapSession(session, now, "idle-ttl")
|
|
1074
|
+
reaped += 1
|
|
1026
1075
|
}
|
|
1027
|
-
reaped += 1
|
|
1028
|
-
this.logger?.info?.(
|
|
1029
|
-
{ stream_id: session.stream_id, thread_id: session.threadId, idle_ms: now - last },
|
|
1030
|
-
"codex abandoned session reaped (no activity past TTL)",
|
|
1031
|
-
)
|
|
1032
|
-
this.emit("exit", {
|
|
1033
|
-
stream_id: session.stream_id,
|
|
1034
|
-
thread_id: session.threadId,
|
|
1035
|
-
code: 0,
|
|
1036
|
-
reason: "idle-reaped",
|
|
1037
|
-
})
|
|
1038
1076
|
}
|
|
1077
|
+
// 3: LRU 上限の超過分を退避(stuck/idle reap 後の生存数で判定)。
|
|
1078
|
+
reaped += this._evictWarmPoolOverflow(now, "lru-evict")
|
|
1039
1079
|
return reaped
|
|
1040
1080
|
}
|
|
1041
1081
|
|
|
1082
|
+
/** 後方互換エイリアス(main.mjs state loop が呼ぶ)。中身は reconcileSessions。 */
|
|
1083
|
+
gcAbandonedSessions() {
|
|
1084
|
+
return this.reconcileSessions()
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1042
1087
|
/** 全セッションを即座に終了する (agent shutdown 用。ターン完走は待たない)。 */
|
|
1043
1088
|
shutdown() {
|
|
1044
1089
|
for (const session of this.sessions.values()) {
|
|
1045
|
-
this._clearDetachTimer(session)
|
|
1046
1090
|
session.dead = true
|
|
1047
1091
|
// hub-agent 再起動時にターン実行中のセッションを落とすと thread が汚染されうる
|
|
1048
1092
|
// (exec 中の kill → output 欠落)。因果を後から辿れるよう必ず記録する。
|
package/src/main.mjs
CHANGED
|
@@ -4399,11 +4399,23 @@ async function dispatch(msg, ctx) {
|
|
|
4399
4399
|
}
|
|
4400
4400
|
try {
|
|
4401
4401
|
const sessions = await ctx.codexBridge.listThreads({ cwd, limit })
|
|
4402
|
+
// Stage 5 ③: 各 thread に「hub-agent 側で app-server が live か / busy か」を付す。
|
|
4403
|
+
// rollout 由来の一覧(listThreads)は live 状態を知らないため、describeLiveSessions
|
|
4404
|
+
// の thread_id → {live, busy} を併合する。browser は sidebar に live/reaped ドットを出す。
|
|
4405
|
+
const liveByThread = new Map()
|
|
4406
|
+
for (const s of ctx.codexBridge.describeLiveSessions(cwd)) {
|
|
4407
|
+
if (s.thread_id) liveByThread.set(s.thread_id, { live: true, busy: !!s.busy })
|
|
4408
|
+
}
|
|
4409
|
+
const withLive = sessions.map((t) => ({
|
|
4410
|
+
...t,
|
|
4411
|
+
live: liveByThread.get(t.thread_id)?.live === true,
|
|
4412
|
+
busy: liveByThread.get(t.thread_id)?.busy === true,
|
|
4413
|
+
}))
|
|
4402
4414
|
ctx.client.send({
|
|
4403
4415
|
type: "codex.sessions.response",
|
|
4404
4416
|
stream_id,
|
|
4405
4417
|
cwd,
|
|
4406
|
-
sessions,
|
|
4418
|
+
sessions: withLive,
|
|
4407
4419
|
})
|
|
4408
4420
|
} catch (err) {
|
|
4409
4421
|
ctx.client.send({
|
package/src/sibling-inject.mjs
CHANGED
|
@@ -16,9 +16,17 @@
|
|
|
16
16
|
* `src/sibling-token.mjs` の cwd スコープ capability トークンによる検証を必須とする。
|
|
17
17
|
* トークンが一致しない要求は無視し (実行せず削除)、warn ログのみ残す。
|
|
18
18
|
*
|
|
19
|
-
* 契約: request file `<request_id>.json`
|
|
19
|
+
* 契約: request file `<request_id>.json` は op で 3 形態 (2026-07-19 / T06271):
|
|
20
|
+
* - 通常注入 (op 無し): `{cwd, target_cli_kind, text, token}`
|
|
21
|
+
* cwd 内が 1 本なら注入、複数なら黙って推測せず `ambiguous_target` + 候補一覧。
|
|
22
|
+
* - 明示注入: `{op:"inject", cwd, target_cli_kind, token, target_session_id, message}`
|
|
23
|
+
* 本文は **top-level text ではなく `message`** に入れる。これにより旧 hub-agent は
|
|
24
|
+
* `!text` で `invalid_request` を返し (推測注入せず fail-closed)、明示宛先を解釈できる
|
|
25
|
+
* 新 agent だけが処理する (probe を挟まず単一リクエストで TOCTOU を排除)。
|
|
26
|
+
* 宛先は session_id(claude) / stream_id・thread_id(codex) を cwd 一致下に確定解決。
|
|
27
|
+
* - 一覧: `{op:"list", cwd, target_cli_kind, token}` → `{ok, live_sessions}` (--list 用)。
|
|
20
28
|
* 処理後は必ず削除し (成否問わず)、呼び出し側が待ち受けられるよう
|
|
21
|
-
* `<request_id>.result.json` = `{ok, error?}` を書く。
|
|
29
|
+
* `<request_id>.result.json` = `{ok, error?, delivered_to?, candidates?, live_sessions?, hint?}` を書く。
|
|
22
30
|
*/
|
|
23
31
|
|
|
24
32
|
import { watch } from "node:fs"
|
|
@@ -100,23 +108,104 @@ export class SiblingInjectBridge {
|
|
|
100
108
|
return
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
|
|
111
|
+
// 契約の 3 形態を op で判別する:
|
|
112
|
+
// - op:"list" → 候補列挙 (top-level text 無し)
|
|
113
|
+
// - op:"inject" → 明示宛先 (target_session_id + message、top-level text は **持たない**)
|
|
114
|
+
// - op 無し → 通常 (cwd 最新、top-level text あり) ← 旧 agent と後方互換
|
|
115
|
+
// ⚠️ 明示宛先を「op:inject + message (top-level text 無し)」にするのが fail-closed の肝
|
|
116
|
+
// (T06271 レビュー major①): 旧 hub-agent は `!text` で invalid_request を返すため、
|
|
117
|
+
// 未知フィールドを無視して sendToCwd 推測注入する事故が **単一リクエストで原理的に
|
|
118
|
+
// 起きない** (probe→注入の TOCTOU を排除)。
|
|
119
|
+
const { op, cwd, target_cli_kind, text, message, token, target_session_id } = body || {}
|
|
120
|
+
const isList = op === "list"
|
|
121
|
+
const isExplicit = op === "inject"
|
|
104
122
|
let result
|
|
105
|
-
|
|
123
|
+
const badKind = target_cli_kind !== "claude" && target_cli_kind !== "codex"
|
|
124
|
+
const badBody = isList
|
|
125
|
+
? false
|
|
126
|
+
: isExplicit
|
|
127
|
+
? !target_session_id || !message // 明示は id と message 必須 (空指定 fail-open を防ぐ)
|
|
128
|
+
: !text // 通常は top-level text 必須 (=旧契約)
|
|
129
|
+
if (!cwd || badKind || badBody) {
|
|
106
130
|
result = { ok: false, error: "invalid_request" }
|
|
107
131
|
} else if (!verifySiblingToken(cwd, token)) {
|
|
108
132
|
this.logger?.warn({ cwd, request_id }, "sibling-inject: token mismatch, request denied")
|
|
109
133
|
result = { ok: false, error: "invalid_token" }
|
|
110
134
|
} else {
|
|
111
135
|
const bridge = target_cli_kind === "codex" ? this.codexBridge : this.claudeBridge
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
if (isList) {
|
|
137
|
+
// 宛先候補の列挙 (cockpit_orch.py --list 用)。この cwd の同種セッションを返す。
|
|
138
|
+
result = { ok: true, live_sessions: bridge?.describeLiveSessions?.(cwd) ?? [] }
|
|
139
|
+
} else if (isExplicit) {
|
|
140
|
+
// 明示宛先: session_id (claude) / stream_id|thread_id (codex) で cwd 一致下に確定解決。
|
|
141
|
+
// sendToStream は {status:"delivered"|"not_found"|"ambiguous"} を返す。
|
|
142
|
+
const r = bridge?.sendToStream?.(cwd, target_session_id, message) ?? { status: "not_found" }
|
|
143
|
+
if (r.status === "delivered") {
|
|
144
|
+
result = { ok: true, delivered_to: r.session }
|
|
145
|
+
} else if (r.status === "ambiguous") {
|
|
146
|
+
// 宛先 id が同一 cwd の複数 live セッションに一致し一意に定まらない。
|
|
147
|
+
// ⚠️ 解消手段は CLI で異なる (T06271 レビュー4 minor):
|
|
148
|
+
// - codex: stream_id は一意なので stream_id で名指しできる
|
|
149
|
+
// - claude: 一意な宛先指定子が無い (session_id は重複、stream_id は非公開) ため
|
|
150
|
+
// --to では解消不能。Cockpit で該当チャットを開き直す / 再一覧を案内する
|
|
151
|
+
result = {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: "ambiguous_target",
|
|
154
|
+
candidates: r.candidates,
|
|
155
|
+
live_sessions: r.candidates,
|
|
156
|
+
hint:
|
|
157
|
+
target_cli_kind === "codex"
|
|
158
|
+
? `指定した id (${target_session_id}) はこの cwd で複数の live セッションに一致します。` +
|
|
159
|
+
`stream_id で名指ししてください (--list で確認)。`
|
|
160
|
+
: `指定した session_id (${target_session_id}) がこの cwd で複数の live セッションに ` +
|
|
161
|
+
`一致します。Claude は一意な宛先指定子が無いため --to では解消できません。Cockpit で` +
|
|
162
|
+
`対象 cwd の Claude チャットを開き直して1本に整理してから再送してください` +
|
|
163
|
+
`(通常は attach 時の重複解消で発生しません)。`,
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
result = {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: "target_session_not_found",
|
|
169
|
+
target_session_id,
|
|
170
|
+
// ⚠️ _diagnose の hint は「対象 CLI 不在」前提で id 不一致 (別 cwd 指定含む) では矛盾
|
|
171
|
+
// する ("claude は無い" と誤表示) ため、id 不一致専用の hint に差し替える。
|
|
172
|
+
live_sessions: this._diagnose(cwd, target_cli_kind).live_sessions,
|
|
173
|
+
hint:
|
|
174
|
+
`指定した宛先 id (${target_session_id}) はこの cwd (${cwd}) の生きた ` +
|
|
175
|
+
`${target_cli_kind} セッションに見つかりません (別 cwd の id は越境注入できません)。` +
|
|
176
|
+
`--list で候補を確認してください。`,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
// 宛先省略 (通常): 同一 cwd の生きたセッション数で分岐。
|
|
181
|
+
// 0 本 → no_live_session / 1 本 → 注入 / 複数 → 黙って推測せず ambiguous_target。
|
|
182
|
+
const sameCwd = bridge?.describeLiveSessions?.(cwd) ?? []
|
|
183
|
+
if (sameCwd.length > 1) {
|
|
184
|
+
result = {
|
|
185
|
+
ok: false,
|
|
186
|
+
error: "ambiguous_target",
|
|
187
|
+
candidates: sameCwd,
|
|
188
|
+
live_sessions: sameCwd,
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
const sent = bridge?.sendToCwd?.(cwd, text) ?? false
|
|
192
|
+
result = sent
|
|
193
|
+
? { ok: true, delivered_to: sameCwd[0] ?? null }
|
|
194
|
+
: { ok: false, error: "no_live_session_for_cwd", ...this._diagnose(cwd, target_cli_kind) }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
116
197
|
}
|
|
117
198
|
|
|
118
199
|
this.logger?.[result.ok ? "info" : "warn"]?.(
|
|
119
|
-
{
|
|
200
|
+
{
|
|
201
|
+
request_id,
|
|
202
|
+
cwd,
|
|
203
|
+
target_cli_kind,
|
|
204
|
+
op: op || "normal",
|
|
205
|
+
...(result.ok
|
|
206
|
+
? { delivered_to: result.delivered_to }
|
|
207
|
+
: { error: result.error, live_sessions: result.live_sessions }),
|
|
208
|
+
},
|
|
120
209
|
result.ok ? "sibling-inject delivered" : "sibling-inject failed",
|
|
121
210
|
)
|
|
122
211
|
await this._writeResult(request_id, result)
|
|
@@ -136,22 +225,26 @@ export class SiblingInjectBridge {
|
|
|
136
225
|
* ⚠️ ここに出るのは hub-agent が spawn した **チャットセッションのみ**。tmux 配下で
|
|
137
226
|
* 動く TUI codex は ask-sibling の宛先にならないため、一覧が空でも「codex は画面上
|
|
138
227
|
* 動いている」ことは普通に起きる。その旨を hint に明記する。
|
|
228
|
+
*
|
|
229
|
+
* ⚠️ セキュリティ (T06271 レビュー3 major②): 返す live_sessions は **この cwd のものだけ**に
|
|
230
|
+
* 限定する。呼び出し元トークンは cwd スコープなので、全 cwd の session id を返すと別
|
|
231
|
+
* workspace の cwd/セッション存在を漏らす (cwd scoped token での越境情報漏洩)。cwd 指定で
|
|
232
|
+
* describeLiveSessions を呼び、同一 cwd のみを材料にする。
|
|
139
233
|
* @param {string} cwd
|
|
140
234
|
* @param {string} targetKind
|
|
141
235
|
*/
|
|
142
236
|
_diagnose(cwd, targetKind) {
|
|
143
|
-
const
|
|
144
|
-
...(this.claudeBridge?.describeLiveSessions?.() ?? []),
|
|
145
|
-
...(this.codexBridge?.describeLiveSessions?.() ?? []),
|
|
237
|
+
const sameCwd = [
|
|
238
|
+
...(this.claudeBridge?.describeLiveSessions?.(cwd) ?? []),
|
|
239
|
+
...(this.codexBridge?.describeLiveSessions?.(cwd) ?? []),
|
|
146
240
|
]
|
|
147
|
-
const sameCwd = all.filter((s) => s.cwd === cwd)
|
|
148
241
|
const hint = sameCwd.length
|
|
149
242
|
? `この cwd には ${sameCwd.map((s) => s.kind).join("/")} のチャットセッションはあるが ${targetKind} は無い。`
|
|
150
243
|
: `この cwd には hub-agent 管理下のチャットセッションが 1 本も無い。` +
|
|
151
244
|
`tmux の TUI codex は ask-sibling の宛先にならない (hub-agent が spawn した` +
|
|
152
245
|
` Cockpit のチャットセッションのみが対象)。Cockpit のブラウザで対象 cwd の` +
|
|
153
246
|
` ${targetKind} チャットを開き直してから再送すること。`
|
|
154
|
-
return { live_sessions:
|
|
247
|
+
return { live_sessions: sameCwd, hint }
|
|
155
248
|
}
|
|
156
249
|
|
|
157
250
|
/** 結果ファイルを atomic write する (呼び出し側 CLI がポーリングする)。 */
|