@cocorograph/hub-agent 0.8.44 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.44",
3
+ "version": "0.8.45",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
- // background は spawn 時に確定する属性。後続通知に metadata が無くても true を失わない。
1672
- background: metadata?.background === true || previous?.background === true,
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: args.background === true,
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
- const expiredBefore = now - 4 * 60 * 60 * 1000
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
- if (candidate.turnId === null && candidate.lastSeenAtMs < expiredBefore) {
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 (session.subagentInterruptRegistry.size > 256) {
1908
- const oldest = [...session.subagentInterruptRegistry.entries()]
1909
- .filter(([, candidate]) => candidate.turnId === null)
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: session.subagentInterruptRegistry.size, limit: 256 },
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)) return { ownerRootTurnId: null, hasBackgroundAncestor }
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 { ownerRootTurnId: current.ownerTurnId || null, hasBackgroundAncestor }
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(session, record, targetRootTurnId) {
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
- latestOwnership?.ownerRootTurnId === targetRootTurnId &&
1964
- !latestOwnership.hasBackgroundAncestor
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
- thread?.status?.type === "active" &&
2082
- (!lastTurn || lastTurn.status === "inProgress")
2083
- ) {
2084
- this._setSubagentPhase(
2085
- session,
2086
- record.agentThreadId,
2087
- "started",
2088
- {
2089
- agentPath: record.agentPath,
2090
- metadata,
2091
- atMs: record.startedAtMs,
2092
- },
2093
- { emit: false },
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
- * | { outcome: "interrupted", turnId: string, corrected: boolean }
2618
- * | { outcome: "no_active_turn" }
2619
- * | { outcome: "failed", reason: string }
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 (ownership.ownerRootTurnId !== targetRootTurnId || ownership.hasBackgroundAncestor) continue
2672
- children.push(await this._interruptSubagent(session, { ...record }, targetRootTurnId))
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
@@ -2123,6 +2123,9 @@ export function buildCodexReadyPayload(streamId, info, queueState) {
2123
2123
  thread_status: info?.threadStatus || null,
2124
2124
  last_turn_status: info?.lastTurnStatus || null,
2125
2125
  queue_state: queueState,
2126
+ // Browser は旧 hub-agent との混在期間、この明示 capability が true のときだけ
2127
+ // 子 thread の個別停止 UI を有効化する。省略は未対応を意味する。
2128
+ supports_subagent_interrupt: true,
2126
2129
  }
2127
2130
  if (Array.isArray(info?.subagents)) {
2128
2131
  payload.subagents = info.subagents.map((subagent) => ({
@@ -3249,26 +3252,69 @@ export function handleCodexChatInput(msg, ctx) {
3249
3252
  return p
3250
3253
  }
3251
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
+ */
3252
3261
  export async function handleCodexInterrupt(msg, ctx) {
3253
3262
  let result
3254
- if (!ctx.codexBridge) {
3255
- result = { outcome: "failed", reason: "bridge_unavailable", children: [] }
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: [] }
3256
3274
  } else {
3257
3275
  try {
3258
- result = await ctx.codexBridge.interrupt({ stream_id: msg.stream_id })
3259
- if (result.outcome === "failed") {
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") {
3260
3306
  ctx.logger?.warn?.({ err: result.reason }, "codex.interrupt failed")
3261
3307
  }
3262
3308
  } catch (err) {
3263
3309
  const reason = err?.message || String(err)
3264
3310
  ctx.logger?.warn?.({ err: reason }, "codex.interrupt failed")
3265
- result = { outcome: "failed", reason, children: [] }
3311
+ result = { outcome: "failed", reason, children: [], skipped: [] }
3266
3312
  }
3267
3313
  }
3268
3314
  ctx.client.send({
3269
3315
  type: "codex.interrupt.response",
3270
3316
  stream_id: msg.stream_id,
3271
- request_id: msg.request_id ?? null,
3317
+ request_id: isSubagentInterrupt && hasRequestId ? msg.request_id : null,
3272
3318
  ...result,
3273
3319
  })
3274
3320
  }