@cocorograph/hub-agent 0.8.41 → 0.8.42

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.41",
3
+ "version": "0.8.42",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -770,6 +770,8 @@ export class CodexStreamBridge extends EventEmitter {
770
770
  /** 組み込み collaboration subagent の表示用台帳。検索キーは agentThreadId、
771
771
  * 外部へ出す安定した委譲 ID は subAgentActivity の lifecycle ID を保持する。 */
772
772
  subagents: new Map(),
773
+ /** 停止対象の因果関係と実行中 turn を保持する、表示台帳とは独立した台帳。 */
774
+ subagentInterruptRegistry: new Map(),
773
775
  /** dynamicToolCall(spawn_agent) の item.id -> 表示メタデータ。 */
774
776
  subagentSpawnMetadata: new Map(),
775
777
  /** live subAgentActivity の spawn metadata miss を child thread/read で補完する
@@ -1759,9 +1761,22 @@ export class CodexStreamBridge extends EventEmitter {
1759
1761
  session,
1760
1762
  item,
1761
1763
  atMs = Date.now(),
1762
- { emit = true, sourceThreadId = session?.threadId } = {},
1764
+ { emit = true, sourceThreadId = session?.threadId, sourceTurnId = null } = {},
1763
1765
  ) {
1764
1766
  if (!item || typeof item !== "object") return
1767
+ if (item.type === "subAgentActivity") {
1768
+ const childThreadId = item.agentThreadId
1769
+ if (typeof childThreadId === "string" && childThreadId && childThreadId !== session?.threadId) {
1770
+ const interruptFields = {}
1771
+ if (typeof sourceThreadId === "string" && sourceThreadId) {
1772
+ interruptFields.parentThreadId = sourceThreadId
1773
+ }
1774
+ if (typeof sourceTurnId === "string" && sourceTurnId) {
1775
+ interruptFields.ownerTurnId = sourceTurnId
1776
+ }
1777
+ this._touchSubagentInterruptRecord(session, childThreadId, interruptFields)
1778
+ }
1779
+ }
1765
1780
  // root thread の collaboration item だけが root subagent 台帳の正本。子 thread 内の
1766
1781
  // spawn/subAgentActivity(孫)を root の lifecycle/snapshot へ昇格しない。
1767
1782
  if (
@@ -1823,8 +1838,6 @@ export class CodexStreamBridge extends EventEmitter {
1823
1838
  _observeSubagentTurn(session, msg) {
1824
1839
  const threadId = msg?.params?.threadId
1825
1840
  if (typeof threadId !== "string" || threadId === session.threadId) return false
1826
- const record = session.subagents.get(threadId)
1827
- if (!record) return true
1828
1841
  const turn = msg.params?.turn
1829
1842
  const atMs =
1830
1843
  typeof msg.params?.completedAtMs === "number"
@@ -1836,6 +1849,16 @@ export class CodexStreamBridge extends EventEmitter {
1836
1849
  : typeof turn?.startedAt === "number"
1837
1850
  ? Math.round(turn.startedAt * 1000)
1838
1851
  : Date.now()
1852
+ const interruptRecord = this._touchSubagentInterruptRecord(session, threadId)
1853
+ const observedTurnId = typeof turn?.id === "string" ? turn.id : null
1854
+ if (msg.method === "turn/started" && observedTurnId) {
1855
+ interruptRecord.turnId = observedTurnId
1856
+ interruptRecord.startedAtMs = atMs
1857
+ } else if (msg.method === "turn/completed" && interruptRecord.turnId === observedTurnId) {
1858
+ interruptRecord.turnId = null
1859
+ }
1860
+ const record = session.subagents.get(threadId)
1861
+ if (!record) return true
1839
1862
  if (msg.method === "turn/started") {
1840
1863
  this._setSubagentPhase(session, threadId, "started", {
1841
1864
  agentPath: record.agentPath,
@@ -1853,6 +1876,127 @@ export class CodexStreamBridge extends EventEmitter {
1853
1876
  return true
1854
1877
  }
1855
1878
 
1879
+ _touchSubagentInterruptRecord(session, threadId, fields = {}) {
1880
+ const now = Date.now()
1881
+ let record = session.subagentInterruptRegistry.get(threadId)
1882
+ if (!record) {
1883
+ record = {
1884
+ threadId,
1885
+ turnId: null,
1886
+ parentThreadId: null,
1887
+ ownerTurnId: null,
1888
+ startedAtMs: null,
1889
+ lastSeenAtMs: now,
1890
+ }
1891
+ session.subagentInterruptRegistry.set(threadId, record)
1892
+ }
1893
+ const { parentThreadId, ownerTurnId, ...mutableFields } = fields
1894
+ Object.assign(record, mutableFields, { lastSeenAtMs: now })
1895
+ if (!record.parentThreadId && typeof parentThreadId === "string" && parentThreadId) {
1896
+ record.parentThreadId = parentThreadId
1897
+ }
1898
+ if (!record.ownerTurnId && typeof ownerTurnId === "string" && ownerTurnId) {
1899
+ record.ownerTurnId = ownerTurnId
1900
+ }
1901
+ const expiredBefore = now - 4 * 60 * 60 * 1000
1902
+ for (const [id, candidate] of session.subagentInterruptRegistry) {
1903
+ if (candidate.turnId === null && candidate.lastSeenAtMs < expiredBefore) {
1904
+ this._evictSubagentInterruptRecord(session, id, candidate)
1905
+ }
1906
+ }
1907
+ while (session.subagentInterruptRegistry.size > 256) {
1908
+ const oldest = [...session.subagentInterruptRegistry.entries()]
1909
+ .filter(([, candidate]) => candidate.turnId === null)
1910
+ .sort((a, b) => a[1].lastSeenAtMs - b[1].lastSeenAtMs)[0]
1911
+ if (!oldest) {
1912
+ this.logger?.warn?.(
1913
+ { size: session.subagentInterruptRegistry.size, limit: 256 },
1914
+ "codex subagent interrupt registry exceeds limit with all records active",
1915
+ )
1916
+ break
1917
+ }
1918
+ this._evictSubagentInterruptRecord(session, oldest[0], oldest[1])
1919
+ }
1920
+ return record
1921
+ }
1922
+
1923
+ _evictSubagentInterruptRecord(session, threadId, record) {
1924
+ if (record.turnId) {
1925
+ this.logger?.warn?.({ thread_id: threadId, turn_id: record.turnId }, "evicting active codex subagent interrupt record")
1926
+ }
1927
+ session.subagentInterruptRegistry.delete(threadId)
1928
+ }
1929
+
1930
+ _subagentOwnership(session, record) {
1931
+ const visited = new Set()
1932
+ let current = record
1933
+ let hasBackgroundAncestor = false
1934
+ while (current) {
1935
+ if (visited.has(current.threadId)) return { ownerRootTurnId: null, hasBackgroundAncestor }
1936
+ visited.add(current.threadId)
1937
+ if (session.subagents.get(current.threadId)?.background === true) {
1938
+ hasBackgroundAncestor = true
1939
+ }
1940
+ if (current.parentThreadId === session.threadId) {
1941
+ return { ownerRootTurnId: current.ownerTurnId || null, hasBackgroundAncestor }
1942
+ }
1943
+ current = session.subagentInterruptRegistry.get(current.parentThreadId)
1944
+ }
1945
+ return { ownerRootTurnId: null, hasBackgroundAncestor }
1946
+ }
1947
+
1948
+ async _interruptSubagent(session, record, targetRootTurnId) {
1949
+ const displayed = session.subagents.get(record.threadId)
1950
+ const backgroundKnown = displayed ? typeof displayed.background === "boolean" : false
1951
+ const background = backgroundKnown ? displayed.background : null
1952
+ try {
1953
+ await session.client.request("turn/interrupt", { threadId: record.threadId, turnId: record.turnId })
1954
+ return { threadId: record.threadId, turnId: record.turnId, outcome: "interrupted", background, backgroundKnown }
1955
+ } catch (err) {
1956
+ const reason = err?.message || String(err)
1957
+ const mismatch = parseTurnMismatch(reason)
1958
+ const latestRecord = session.subagentInterruptRegistry.get(record.threadId)
1959
+ const latestOwnership = latestRecord ? this._subagentOwnership(session, latestRecord) : null
1960
+ if (
1961
+ mismatch &&
1962
+ latestRecord?.turnId === mismatch.found &&
1963
+ latestOwnership?.ownerRootTurnId === targetRootTurnId &&
1964
+ !latestOwnership.hasBackgroundAncestor
1965
+ ) {
1966
+ try {
1967
+ await session.client.request("turn/interrupt", {
1968
+ threadId: latestRecord.threadId,
1969
+ turnId: latestRecord.turnId,
1970
+ })
1971
+ return {
1972
+ threadId: latestRecord.threadId,
1973
+ turnId: latestRecord.turnId,
1974
+ outcome: "interrupted",
1975
+ background,
1976
+ backgroundKnown,
1977
+ }
1978
+ } catch (retryErr) {
1979
+ return {
1980
+ threadId: latestRecord.threadId,
1981
+ turnId: latestRecord.turnId,
1982
+ outcome: "failed",
1983
+ reason: retryErr?.message || String(retryErr),
1984
+ background,
1985
+ backgroundKnown,
1986
+ }
1987
+ }
1988
+ }
1989
+ return {
1990
+ threadId: record.threadId,
1991
+ turnId: record.turnId,
1992
+ outcome: "failed",
1993
+ reason,
1994
+ background,
1995
+ backgroundKnown,
1996
+ }
1997
+ }
1998
+ }
1999
+
1856
2000
  _subagentSnapshots(session, history = session?.historyItems) {
1857
2001
  const items = Array.isArray(history) ? history : []
1858
2002
  const startedIndexByThread = new Map()
@@ -2063,7 +2207,7 @@ export class CodexStreamBridge extends EventEmitter {
2063
2207
  session,
2064
2208
  effectiveParams?.item,
2065
2209
  effectiveParams?.completedAtMs || Date.now(),
2066
- { sourceThreadId: eventThreadId },
2210
+ { sourceThreadId: eventThreadId, sourceTurnId: msg.params?.turnId },
2067
2211
  )
2068
2212
  }
2069
2213
  const childTurnNotification =
@@ -2478,20 +2622,23 @@ export class CodexStreamBridge extends EventEmitter {
2478
2622
  async interrupt({ stream_id }) {
2479
2623
  const session = this._require(stream_id)
2480
2624
  this._touch(session, stream_id)
2481
- if (!session.activeTurnId) return { outcome: "no_active_turn" }
2625
+ if (!session.activeTurnId) return { outcome: "no_active_turn", children: [] }
2482
2626
 
2483
2627
  const turnId = session.activeTurnId
2628
+ let attemptedRootTurnId = turnId
2629
+ let result
2484
2630
  try {
2485
2631
  await session.client.request("turn/interrupt", {
2486
2632
  threadId: session.threadId,
2487
2633
  turnId,
2488
2634
  })
2489
- return { outcome: "interrupted", turnId, corrected: false }
2635
+ result = { outcome: "interrupted", turnId, corrected: false }
2490
2636
  } catch (err) {
2491
2637
  const reason = err?.message || String(err)
2492
2638
  const mismatch = parseTurnMismatch(reason)
2493
- if (!mismatch) return { outcome: "failed", reason }
2639
+ if (!mismatch) result = { outcome: "failed", reason }
2494
2640
 
2641
+ if (mismatch) {
2495
2642
  const correctedTurnId = mismatch.found
2496
2643
  // 是正は再送前に行い、再送中の corrected turn の完了通知を正しく照合できるようにする。
2497
2644
  // 初回リクエスト中に状態が進んでいた場合は、その新しい状態を上書きしない。
@@ -2499,20 +2646,32 @@ export class CodexStreamBridge extends EventEmitter {
2499
2646
  session.activeTurnId = correctedTurnId
2500
2647
  }
2501
2648
  try {
2649
+ attemptedRootTurnId = correctedTurnId
2502
2650
  await session.client.request("turn/interrupt", {
2503
2651
  threadId: session.threadId,
2504
2652
  turnId: correctedTurnId,
2505
2653
  })
2506
- return { outcome: "interrupted", turnId: correctedTurnId, corrected: true }
2654
+ result = { outcome: "interrupted", turnId: correctedTurnId, corrected: true }
2507
2655
  } catch (retryErr) {
2508
2656
  const retryReason = retryErr?.message || String(retryErr)
2509
2657
  const retryMismatch = parseTurnMismatch(retryReason)
2510
2658
  if (retryMismatch && session.activeTurnId === correctedTurnId) {
2511
2659
  session.activeTurnId = retryMismatch.found
2512
2660
  }
2513
- return { outcome: "failed", reason: retryReason }
2661
+ result = { outcome: "failed", reason: retryReason }
2662
+ }
2514
2663
  }
2515
2664
  }
2665
+ const children = []
2666
+ const targetRootTurnId = attemptedRootTurnId
2667
+ const interruptSnapshot = [...session.subagentInterruptRegistry.values()].map((record) => ({ ...record }))
2668
+ for (const record of interruptSnapshot) {
2669
+ if (!record.turnId) continue
2670
+ const ownership = this._subagentOwnership(session, record)
2671
+ if (ownership.ownerRootTurnId !== targetRootTurnId || ownership.hasBackgroundAncestor) continue
2672
+ children.push(await this._interruptSubagent(session, { ...record }, targetRootTurnId))
2673
+ }
2674
+ return { ...result, children }
2516
2675
  }
2517
2676
 
2518
2677
  /** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
package/src/main.mjs CHANGED
@@ -3197,7 +3197,7 @@ export function handleCodexChatInput(msg, ctx) {
3197
3197
  export async function handleCodexInterrupt(msg, ctx) {
3198
3198
  let result
3199
3199
  if (!ctx.codexBridge) {
3200
- result = { outcome: "failed", reason: "bridge_unavailable" }
3200
+ result = { outcome: "failed", reason: "bridge_unavailable", children: [] }
3201
3201
  } else {
3202
3202
  try {
3203
3203
  result = await ctx.codexBridge.interrupt({ stream_id: msg.stream_id })
@@ -3207,7 +3207,7 @@ export async function handleCodexInterrupt(msg, ctx) {
3207
3207
  } catch (err) {
3208
3208
  const reason = err?.message || String(err)
3209
3209
  ctx.logger?.warn?.({ err: reason }, "codex.interrupt failed")
3210
- result = { outcome: "failed", reason }
3210
+ result = { outcome: "failed", reason, children: [] }
3211
3211
  }
3212
3212
  }
3213
3213
  ctx.client.send({