@cocorograph/hub-agent 0.8.43 → 0.8.45
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 +139 -12
- package/src/codex-stream-bridge.mjs +349 -39
- package/src/main.mjs +125 -27
- package/src/ws-client.mjs +5 -0
package/package.json
CHANGED
|
@@ -439,6 +439,7 @@ export class ClaudeStreamSession {
|
|
|
439
439
|
onPermissionCancel,
|
|
440
440
|
onExit,
|
|
441
441
|
onError,
|
|
442
|
+
onOptions,
|
|
442
443
|
onReap,
|
|
443
444
|
onTurnSettled,
|
|
444
445
|
}) {
|
|
@@ -481,6 +482,7 @@ export class ClaudeStreamSession {
|
|
|
481
482
|
this.onPermissionCancel = onPermissionCancel
|
|
482
483
|
this.onExit = onExit
|
|
483
484
|
this.onError = onError
|
|
485
|
+
this.onOptions = onOptions
|
|
484
486
|
/** ターン完走後に遅延クローズする際、manager にセッション撤去を依頼するコールバック */
|
|
485
487
|
this.onReap = onReap
|
|
486
488
|
/** RC-4: result を届けずにターンが終わった (abort/異常終了) ときに呼ぶコールバック。
|
|
@@ -551,6 +553,11 @@ export class ClaudeStreamSession {
|
|
|
551
553
|
/** この session オブジェクトの世代 (T06603)。上記 nextQueueGeneration の説明を参照。
|
|
552
554
|
* **同一プロセス内の順序のみ**を保証する (プロセス間は backend の接続フェンスが担う)。 */
|
|
553
555
|
this._queueGeneration = nextQueueGeneration()
|
|
556
|
+
/** model / permissionMode の「次ターン desired」authority identity。
|
|
557
|
+
* UUID は同一 session object の一致確認だけに使い、大小・生成順は比較しない。 */
|
|
558
|
+
this._optionsInstanceId = randomUUID()
|
|
559
|
+
/** 上記instance内だけで比較するCAS revision。queue seq/generationとは独立。 */
|
|
560
|
+
this._optionsRevision = 0
|
|
554
561
|
/** 配達確認の再表明用 (T06603): この session が受理 (キュー投入 or 直接配送) した
|
|
555
562
|
* input_id の直近リング。`claude.input.ack` は一度きりのユニキャストで、WS 切断が
|
|
556
563
|
* 30s を超えると ws-client の ctrl バッファから破棄され二度と再送されない。その結果
|
|
@@ -988,12 +995,9 @@ export class ClaudeStreamSession {
|
|
|
988
995
|
* browser 接続へライブに流れる (= 通常の生成中表示と同じ)。再アタッチ前の確定分は
|
|
989
996
|
* browser 側の jsonl hydrate (history.request) で復元するため、ここでは replay しない。
|
|
990
997
|
*
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
994
|
-
* 効くようになる。従来は reattach が stream_id だけ差し替えていたため、起動済み
|
|
995
|
-
* query の model/permission/thinking は初期値のまま変わらなかった (バッジが効かない
|
|
996
|
-
* 不具合の原因)。 */
|
|
998
|
+
* 通常 attach は購読 + current snapshot 取得専用。model / permissionMode は CAS request
|
|
999
|
+
* (`claude.session.options.set`) だけが変更できる。maxThinkingTokens / effort は今回の
|
|
1000
|
+
* authority 対象外なので、既存 attach 反映を維持する。 */
|
|
997
1001
|
reattach(stream_id, opts = undefined) {
|
|
998
1002
|
this.stream_id = stream_id
|
|
999
1003
|
// 多端末共有: この端末を購読集合に追加 (無効時は未使用)。旧端末の stream_id は
|
|
@@ -1005,7 +1009,12 @@ export class ClaudeStreamSession {
|
|
|
1005
1009
|
clearTimeout(this._idleTimer)
|
|
1006
1010
|
this._idleTimer = null
|
|
1007
1011
|
}
|
|
1008
|
-
if (opts)
|
|
1012
|
+
if (opts) {
|
|
1013
|
+
this.applyRuntimeOptions({
|
|
1014
|
+
maxThinkingTokens: opts.maxThinkingTokens,
|
|
1015
|
+
effort: opts.effort,
|
|
1016
|
+
})
|
|
1017
|
+
}
|
|
1009
1018
|
// キュー再表示バグ修正 (0.6.30): 再アタッチした端末は queue_state のライブ配信を
|
|
1010
1019
|
// 取りこぼしている (jsonl hydrate にはキュー状態が含まれない)。現在の pending を
|
|
1011
1020
|
// force で再 emit し、再表示端末・後から接続した端末の送信待ちチップを復元する。
|
|
@@ -1064,11 +1073,13 @@ export class ClaudeStreamSession {
|
|
|
1064
1073
|
* maxThinkingTokens に 0/null が来たら次 spawn 時に budget オフ (thinking 未指定) となる。 */
|
|
1065
1074
|
applyRuntimeOptions({ model, permissionMode, maxThinkingTokens, effort } = {}) {
|
|
1066
1075
|
const q = this._residentQuery
|
|
1076
|
+
let desiredOptionsChanged = false
|
|
1067
1077
|
// モデル
|
|
1068
1078
|
if (model !== undefined) {
|
|
1069
1079
|
const next = model || null
|
|
1070
1080
|
if (next !== this.model) {
|
|
1071
1081
|
this.model = next
|
|
1082
|
+
desiredOptionsChanged = true
|
|
1072
1083
|
if (q && typeof q.setModel === "function") {
|
|
1073
1084
|
q.setModel(next || undefined).catch((err) =>
|
|
1074
1085
|
this.logger?.warn(
|
|
@@ -1084,6 +1095,7 @@ export class ClaudeStreamSession {
|
|
|
1084
1095
|
const next = permissionMode || null
|
|
1085
1096
|
if (next !== this.permissionMode) {
|
|
1086
1097
|
this.permissionMode = next
|
|
1098
|
+
desiredOptionsChanged = true
|
|
1087
1099
|
// setPermissionMode は有効な mode を要求する。null/空 (=未指定へ戻す) は
|
|
1088
1100
|
// SDK 側に「解除」API が無いため、保持値の更新のみ (次回 spawn で既定に従う)。
|
|
1089
1101
|
if (next && q && typeof q.setPermissionMode === "function") {
|
|
@@ -1116,6 +1128,84 @@ export class ClaudeStreamSession {
|
|
|
1116
1128
|
if (effort !== undefined) {
|
|
1117
1129
|
this.effort = effort || null
|
|
1118
1130
|
}
|
|
1131
|
+
if (desiredOptionsChanged) {
|
|
1132
|
+
this._optionsRevision += 1
|
|
1133
|
+
try {
|
|
1134
|
+
this.onOptions?.(this.optionsSnapshot())
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
this.logger?.warn?.(
|
|
1137
|
+
{ err: err?.message, stream_id: this.stream_id },
|
|
1138
|
+
"onOptions callback threw",
|
|
1139
|
+
)
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** Exact-base CAS で model / permissionMode の次ターン desired を変更する。 */
|
|
1145
|
+
setSessionOptions({ baseOptionsInstanceId, baseOptionsRevision, patch }) {
|
|
1146
|
+
const current = () => this.optionsSnapshot()
|
|
1147
|
+
const hasModel = patch && Object.hasOwn(patch, "model")
|
|
1148
|
+
const hasPermissionMode =
|
|
1149
|
+
patch && Object.hasOwn(patch, "permission_mode")
|
|
1150
|
+
if (
|
|
1151
|
+
!patch ||
|
|
1152
|
+
typeof patch !== "object" ||
|
|
1153
|
+
Array.isArray(patch) ||
|
|
1154
|
+
Object.keys(patch).length === 0 ||
|
|
1155
|
+
Object.keys(patch).some(
|
|
1156
|
+
(key) => key !== "model" && key !== "permission_mode",
|
|
1157
|
+
) ||
|
|
1158
|
+
!(hasModel || hasPermissionMode) ||
|
|
1159
|
+
(hasModel &&
|
|
1160
|
+
!(patch.model === null || typeof patch.model === "string")) ||
|
|
1161
|
+
(hasPermissionMode &&
|
|
1162
|
+
!(
|
|
1163
|
+
patch.permission_mode === null ||
|
|
1164
|
+
typeof patch.permission_mode === "string"
|
|
1165
|
+
))
|
|
1166
|
+
) {
|
|
1167
|
+
return { outcome: "invalid_patch", current: current() }
|
|
1168
|
+
}
|
|
1169
|
+
if (
|
|
1170
|
+
baseOptionsInstanceId !== this._optionsInstanceId ||
|
|
1171
|
+
!Number.isSafeInteger(baseOptionsRevision) ||
|
|
1172
|
+
baseOptionsRevision !== this._optionsRevision
|
|
1173
|
+
) {
|
|
1174
|
+
return { outcome: "conflict", current: current() }
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// attach/configと同じく空文字はdefault(null)。比較前に正規化してnull状態のnoopを保つ。
|
|
1178
|
+
const normalizedPatch = {
|
|
1179
|
+
...(hasModel ? { model: patch.model || null } : {}),
|
|
1180
|
+
...(hasPermissionMode
|
|
1181
|
+
? { permission_mode: patch.permission_mode || null }
|
|
1182
|
+
: {}),
|
|
1183
|
+
}
|
|
1184
|
+
const nextModel = hasModel ? normalizedPatch.model : this.model
|
|
1185
|
+
const nextPermissionMode =
|
|
1186
|
+
hasPermissionMode
|
|
1187
|
+
? normalizedPatch.permission_mode
|
|
1188
|
+
: this.permissionMode
|
|
1189
|
+
if (nextModel === this.model && nextPermissionMode === this.permissionMode) {
|
|
1190
|
+
return { outcome: "noop", current: current() }
|
|
1191
|
+
}
|
|
1192
|
+
this.applyRuntimeOptions({
|
|
1193
|
+
...(hasModel ? { model: normalizedPatch.model } : {}),
|
|
1194
|
+
...(hasPermissionMode
|
|
1195
|
+
? { permissionMode: normalizedPatch.permission_mode }
|
|
1196
|
+
: {}),
|
|
1197
|
+
})
|
|
1198
|
+
return { outcome: "applied", current: current() }
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** 次ターンに使う desired options。SDK setter の成功実測値ではない。 */
|
|
1202
|
+
optionsSnapshot() {
|
|
1203
|
+
return {
|
|
1204
|
+
model: this.model,
|
|
1205
|
+
permission_mode: this.permissionMode,
|
|
1206
|
+
options_instance_id: this._optionsInstanceId,
|
|
1207
|
+
options_revision: this._optionsRevision,
|
|
1208
|
+
}
|
|
1119
1209
|
}
|
|
1120
1210
|
|
|
1121
1211
|
/** モデルが effort / adaptive thinking 対応かどうか。
|
|
@@ -2534,12 +2624,10 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2534
2624
|
// 多端末共有 (T06542 で常時): 旧 stream_id を Map から消さない。同一セッションを複数端末が
|
|
2535
2625
|
// 購読する状態を作り、どの端末の input/permission/interrupt も解決できるようにする
|
|
2536
2626
|
// (旧端末は reattach.streamIds に足され、backend が session group へ fan-out する)。
|
|
2537
|
-
//
|
|
2538
|
-
//
|
|
2539
|
-
//
|
|
2627
|
+
// 通常 attach は購読 + current snapshot 取得だけ。旧 frontend は capability flag を
|
|
2628
|
+
// 送らず model/permission を載せるが、新 agent は live session では fail-closed で無視する。
|
|
2629
|
+
// maxThinkingTokens / effort は model/permission authority の対象外なので既存反映を維持。
|
|
2540
2630
|
live.reattach(stream_id, {
|
|
2541
|
-
model,
|
|
2542
|
-
permissionMode,
|
|
2543
2631
|
maxThinkingTokens:
|
|
2544
2632
|
typeof maxThinkingTokens === "number" ? maxThinkingTokens : null,
|
|
2545
2633
|
effort,
|
|
@@ -2562,6 +2650,7 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2562
2650
|
resuming: true,
|
|
2563
2651
|
reattached: true,
|
|
2564
2652
|
turn_active: live._busy === true,
|
|
2653
|
+
...live.optionsSnapshot(),
|
|
2565
2654
|
}
|
|
2566
2655
|
}
|
|
2567
2656
|
}
|
|
@@ -2635,6 +2724,14 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2635
2724
|
error: err?.message || String(err),
|
|
2636
2725
|
})
|
|
2637
2726
|
},
|
|
2727
|
+
onOptions: (snapshot) => {
|
|
2728
|
+
// bridge 内部イベントは wire type を持たない。wire 契約は main builder に閉じる。
|
|
2729
|
+
this.emit("sessionoptions", {
|
|
2730
|
+
stream_id: session.stream_id,
|
|
2731
|
+
session_id: session.sessionId,
|
|
2732
|
+
...snapshot,
|
|
2733
|
+
})
|
|
2734
|
+
},
|
|
2638
2735
|
onReap: (info) => {
|
|
2639
2736
|
// ターン完走後の遅延クローズ、または R1(a) 子プロセス死亡時の即 evict。Map / 索引から
|
|
2640
2737
|
// 撤去し exit を emit する。多端末共有では複数端末キーが Map に載っているのでまとめて外す。
|
|
@@ -2680,9 +2777,39 @@ export class ClaudeStreamBridge extends EventEmitter {
|
|
|
2680
2777
|
stream_id,
|
|
2681
2778
|
resuming: !!resumeSessionId,
|
|
2682
2779
|
turn_active: session._busy === true,
|
|
2780
|
+
...session.optionsSnapshot(),
|
|
2683
2781
|
}
|
|
2684
2782
|
}
|
|
2685
2783
|
|
|
2784
|
+
/** browser の session options CAS request。常に current snapshot 付き outcome を返す。 */
|
|
2785
|
+
setSessionOptions({
|
|
2786
|
+
stream_id,
|
|
2787
|
+
session_id,
|
|
2788
|
+
base_options_instance_id,
|
|
2789
|
+
base_options_revision,
|
|
2790
|
+
patch,
|
|
2791
|
+
}) {
|
|
2792
|
+
const session = this.sessions.get(stream_id)
|
|
2793
|
+
if (!session) {
|
|
2794
|
+
return { outcome: "stream_missing", current: null }
|
|
2795
|
+
}
|
|
2796
|
+
// CAS操作もpermission reply/inputと同じユーザー活性。dead-terminal GCとwarm LRUを更新する。
|
|
2797
|
+
session.touch(stream_id)
|
|
2798
|
+
// fresh は SDK init 前だけ sessionId=null。同じ stream なら CAS 可能だが、session group の
|
|
2799
|
+
// identity がまだ無いため他端末はこの fresh session を購読できない。
|
|
2800
|
+
if (session.sessionId !== session_id) {
|
|
2801
|
+
return {
|
|
2802
|
+
outcome: "session_mismatch",
|
|
2803
|
+
current: session.optionsSnapshot(),
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
return session.setSessionOptions({
|
|
2807
|
+
baseOptionsInstanceId: base_options_instance_id,
|
|
2808
|
+
baseOptionsRevision: base_options_revision,
|
|
2809
|
+
patch,
|
|
2810
|
+
})
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2686
2813
|
/**
|
|
2687
2814
|
* browser → claude の user メッセージ。1 件 = 1 query (resume チェーン)。
|
|
2688
2815
|
*
|
|
@@ -136,6 +136,14 @@ const STUCK_TURN_TTL_MS = readPositiveIntEnv(
|
|
|
136
136
|
60 * 60 * 1000,
|
|
137
137
|
)
|
|
138
138
|
|
|
139
|
+
// 子 turn の停止 handle 台帳を回収できる最短 age(既定 4h)。通常は保護 TTL(既定 1h)を
|
|
140
|
+
// 先に超えて protectionExpiredAtMs が立ち、その後も長時間 heartbeat が無い record だけを回収する。
|
|
141
|
+
// つまり保護 TTL 直後には停止可能性を残し、台帳だけが永久化することも避ける。設定で保護 TTL を
|
|
142
|
+
// 4h 以上へ延ばした場合も、protectionExpiredAtMs が立つまでは active handle を回収しない。
|
|
143
|
+
const SUBAGENT_INTERRUPT_RECORD_TTL_MS = 4 * 60 * 60 * 1000
|
|
144
|
+
/** 個別停止中に後発 descendant を拾う再走査回数。無制限 spawn に応答を占有されない上限。 */
|
|
145
|
+
const SUBAGENT_INTERRUPT_SCAN_ROUNDS = 3
|
|
146
|
+
|
|
139
147
|
// 案B (T06542): 死端末 GC の TTL。多端末購読で、応答を返さなくなった(クラッシュ / タブ強制終了で
|
|
140
148
|
// codex.detach を送れなかった)購読端末を購読集合から掃除する閾値。claude-stream-bridge.mjs の
|
|
141
149
|
// DEAD_TERMINAL_TTL_MS(既定 1h)と同型・同既定。stuck turn TTL とは別軸(あちらはターン固着、
|
|
@@ -774,6 +782,8 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
774
782
|
subagentInterruptRegistry: new Map(),
|
|
775
783
|
/** dynamicToolCall(spawn_agent) の item.id -> 表示メタデータ。 */
|
|
776
784
|
subagentSpawnMetadata: new Map(),
|
|
785
|
+
/** invalid background の同一 item 再送で warn を重複させない bounded set。 */
|
|
786
|
+
subagentInvalidBackgroundWarned: new Set(),
|
|
777
787
|
/** live subAgentActivity の spawn metadata miss を child thread/read で補完する
|
|
778
788
|
* single-flight 台帳。key = agentThreadId, value = 進行中 Promise。 */
|
|
779
789
|
subagentRoleReads: new Map(),
|
|
@@ -1668,8 +1678,12 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1668
1678
|
description:
|
|
1669
1679
|
metadata?.description || previous?.description || subagentFallbackName(agentPath),
|
|
1670
1680
|
prompt: metadata?.prompt || previous?.prompt || "",
|
|
1671
|
-
//
|
|
1672
|
-
|
|
1681
|
+
// metadata に明示 boolean があるときは後着値を権威とする。metadata miss は spawn の
|
|
1682
|
+
// background 省略既定と同じ true に倒し、明示 false だけが訂正する。
|
|
1683
|
+
background:
|
|
1684
|
+
typeof metadata?.background === "boolean"
|
|
1685
|
+
? metadata.background
|
|
1686
|
+
: previous?.background ?? true,
|
|
1673
1687
|
atMs: transitionAtMs,
|
|
1674
1688
|
startedAtMs:
|
|
1675
1689
|
phase === "started" && terminalSubagentPhase(previous?.phase)
|
|
@@ -1791,12 +1805,35 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1791
1805
|
if (!args) return
|
|
1792
1806
|
const agent = typeof args.agent_type === "string" ? args.agent_type.trim() : ""
|
|
1793
1807
|
const agentExplicit = agent.length > 0
|
|
1808
|
+
// 現行 Codex schema は background を送らず spawn は非同期で返るため、省略・非 boolean は
|
|
1809
|
+
// 背景扱いにする。invalid 値は本文を永続ログへ出さず、型と item id だけを記録する。
|
|
1810
|
+
const hasBackground = Object.prototype.hasOwnProperty.call(args, "background")
|
|
1811
|
+
const background = typeof args.background === "boolean" ? args.background : true
|
|
1812
|
+
if (hasBackground && typeof args.background !== "boolean") {
|
|
1813
|
+
if (!session.subagentInvalidBackgroundWarned.has(item.id)) {
|
|
1814
|
+
session.subagentInvalidBackgroundWarned.add(item.id)
|
|
1815
|
+
while (session.subagentInvalidBackgroundWarned.size > 256) {
|
|
1816
|
+
session.subagentInvalidBackgroundWarned.delete(
|
|
1817
|
+
session.subagentInvalidBackgroundWarned.values().next().value,
|
|
1818
|
+
)
|
|
1819
|
+
}
|
|
1820
|
+
const backgroundType = Array.isArray(args.background)
|
|
1821
|
+
? "array"
|
|
1822
|
+
: args.background === null
|
|
1823
|
+
? "null"
|
|
1824
|
+
: typeof args.background
|
|
1825
|
+
this.logger?.warn?.(
|
|
1826
|
+
{ item_id: item.id, background_type: backgroundType },
|
|
1827
|
+
"codex subagent spawn background must be boolean; defaulting to background",
|
|
1828
|
+
)
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1794
1831
|
const metadata = {
|
|
1795
1832
|
agent: agentExplicit ? agent : null,
|
|
1796
1833
|
agentExplicit,
|
|
1797
1834
|
description: typeof args.task_name === "string" && args.task_name ? args.task_name : null,
|
|
1798
1835
|
prompt: typeof args.message === "string" ? args.message : null,
|
|
1799
|
-
background
|
|
1836
|
+
background,
|
|
1800
1837
|
}
|
|
1801
1838
|
session.subagentSpawnMetadata.set(item.id, metadata)
|
|
1802
1839
|
while (session.subagentSpawnMetadata.size > 256) {
|
|
@@ -1892,32 +1929,52 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1892
1929
|
}
|
|
1893
1930
|
const { parentThreadId, ownerTurnId, ...mutableFields } = fields
|
|
1894
1931
|
Object.assign(record, mutableFields, { lastSeenAtMs: now })
|
|
1932
|
+
delete record.protectionExpiredAtMs
|
|
1895
1933
|
if (!record.parentThreadId && typeof parentThreadId === "string" && parentThreadId) {
|
|
1896
1934
|
record.parentThreadId = parentThreadId
|
|
1897
1935
|
}
|
|
1898
1936
|
if (!record.ownerTurnId && typeof ownerTurnId === "string" && ownerTurnId) {
|
|
1899
1937
|
record.ownerTurnId = ownerTurnId
|
|
1900
1938
|
}
|
|
1901
|
-
|
|
1939
|
+
this._sweepSubagentInterruptRegistry(session, now)
|
|
1940
|
+
return record
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/** delta hot path。対象 record だけを更新し、全台帳 sweep/sort は行わない。 */
|
|
1944
|
+
_heartbeatSubagentInterruptRecord(session, threadId, now = Date.now()) {
|
|
1945
|
+
const record = session?.subagentInterruptRegistry?.get(threadId)
|
|
1946
|
+
if (!record) return null
|
|
1947
|
+
record.lastSeenAtMs = now
|
|
1948
|
+
delete record.protectionExpiredAtMs
|
|
1949
|
+
return record
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
/** lifecycle 更新時だけ、完了済みまたは保護失効済みの古い停止 record と上限超過を回収する。 */
|
|
1953
|
+
_sweepSubagentInterruptRegistry(session, now = Date.now()) {
|
|
1954
|
+
const registry = session?.subagentInterruptRegistry
|
|
1955
|
+
if (!registry) return
|
|
1956
|
+
const expiredBefore = now - SUBAGENT_INTERRUPT_RECORD_TTL_MS
|
|
1902
1957
|
for (const [id, candidate] of session.subagentInterruptRegistry) {
|
|
1903
|
-
|
|
1958
|
+
const evictable = candidate.turnId === null || candidate.protectionExpiredAtMs !== undefined
|
|
1959
|
+
if (evictable && candidate.lastSeenAtMs < expiredBefore) {
|
|
1904
1960
|
this._evictSubagentInterruptRecord(session, id, candidate)
|
|
1905
1961
|
}
|
|
1906
1962
|
}
|
|
1907
|
-
while (
|
|
1908
|
-
const oldest = [...
|
|
1909
|
-
.filter(([, candidate]) =>
|
|
1963
|
+
while (registry.size > 256) {
|
|
1964
|
+
const oldest = [...registry.entries()]
|
|
1965
|
+
.filter(([, candidate]) => (
|
|
1966
|
+
candidate.turnId === null || candidate.protectionExpiredAtMs !== undefined
|
|
1967
|
+
))
|
|
1910
1968
|
.sort((a, b) => a[1].lastSeenAtMs - b[1].lastSeenAtMs)[0]
|
|
1911
1969
|
if (!oldest) {
|
|
1912
1970
|
this.logger?.warn?.(
|
|
1913
|
-
{ size:
|
|
1971
|
+
{ size: registry.size, limit: 256 },
|
|
1914
1972
|
"codex subagent interrupt registry exceeds limit with all records active",
|
|
1915
1973
|
)
|
|
1916
1974
|
break
|
|
1917
1975
|
}
|
|
1918
1976
|
this._evictSubagentInterruptRecord(session, oldest[0], oldest[1])
|
|
1919
1977
|
}
|
|
1920
|
-
return record
|
|
1921
1978
|
}
|
|
1922
1979
|
|
|
1923
1980
|
_evictSubagentInterruptRecord(session, threadId, record) {
|
|
@@ -1927,25 +1984,50 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1927
1984
|
session.subagentInterruptRegistry.delete(threadId)
|
|
1928
1985
|
}
|
|
1929
1986
|
|
|
1987
|
+
/**
|
|
1988
|
+
* 子 record から root までの所有チェーンを辿る。cycle は呼び出し側が停止対象から除外する。
|
|
1989
|
+
* @returns {{ownerRootTurnId:string|null, hasBackgroundAncestor:boolean, lineage:string[], cycle:boolean}}
|
|
1990
|
+
*/
|
|
1930
1991
|
_subagentOwnership(session, record) {
|
|
1931
1992
|
const visited = new Set()
|
|
1993
|
+
const lineage = []
|
|
1932
1994
|
let current = record
|
|
1933
1995
|
let hasBackgroundAncestor = false
|
|
1934
1996
|
while (current) {
|
|
1935
|
-
if (visited.has(current.threadId))
|
|
1997
|
+
if (visited.has(current.threadId)) {
|
|
1998
|
+
return { ownerRootTurnId: null, hasBackgroundAncestor, lineage, cycle: true }
|
|
1999
|
+
}
|
|
1936
2000
|
visited.add(current.threadId)
|
|
2001
|
+
lineage.push(current.threadId)
|
|
2002
|
+
// background は表示台帳の省略既定を含む。親チェーン上の背景 root を一度検出したら、
|
|
2003
|
+
// capability 対応 browser の root interrupt では部分木全体を残す。
|
|
1937
2004
|
if (session.subagents.get(current.threadId)?.background === true) {
|
|
1938
2005
|
hasBackgroundAncestor = true
|
|
1939
2006
|
}
|
|
1940
2007
|
if (current.parentThreadId === session.threadId) {
|
|
1941
|
-
return {
|
|
2008
|
+
return {
|
|
2009
|
+
ownerRootTurnId: current.ownerTurnId || null,
|
|
2010
|
+
hasBackgroundAncestor,
|
|
2011
|
+
lineage,
|
|
2012
|
+
cycle: false,
|
|
2013
|
+
}
|
|
1942
2014
|
}
|
|
1943
2015
|
current = session.subagentInterruptRegistry.get(current.parentThreadId)
|
|
1944
2016
|
}
|
|
1945
|
-
return { ownerRootTurnId: null, hasBackgroundAncestor }
|
|
2017
|
+
return { ownerRootTurnId: null, hasBackgroundAncestor, lineage, cycle: false }
|
|
1946
2018
|
}
|
|
1947
2019
|
|
|
1948
|
-
async _interruptSubagent(
|
|
2020
|
+
async _interruptSubagent(
|
|
2021
|
+
session,
|
|
2022
|
+
record,
|
|
2023
|
+
targetRootTurnId,
|
|
2024
|
+
{
|
|
2025
|
+
allowBackground = false,
|
|
2026
|
+
requireRootOwnership = true,
|
|
2027
|
+
expectedDelegationId = null,
|
|
2028
|
+
expectedAncestorThreadId = null,
|
|
2029
|
+
} = {},
|
|
2030
|
+
) {
|
|
1949
2031
|
const displayed = session.subagents.get(record.threadId)
|
|
1950
2032
|
const backgroundKnown = displayed ? typeof displayed.background === "boolean" : false
|
|
1951
2033
|
const background = backgroundKnown ? displayed.background : null
|
|
@@ -1957,11 +2039,21 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1957
2039
|
const mismatch = parseTurnMismatch(reason)
|
|
1958
2040
|
const latestRecord = session.subagentInterruptRegistry.get(record.threadId)
|
|
1959
2041
|
const latestOwnership = latestRecord ? this._subagentOwnership(session, latestRecord) : null
|
|
2042
|
+
const latestDisplayed = session.subagents.get(record.threadId)
|
|
2043
|
+
const lineageStillMatches = !expectedAncestorThreadId || (
|
|
2044
|
+
latestOwnership &&
|
|
2045
|
+
!latestOwnership.cycle &&
|
|
2046
|
+
latestOwnership.lineage.includes(expectedAncestorThreadId)
|
|
2047
|
+
)
|
|
1960
2048
|
if (
|
|
1961
2049
|
mismatch &&
|
|
1962
2050
|
latestRecord?.turnId === mismatch.found &&
|
|
1963
|
-
|
|
1964
|
-
|
|
2051
|
+
(!expectedDelegationId || latestDisplayed?.delegationId === expectedDelegationId) &&
|
|
2052
|
+
lineageStillMatches &&
|
|
2053
|
+
(!requireRootOwnership || (
|
|
2054
|
+
latestOwnership?.ownerRootTurnId === targetRootTurnId &&
|
|
2055
|
+
(allowBackground || !latestOwnership.hasBackgroundAncestor)
|
|
2056
|
+
))
|
|
1965
2057
|
) {
|
|
1966
2058
|
try {
|
|
1967
2059
|
await session.client.request("turn/interrupt", {
|
|
@@ -1986,6 +2078,15 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1986
2078
|
}
|
|
1987
2079
|
}
|
|
1988
2080
|
}
|
|
2081
|
+
if (mismatch && latestRecord?.turnId === mismatch.found && !lineageStillMatches) {
|
|
2082
|
+
this.logger?.warn?.(
|
|
2083
|
+
{
|
|
2084
|
+
thread_id: record.threadId,
|
|
2085
|
+
expected_ancestor_thread_id: expectedAncestorThreadId,
|
|
2086
|
+
},
|
|
2087
|
+
"codex subagent interrupt retry skipped after ownership changed",
|
|
2088
|
+
)
|
|
2089
|
+
}
|
|
1989
2090
|
return {
|
|
1990
2091
|
threadId: record.threadId,
|
|
1991
2092
|
turnId: record.turnId,
|
|
@@ -1997,6 +2098,111 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1997
2098
|
}
|
|
1998
2099
|
}
|
|
1999
2100
|
|
|
2101
|
+
/**
|
|
2102
|
+
* 対象子と active descendants を停止する。対象 RPC 待ちや子停止中に増えた descendant も
|
|
2103
|
+
* 最大3ラウンド再走査する。上限後の未処理 active turn は skipped へ明示し、無限 spawn に
|
|
2104
|
+
* WebSocket 応答を占有されないようにする。
|
|
2105
|
+
* @returns {Promise<object>} targetOutcome/targetReason は対象自身、outcome は部分木全体の結果。
|
|
2106
|
+
*/
|
|
2107
|
+
async interruptSubagent({ stream_id, delegation_id, agent_thread_id }) {
|
|
2108
|
+
const session = this._require(stream_id)
|
|
2109
|
+
this._touch(session, stream_id)
|
|
2110
|
+
const displayed = session.subagents.get(agent_thread_id)
|
|
2111
|
+
if (!displayed || displayed.delegationId !== delegation_id) {
|
|
2112
|
+
return { outcome: "failed", reason: "subagent_not_found", children: [], skipped: [] }
|
|
2113
|
+
}
|
|
2114
|
+
const record = session.subagentInterruptRegistry.get(agent_thread_id)
|
|
2115
|
+
const result = record?.turnId
|
|
2116
|
+
? await this._interruptSubagent(session, { ...record }, null, {
|
|
2117
|
+
allowBackground: true,
|
|
2118
|
+
requireRootOwnership: false,
|
|
2119
|
+
expectedDelegationId: delegation_id,
|
|
2120
|
+
})
|
|
2121
|
+
: { outcome: "no_active_turn", reason: "subagent_not_active" }
|
|
2122
|
+
const children = []
|
|
2123
|
+
const processedTurns = new Set()
|
|
2124
|
+
const warnedCycleThreads = new Set()
|
|
2125
|
+
const turnKey = (candidate) => `${candidate.threadId}\u0000${candidate.turnId}`
|
|
2126
|
+
const activeDescendants = () => [...session.subagentInterruptRegistry.values()]
|
|
2127
|
+
.filter((candidate) => {
|
|
2128
|
+
if (!candidate?.turnId || candidate.threadId === agent_thread_id) return false
|
|
2129
|
+
const ownership = this._subagentOwnership(session, candidate)
|
|
2130
|
+
if (ownership.cycle) {
|
|
2131
|
+
if (!warnedCycleThreads.has(candidate.threadId)) {
|
|
2132
|
+
warnedCycleThreads.add(candidate.threadId)
|
|
2133
|
+
this.logger?.warn?.(
|
|
2134
|
+
{ thread_id: candidate.threadId },
|
|
2135
|
+
"codex subagent descendant skipped because ownership chain is cyclic",
|
|
2136
|
+
)
|
|
2137
|
+
}
|
|
2138
|
+
return false
|
|
2139
|
+
}
|
|
2140
|
+
return ownership.lineage.includes(agent_thread_id)
|
|
2141
|
+
})
|
|
2142
|
+
|
|
2143
|
+
for (let round = 0; round < SUBAGENT_INTERRUPT_SCAN_ROUNDS; round += 1) {
|
|
2144
|
+
const descendants = activeDescendants()
|
|
2145
|
+
.filter((candidate) => !processedTurns.has(turnKey(candidate)))
|
|
2146
|
+
.map((candidate) => ({ ...candidate }))
|
|
2147
|
+
if (descendants.length === 0) break
|
|
2148
|
+
for (const descendant of descendants) {
|
|
2149
|
+
const key = turnKey(descendant)
|
|
2150
|
+
// 同じ round の先行 RPC 待ち中にも lineage/turn は動き得るため、送信直前にも live 台帳で確認する。
|
|
2151
|
+
const current = session.subagentInterruptRegistry.get(descendant.threadId)
|
|
2152
|
+
const ownership = current ? this._subagentOwnership(session, current) : null
|
|
2153
|
+
if (
|
|
2154
|
+
!current?.turnId ||
|
|
2155
|
+
current.turnId !== descendant.turnId ||
|
|
2156
|
+
ownership?.cycle ||
|
|
2157
|
+
!ownership?.lineage.includes(agent_thread_id)
|
|
2158
|
+
) {
|
|
2159
|
+
this.logger?.warn?.(
|
|
2160
|
+
{ thread_id: descendant.threadId, round: round + 1 },
|
|
2161
|
+
"codex subagent descendant skipped after ownership changed before interrupt",
|
|
2162
|
+
)
|
|
2163
|
+
continue
|
|
2164
|
+
}
|
|
2165
|
+
processedTurns.add(key)
|
|
2166
|
+
const childResult = await this._interruptSubagent(session, { ...current }, null, {
|
|
2167
|
+
allowBackground: true,
|
|
2168
|
+
requireRootOwnership: false,
|
|
2169
|
+
expectedAncestorThreadId: agent_thread_id,
|
|
2170
|
+
})
|
|
2171
|
+
children.push(childResult)
|
|
2172
|
+
if (childResult?.threadId && childResult?.turnId) {
|
|
2173
|
+
processedTurns.add(turnKey(childResult))
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
const targetOutcome = result.outcome
|
|
2178
|
+
const descendantInterrupted = children.some((child) => child.outcome === "interrupted")
|
|
2179
|
+
const outcome =
|
|
2180
|
+
targetOutcome === "no_active_turn" && descendantInterrupted
|
|
2181
|
+
? "interrupted"
|
|
2182
|
+
: targetOutcome
|
|
2183
|
+
const response = {
|
|
2184
|
+
...result,
|
|
2185
|
+
outcome,
|
|
2186
|
+
targetOutcome,
|
|
2187
|
+
delegationId: displayed.delegationId,
|
|
2188
|
+
agentThreadId: displayed.agentThreadId,
|
|
2189
|
+
children,
|
|
2190
|
+
skipped: activeDescendants()
|
|
2191
|
+
.filter((candidate) => !processedTurns.has(turnKey(candidate)))
|
|
2192
|
+
.map((candidate) => ({
|
|
2193
|
+
threadId: candidate.threadId,
|
|
2194
|
+
turnId: candidate.turnId,
|
|
2195
|
+
reason: "spawned_during_interrupt",
|
|
2196
|
+
})),
|
|
2197
|
+
}
|
|
2198
|
+
// 部分木として成功した応答に failure 用 reason を残さない。対象単体の理由は別軸で保持する。
|
|
2199
|
+
if (outcome === "interrupted" && result.reason) {
|
|
2200
|
+
delete response.reason
|
|
2201
|
+
response.targetReason = result.reason
|
|
2202
|
+
}
|
|
2203
|
+
return response
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2000
2206
|
_subagentSnapshots(session, history = session?.historyItems) {
|
|
2001
2207
|
const items = Array.isArray(history) ? history : []
|
|
2002
2208
|
const startedIndexByThread = new Map()
|
|
@@ -2077,21 +2283,47 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2077
2283
|
{ agentPath: record.agentPath, metadata, atMs: lastTurnAtMs },
|
|
2078
2284
|
{ emit: false },
|
|
2079
2285
|
)
|
|
2080
|
-
} else if (
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2286
|
+
} else if (thread?.status?.type === "active") {
|
|
2287
|
+
if (
|
|
2288
|
+
lastTurn?.status === "inProgress" &&
|
|
2289
|
+
typeof lastTurn.id === "string" &&
|
|
2290
|
+
lastTurn.id
|
|
2291
|
+
) {
|
|
2292
|
+
this._setSubagentPhase(
|
|
2293
|
+
session,
|
|
2294
|
+
record.agentThreadId,
|
|
2295
|
+
"started",
|
|
2296
|
+
{
|
|
2297
|
+
agentPath: record.agentPath,
|
|
2298
|
+
metadata,
|
|
2299
|
+
atMs: record.startedAtMs,
|
|
2300
|
+
},
|
|
2301
|
+
{ emit: false },
|
|
2302
|
+
)
|
|
2303
|
+
this._touchSubagentInterruptRecord(session, record.agentThreadId, {
|
|
2304
|
+
turnId: lastTurn.id,
|
|
2305
|
+
startedAtMs: lastTurnAtMs,
|
|
2306
|
+
})
|
|
2307
|
+
} else {
|
|
2308
|
+
// active 表示なのに停止 RPC に必要な turn ID が無い。running と表示すると停止可能な
|
|
2309
|
+
// capability を偽るため、cold snapshot は正直な terminal へ倒す。
|
|
2310
|
+
this._setSubagentPhase(
|
|
2311
|
+
session,
|
|
2312
|
+
record.agentThreadId,
|
|
2313
|
+
"failed",
|
|
2314
|
+
{ agentPath: record.agentPath, metadata, atMs: lastTurnAtMs },
|
|
2315
|
+
{ emit: false },
|
|
2316
|
+
)
|
|
2317
|
+
this.logger?.warn?.(
|
|
2318
|
+
{
|
|
2319
|
+
thread_id: session.threadId,
|
|
2320
|
+
subagent_thread_id: record.agentThreadId,
|
|
2321
|
+
child_status: thread?.status?.type || null,
|
|
2322
|
+
last_turn_status: lastTurn?.status || null,
|
|
2323
|
+
},
|
|
2324
|
+
"codex active subagent resume lacked an interruptible turn id; marked failed",
|
|
2325
|
+
)
|
|
2326
|
+
}
|
|
2095
2327
|
} else {
|
|
2096
2328
|
session.subagents.delete(record.agentThreadId)
|
|
2097
2329
|
}
|
|
@@ -2168,6 +2400,14 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2168
2400
|
// ※ hub-agent 再起動 (cold resume) では historyItems が揮発するため青バブルへ戻る (claude の
|
|
2169
2401
|
// jsonl at-rest と同じ許容範囲。source 永続マップは TTL/ID 再利用の複雑さに見合わないため不採用)。
|
|
2170
2402
|
const eventThreadId = msg?.params?.threadId
|
|
2403
|
+
if (
|
|
2404
|
+
typeof eventThreadId === "string" &&
|
|
2405
|
+
eventThreadId !== session.threadId &&
|
|
2406
|
+
session.subagentInterruptRegistry.has(eventThreadId)
|
|
2407
|
+
) {
|
|
2408
|
+
// lifecycle 以外の message/reasoning/tool delta も子 turn の生存証跡。root 通知は更新しない。
|
|
2409
|
+
this._heartbeatSubagentInterruptRecord(session, eventThreadId)
|
|
2410
|
+
}
|
|
2171
2411
|
const taggedParams = this._tagInjectSourceOnItem(session, msg)
|
|
2172
2412
|
let effectiveParams = taggedParams || msg.params
|
|
2173
2413
|
const childRecord =
|
|
@@ -2350,6 +2590,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2350
2590
|
|
|
2351
2591
|
_onClientExit(session, info) {
|
|
2352
2592
|
if (session.dead) return
|
|
2593
|
+
this._markRunningSubagentsKilled(session)
|
|
2353
2594
|
session.dead = true
|
|
2354
2595
|
// round3: app-server が死んだら barrier を解除し queue を reject する(promise 宙吊り防止)。
|
|
2355
2596
|
this._settleTurnActiveWaiter(session)
|
|
@@ -2614,15 +2855,17 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2614
2855
|
|
|
2615
2856
|
/**
|
|
2616
2857
|
* @returns {Promise<
|
|
2617
|
-
*
|
|
2618
|
-
*
|
|
2619
|
-
*
|
|
2858
|
+
* ({children: object[], skipped: object[]} & (
|
|
2859
|
+
* | { outcome: "interrupted", turnId: string, corrected: boolean }
|
|
2860
|
+
* | { outcome: "no_active_turn" }
|
|
2861
|
+
* | { outcome: "failed", reason: string }
|
|
2862
|
+
* ))
|
|
2620
2863
|
* >}
|
|
2621
2864
|
*/
|
|
2622
|
-
async interrupt({ stream_id }) {
|
|
2865
|
+
async interrupt({ stream_id, preserve_background_subagents = false }) {
|
|
2623
2866
|
const session = this._require(stream_id)
|
|
2624
2867
|
this._touch(session, stream_id)
|
|
2625
|
-
if (!session.activeTurnId) return { outcome: "no_active_turn", children: [] }
|
|
2868
|
+
if (!session.activeTurnId) return { outcome: "no_active_turn", children: [], skipped: [] }
|
|
2626
2869
|
|
|
2627
2870
|
const turnId = session.activeTurnId
|
|
2628
2871
|
let attemptedRootTurnId = turnId
|
|
@@ -2665,13 +2908,35 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
2665
2908
|
const children = []
|
|
2666
2909
|
const targetRootTurnId = attemptedRootTurnId
|
|
2667
2910
|
const interruptSnapshot = [...session.subagentInterruptRegistry.values()].map((record) => ({ ...record }))
|
|
2911
|
+
// skipped は「今回の owner turn の停止候補」ではなく、応答時点で実際に継続中の
|
|
2912
|
+
// root 直下 background 一覧。frontend の「継続中」表示と一致させるため owner に依存しない。
|
|
2913
|
+
const skipped = preserve_background_subagents === true
|
|
2914
|
+
? interruptSnapshot.flatMap((record) => {
|
|
2915
|
+
const displayed = session.subagents.get(record.threadId)
|
|
2916
|
+
if (
|
|
2917
|
+
!record.turnId ||
|
|
2918
|
+
record.parentThreadId !== session.threadId ||
|
|
2919
|
+
displayed?.background !== true
|
|
2920
|
+
) return []
|
|
2921
|
+
return [{
|
|
2922
|
+
threadId: record.threadId,
|
|
2923
|
+
delegationId: displayed.delegationId,
|
|
2924
|
+
reason: "background",
|
|
2925
|
+
}]
|
|
2926
|
+
})
|
|
2927
|
+
: []
|
|
2668
2928
|
for (const record of interruptSnapshot) {
|
|
2669
2929
|
if (!record.turnId) continue
|
|
2670
2930
|
const ownership = this._subagentOwnership(session, record)
|
|
2671
|
-
if (
|
|
2672
|
-
|
|
2931
|
+
if (preserve_background_subagents === true && ownership.hasBackgroundAncestor) {
|
|
2932
|
+
continue
|
|
2933
|
+
}
|
|
2934
|
+
if (ownership.ownerRootTurnId !== targetRootTurnId) continue
|
|
2935
|
+
children.push(await this._interruptSubagent(session, { ...record }, targetRootTurnId, {
|
|
2936
|
+
allowBackground: preserve_background_subagents !== true,
|
|
2937
|
+
}))
|
|
2673
2938
|
}
|
|
2674
|
-
return { ...result, children }
|
|
2939
|
+
return { ...result, children, skipped }
|
|
2675
2940
|
}
|
|
2676
2941
|
|
|
2677
2942
|
/** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
|
|
@@ -3298,13 +3563,57 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3298
3563
|
// ⚠️ activeTurnId の判定より **前** に置くこと。背景コマンドが残るのは turn/completed の
|
|
3299
3564
|
// 後(= activeTurnId が null に戻った後)なので、下の early return を通すと届かない。
|
|
3300
3565
|
if (this._hasLiveExec(session, now)) return true
|
|
3566
|
+
if (this._hasLiveSubagentTurn(session, now)) return true
|
|
3301
3567
|
if (!session.activeTurnId) return false
|
|
3302
3568
|
return now - (session.lastActivityMs ?? now) <= this._stuckTurnTtlMs
|
|
3303
3569
|
}
|
|
3304
3570
|
|
|
3571
|
+
_hasLiveSubagentTurn(session, now = Date.now()) {
|
|
3572
|
+
const registry = session?.subagentInterruptRegistry
|
|
3573
|
+
if (!registry || registry.size === 0) return false
|
|
3574
|
+
let active = false
|
|
3575
|
+
for (const record of registry.values()) {
|
|
3576
|
+
if (!record?.turnId) continue
|
|
3577
|
+
const elapsed = now - (record.lastSeenAtMs ?? 0)
|
|
3578
|
+
if (elapsed <= this._stuckTurnTtlMs) {
|
|
3579
|
+
active = true
|
|
3580
|
+
continue
|
|
3581
|
+
}
|
|
3582
|
+
// TTL は warm-session 保護だけを失効させる。停止 RPC に必要な turnId は破壊しない。
|
|
3583
|
+
// 長時間無出力の正常な子でも、明示停止を受けたときに同じ handle を利用できるようにする。
|
|
3584
|
+
if (record.protectionExpiredAtMs === undefined) {
|
|
3585
|
+
record.protectionExpiredAtMs = now
|
|
3586
|
+
this.logger?.warn?.(
|
|
3587
|
+
{
|
|
3588
|
+
stream_id: session.stream_id,
|
|
3589
|
+
thread_id: record.threadId,
|
|
3590
|
+
turn_id: record.turnId,
|
|
3591
|
+
elapsed_ms: elapsed,
|
|
3592
|
+
ttl_ms: this._stuckTurnTtlMs,
|
|
3593
|
+
},
|
|
3594
|
+
"codex subagent turn protection expired",
|
|
3595
|
+
)
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
return active
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
_markRunningSubagentsKilled(session, atMs = Date.now()) {
|
|
3602
|
+
for (const record of session?.subagents?.values?.() || []) {
|
|
3603
|
+
if (record.phase !== "started") continue
|
|
3604
|
+
this._setSubagentPhase(
|
|
3605
|
+
session,
|
|
3606
|
+
record.agentThreadId,
|
|
3607
|
+
"killed",
|
|
3608
|
+
{ agentPath: record.agentPath, atMs },
|
|
3609
|
+
)
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
|
|
3305
3613
|
/** 実際に app-server を止めてセッションを撤去し exit を emit する(reconcile 共通処理)。 */
|
|
3306
3614
|
_reapSession(session, now, reason) {
|
|
3307
3615
|
if (session.dead) return
|
|
3616
|
+
this._markRunningSubagentsKilled(session, now)
|
|
3308
3617
|
session.dead = true
|
|
3309
3618
|
// round3: reap した session の barrier を解除し queue を reject する(promise 宙吊り防止)。
|
|
3310
3619
|
this._settleTurnActiveWaiter(session)
|
|
@@ -3492,6 +3801,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
3492
3801
|
shutdown() {
|
|
3493
3802
|
// 案B: distinct session で走査(多対一 Map の重複 stop を避ける)。
|
|
3494
3803
|
for (const session of this._liveSessions()) {
|
|
3804
|
+
this._markRunningSubagentsKilled(session)
|
|
3495
3805
|
session.dead = true
|
|
3496
3806
|
// round3: shutdown で barrier を解除し queue 入力を reject(promise 宙吊り防止)。
|
|
3497
3807
|
this._settleTurnActiveWaiter(session)
|
package/src/main.mjs
CHANGED
|
@@ -978,6 +978,9 @@ export async function startDaemon({ version, claudeSdk } = {}) {
|
|
|
978
978
|
claudeBridge.on("exit", ({ stream_id, code, reason, session_id }) => {
|
|
979
979
|
client.send({ type: "claude.exit", stream_id, code, reason, session_id })
|
|
980
980
|
})
|
|
981
|
+
claudeBridge.on("sessionoptions", (message) => {
|
|
982
|
+
client.send(buildClaudeSessionOptionsPayload(message))
|
|
983
|
+
})
|
|
981
984
|
claudeBridge.on("turnsettled", ({ cwd }) => {
|
|
982
985
|
// RC-4: result を届けずにターンが終わった (abort/異常終了)。チャット信号の
|
|
983
986
|
// status=processing を waiting に落として三点リーダー/ステータスドットの固着を防ぐ
|
|
@@ -2052,6 +2055,58 @@ export function buildCodexChatPatch(status, contextPct, threadId, isAuthority) {
|
|
|
2052
2055
|
return patch
|
|
2053
2056
|
}
|
|
2054
2057
|
|
|
2058
|
+
/** 次ターンに使う desired options。現在ターンの setter 成功を観測した値ではない。 */
|
|
2059
|
+
function desiredOptionsWire(snapshot) {
|
|
2060
|
+
// stream_missing はcurrent不明。null(default)と偽装せずoptions fields自体を省く。
|
|
2061
|
+
if (!snapshot) return {}
|
|
2062
|
+
return {
|
|
2063
|
+
model: snapshot?.model ?? null,
|
|
2064
|
+
permission_mode: snapshot?.permission_mode ?? null,
|
|
2065
|
+
options_instance_id: snapshot?.options_instance_id,
|
|
2066
|
+
options_revision: snapshot?.options_revision,
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
/** claude.ready の wire payload。bridge attach info の options snapshot を透過する。 */
|
|
2071
|
+
export function buildClaudeReadyPayload(streamId, info) {
|
|
2072
|
+
return {
|
|
2073
|
+
type: "claude.ready",
|
|
2074
|
+
stream_id: streamId,
|
|
2075
|
+
resuming: info?.resuming === true,
|
|
2076
|
+
...desiredOptionsWire(info),
|
|
2077
|
+
turn_active: info?.turn_active === true,
|
|
2078
|
+
history_pushed: info?.resuming === true,
|
|
2079
|
+
supports_queue_input_id: true,
|
|
2080
|
+
supports_queue_snapshot: true,
|
|
2081
|
+
supports_session_options_cas: true,
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
/** bridge internal options event → session fanout wire。 */
|
|
2086
|
+
export function buildClaudeSessionOptionsPayload(message) {
|
|
2087
|
+
return {
|
|
2088
|
+
type: "claude.session.options",
|
|
2089
|
+
stream_id: message.stream_id,
|
|
2090
|
+
session_id: message.session_id ?? null,
|
|
2091
|
+
...desiredOptionsWire(message),
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/** options CAS result は要求元 stream だけへ返す。 */
|
|
2096
|
+
export function buildClaudeSessionOptionsResultPayload(
|
|
2097
|
+
streamId,
|
|
2098
|
+
requestId,
|
|
2099
|
+
result,
|
|
2100
|
+
) {
|
|
2101
|
+
return {
|
|
2102
|
+
type: "claude.session.options.result",
|
|
2103
|
+
stream_id: streamId,
|
|
2104
|
+
request_id: requestId,
|
|
2105
|
+
outcome: result.outcome,
|
|
2106
|
+
...desiredOptionsWire(result.current),
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2055
2110
|
/** codex.ready の wire payload を組み立てる。history は同じ配列参照を保持し、
|
|
2056
2111
|
* started_history_index が指す位置を main 側の加工でずらさない。表示を保持する reconnect
|
|
2057
2112
|
* (`info.subagents` 未定義) では subagents キー自体を省略し、空 snapshot で既存表示を消さない。 */
|
|
@@ -2068,6 +2123,9 @@ export function buildCodexReadyPayload(streamId, info, queueState) {
|
|
|
2068
2123
|
thread_status: info?.threadStatus || null,
|
|
2069
2124
|
last_turn_status: info?.lastTurnStatus || null,
|
|
2070
2125
|
queue_state: queueState,
|
|
2126
|
+
// Browser は旧 hub-agent との混在期間、この明示 capability が true のときだけ
|
|
2127
|
+
// 子 thread の個別停止 UI を有効化する。省略は未対応を意味する。
|
|
2128
|
+
supports_subagent_interrupt: true,
|
|
2071
2129
|
}
|
|
2072
2130
|
if (Array.isArray(info?.subagents)) {
|
|
2073
2131
|
payload.subagents = info.subagents.map((subagent) => ({
|
|
@@ -3194,26 +3252,69 @@ export function handleCodexChatInput(msg, ctx) {
|
|
|
3194
3252
|
return p
|
|
3195
3253
|
}
|
|
3196
3254
|
|
|
3255
|
+
/**
|
|
3256
|
+
* codex.interrupt を root / individual へ振り分ける。
|
|
3257
|
+
* - delegation_id と agent_thread_id が非空文字列なら individual(request_id 必須・echo する)
|
|
3258
|
+
* - 両 ID が欠落または null なら root(現 frontend 契約。request_id は相関に使わず常に null)
|
|
3259
|
+
* - 片方だけ、または非 null の不正値が混ざれば individual ID validation error
|
|
3260
|
+
*/
|
|
3197
3261
|
export async function handleCodexInterrupt(msg, ctx) {
|
|
3198
3262
|
let result
|
|
3199
|
-
|
|
3200
|
-
|
|
3263
|
+
const hasDelegationId = typeof msg.delegation_id === "string" && msg.delegation_id.length > 0
|
|
3264
|
+
const hasAgentThreadId = typeof msg.agent_thread_id === "string" && msg.agent_thread_id.length > 0
|
|
3265
|
+
const hasEitherId = msg.delegation_id != null || msg.agent_thread_id != null
|
|
3266
|
+
const isSubagentInterrupt = hasDelegationId && hasAgentThreadId
|
|
3267
|
+
const hasRequestId = typeof msg.request_id === "string" && msg.request_id.length > 0
|
|
3268
|
+
if (hasEitherId && !(hasDelegationId && hasAgentThreadId)) {
|
|
3269
|
+
result = { outcome: "failed", reason: "invalid_subagent_interrupt_ids", children: [], skipped: [] }
|
|
3270
|
+
} else if (isSubagentInterrupt && !hasRequestId) {
|
|
3271
|
+
result = { outcome: "failed", reason: "invalid_subagent_interrupt_request_id", children: [], skipped: [] }
|
|
3272
|
+
} else if (!ctx.codexBridge) {
|
|
3273
|
+
result = { outcome: "failed", reason: "bridge_unavailable", children: [], skipped: [] }
|
|
3201
3274
|
} else {
|
|
3202
3275
|
try {
|
|
3203
|
-
|
|
3204
|
-
|
|
3276
|
+
const bridgeResult = hasDelegationId
|
|
3277
|
+
? await ctx.codexBridge.interruptSubagent({
|
|
3278
|
+
stream_id: msg.stream_id,
|
|
3279
|
+
delegation_id: msg.delegation_id,
|
|
3280
|
+
agent_thread_id: msg.agent_thread_id,
|
|
3281
|
+
})
|
|
3282
|
+
: await ctx.codexBridge.interrupt({
|
|
3283
|
+
stream_id: msg.stream_id,
|
|
3284
|
+
preserve_background_subagents: msg.preserve_background_subagents === true,
|
|
3285
|
+
})
|
|
3286
|
+
const validOutcome = ["interrupted", "failed", "no_active_turn"].includes(
|
|
3287
|
+
bridgeResult?.outcome,
|
|
3288
|
+
)
|
|
3289
|
+
if (!bridgeResult || typeof bridgeResult !== "object" || Array.isArray(bridgeResult) || !validOutcome) {
|
|
3290
|
+
ctx.logger?.warn?.(
|
|
3291
|
+
{
|
|
3292
|
+
bridge_result_type: Array.isArray(bridgeResult) ? "array" : typeof bridgeResult,
|
|
3293
|
+
bridge_outcome: typeof bridgeResult?.outcome === "string" ? bridgeResult.outcome : null,
|
|
3294
|
+
},
|
|
3295
|
+
"codex.interrupt bridge returned an invalid result",
|
|
3296
|
+
)
|
|
3297
|
+
result = { outcome: "failed", reason: "invalid_bridge_result", children: [], skipped: [] }
|
|
3298
|
+
} else {
|
|
3299
|
+
result = {
|
|
3300
|
+
...bridgeResult,
|
|
3301
|
+
children: Array.isArray(bridgeResult.children) ? bridgeResult.children : [],
|
|
3302
|
+
skipped: Array.isArray(bridgeResult.skipped) ? bridgeResult.skipped : [],
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
if (result.outcome === "failed" && result.reason !== "invalid_bridge_result") {
|
|
3205
3306
|
ctx.logger?.warn?.({ err: result.reason }, "codex.interrupt failed")
|
|
3206
3307
|
}
|
|
3207
3308
|
} catch (err) {
|
|
3208
3309
|
const reason = err?.message || String(err)
|
|
3209
3310
|
ctx.logger?.warn?.({ err: reason }, "codex.interrupt failed")
|
|
3210
|
-
result = { outcome: "failed", reason, children: [] }
|
|
3311
|
+
result = { outcome: "failed", reason, children: [], skipped: [] }
|
|
3211
3312
|
}
|
|
3212
3313
|
}
|
|
3213
3314
|
ctx.client.send({
|
|
3214
3315
|
type: "codex.interrupt.response",
|
|
3215
3316
|
stream_id: msg.stream_id,
|
|
3216
|
-
request_id: msg.request_id
|
|
3317
|
+
request_id: isSubagentInterrupt && hasRequestId ? msg.request_id : null,
|
|
3217
3318
|
...result,
|
|
3218
3319
|
})
|
|
3219
3320
|
}
|
|
@@ -3768,27 +3869,7 @@ async function dispatch(msg, ctx) {
|
|
|
3768
3869
|
effort: msg.effort || ctx.config?.claude_effort || null,
|
|
3769
3870
|
resumeSessionId,
|
|
3770
3871
|
})
|
|
3771
|
-
ctx.client.send(
|
|
3772
|
-
type: "claude.ready",
|
|
3773
|
-
stream_id,
|
|
3774
|
-
resuming: info.resuming,
|
|
3775
|
-
// 再接続時の生成状態は resident session を保持する hub-agent が正本。
|
|
3776
|
-
// jsonl 履歴末尾の推測だけでは「実際は busy なのに UI は idle」となり、
|
|
3777
|
-
// 送信ボタンが楽観バブルを作る一方 agent はキューへ積む二重表示が起きる。
|
|
3778
|
-
turn_active: info.turn_active === true,
|
|
3779
|
-
history_pushed: resumeSessionId !== null,
|
|
3780
|
-
// id 空間統一 (2026-07-08): この agent は browser の input_id をキュー id として
|
|
3781
|
-
// 採用し、楽観送信待ちアイテムの確認前キャンセルに対応することを通知する capability。
|
|
3782
|
-
// 旧 agent にはこのフラグが無く、browser は楽観アイテムの×を出さない (ゴースト
|
|
3783
|
-
// 実行防止のフォールバック)。
|
|
3784
|
-
supports_queue_input_id: true,
|
|
3785
|
-
// pull 型再同期 capability (T06603, 2026-07-25): この agent は
|
|
3786
|
-
// claude.queue.request に claude.queue.response で答え、queue_state に
|
|
3787
|
-
// 版番号 (seq) と受理済み input_id (accepted) を載せることを通知する。
|
|
3788
|
-
// 旧 agent はこのフラグが無く、browser は従来の「配信到達に依存する」
|
|
3789
|
-
// 挙動へフォールバックする (混在期間も安全に段階デプロイできる)。
|
|
3790
|
-
supports_queue_snapshot: true,
|
|
3791
|
-
})
|
|
3872
|
+
ctx.client.send(buildClaudeReadyPayload(stream_id, info))
|
|
3792
3873
|
if (resumeSessionId) {
|
|
3793
3874
|
void ctx.siblingInjectBridge
|
|
3794
3875
|
?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
|
|
@@ -3874,6 +3955,23 @@ async function dispatch(msg, ctx) {
|
|
|
3874
3955
|
}
|
|
3875
3956
|
return
|
|
3876
3957
|
}
|
|
3958
|
+
case "claude.session.options.set": {
|
|
3959
|
+
const result = ctx.claudeBridge?.setSessionOptions({
|
|
3960
|
+
stream_id: msg.stream_id,
|
|
3961
|
+
session_id: msg.session_id ?? null,
|
|
3962
|
+
base_options_instance_id: msg.base_options_instance_id,
|
|
3963
|
+
base_options_revision: msg.base_options_revision,
|
|
3964
|
+
patch: msg.patch,
|
|
3965
|
+
}) || { outcome: "stream_missing", current: null }
|
|
3966
|
+
ctx.client.send(
|
|
3967
|
+
buildClaudeSessionOptionsResultPayload(
|
|
3968
|
+
msg.stream_id,
|
|
3969
|
+
msg.request_id,
|
|
3970
|
+
result,
|
|
3971
|
+
),
|
|
3972
|
+
)
|
|
3973
|
+
return
|
|
3974
|
+
}
|
|
3877
3975
|
case "claude.input":
|
|
3878
3976
|
// message: { role: 'user', content: string | Array } を期待
|
|
3879
3977
|
handleClaudeChatInput(msg, ctx)
|
package/src/ws-client.mjs
CHANGED
|
@@ -56,6 +56,9 @@ const MIN_BACKOFF_AFTER_5XX_MS = 5_000
|
|
|
56
56
|
// 「送信待ち(未配信)」が消えない (S1/S3)。再接続 flush で遅れて届けば即解消する。
|
|
57
57
|
// - claude/codex の event / ready / exit / error / permission:
|
|
58
58
|
// 喪失すると新規セッションの楽観バブルが本物へ昇格できず、再 attach も固着する。
|
|
59
|
+
// - claude.session.options.result: options.set は再要求型RPCではなく明示intentの終端。
|
|
60
|
+
// agent→Hub切断中のapplied resultも保持し、request_idで同streamへ戻す。frontend側の
|
|
61
|
+
// stale-stream/request mismatch/10s timeoutが遅延結果を無害化する(LWW圧縮はしない)。
|
|
59
62
|
// - claude.jsonl.event / session.event: TUI と一覧の確定状態を復元する。
|
|
60
63
|
// 高頻度の delta 系はここに積まず (下の isEphemeral*DeltaEvent で除外)、低頻度な
|
|
61
64
|
// ack/event がリング evict されるのを防ぐ (ack/event は 1 件ずつ意味を持つ)。
|
|
@@ -63,6 +66,8 @@ const BUFFERED_CTRL_TYPES = new Set([
|
|
|
63
66
|
"claude.jsonl.event",
|
|
64
67
|
"claude.event",
|
|
65
68
|
"claude.ready",
|
|
69
|
+
"claude.session.options",
|
|
70
|
+
"claude.session.options.result",
|
|
66
71
|
"claude.exit",
|
|
67
72
|
"claude.error",
|
|
68
73
|
"claude.permission.request",
|