@cocorograph/hub-agent 0.8.40 → 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.40",
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",
@@ -56,6 +56,11 @@ import { readPositiveIntEnv, sanitizePositiveInt } from "./positive-int-env.mjs"
56
56
  import { nextSessionSeq } from "./session-seq.mjs"
57
57
  import { codexRolloutPresence, listCodexThreadsFromDisk } from "./usage.mjs"
58
58
 
59
+ function parseTurnMismatch(reason) {
60
+ const mismatch = /^expected active turn id (\S+) but found (\S+)$/.exec(reason)
61
+ return mismatch ? { expected: mismatch[1], found: mismatch[2] } : null
62
+ }
63
+
59
64
  /**
60
65
  * sibling-inject (2026-07-04) 用: spawn する codex app-server プロセス自身の
61
66
  * 環境変数に、この cwd の capability トークンを乗せる。ディスクには書かず、
@@ -765,6 +770,8 @@ export class CodexStreamBridge extends EventEmitter {
765
770
  /** 組み込み collaboration subagent の表示用台帳。検索キーは agentThreadId、
766
771
  * 外部へ出す安定した委譲 ID は subAgentActivity の lifecycle ID を保持する。 */
767
772
  subagents: new Map(),
773
+ /** 停止対象の因果関係と実行中 turn を保持する、表示台帳とは独立した台帳。 */
774
+ subagentInterruptRegistry: new Map(),
768
775
  /** dynamicToolCall(spawn_agent) の item.id -> 表示メタデータ。 */
769
776
  subagentSpawnMetadata: new Map(),
770
777
  /** live subAgentActivity の spawn metadata miss を child thread/read で補完する
@@ -1754,9 +1761,22 @@ export class CodexStreamBridge extends EventEmitter {
1754
1761
  session,
1755
1762
  item,
1756
1763
  atMs = Date.now(),
1757
- { emit = true, sourceThreadId = session?.threadId } = {},
1764
+ { emit = true, sourceThreadId = session?.threadId, sourceTurnId = null } = {},
1758
1765
  ) {
1759
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
+ }
1760
1780
  // root thread の collaboration item だけが root subagent 台帳の正本。子 thread 内の
1761
1781
  // spawn/subAgentActivity(孫)を root の lifecycle/snapshot へ昇格しない。
1762
1782
  if (
@@ -1818,8 +1838,6 @@ export class CodexStreamBridge extends EventEmitter {
1818
1838
  _observeSubagentTurn(session, msg) {
1819
1839
  const threadId = msg?.params?.threadId
1820
1840
  if (typeof threadId !== "string" || threadId === session.threadId) return false
1821
- const record = session.subagents.get(threadId)
1822
- if (!record) return true
1823
1841
  const turn = msg.params?.turn
1824
1842
  const atMs =
1825
1843
  typeof msg.params?.completedAtMs === "number"
@@ -1831,6 +1849,16 @@ export class CodexStreamBridge extends EventEmitter {
1831
1849
  : typeof turn?.startedAt === "number"
1832
1850
  ? Math.round(turn.startedAt * 1000)
1833
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
1834
1862
  if (msg.method === "turn/started") {
1835
1863
  this._setSubagentPhase(session, threadId, "started", {
1836
1864
  agentPath: record.agentPath,
@@ -1848,6 +1876,127 @@ export class CodexStreamBridge extends EventEmitter {
1848
1876
  return true
1849
1877
  }
1850
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
+
1851
2000
  _subagentSnapshots(session, history = session?.historyItems) {
1852
2001
  const items = Array.isArray(history) ? history : []
1853
2002
  const startedIndexByThread = new Map()
@@ -2058,7 +2207,7 @@ export class CodexStreamBridge extends EventEmitter {
2058
2207
  session,
2059
2208
  effectiveParams?.item,
2060
2209
  effectiveParams?.completedAtMs || Date.now(),
2061
- { sourceThreadId: eventThreadId },
2210
+ { sourceThreadId: eventThreadId, sourceTurnId: msg.params?.turnId },
2062
2211
  )
2063
2212
  }
2064
2213
  const childTurnNotification =
@@ -2463,15 +2612,66 @@ export class CodexStreamBridge extends EventEmitter {
2463
2612
  if (!session.dead) this._emitQueueState(session)
2464
2613
  }
2465
2614
 
2466
- /** @returns {Promise<void>} */
2615
+ /**
2616
+ * @returns {Promise<
2617
+ * | { outcome: "interrupted", turnId: string, corrected: boolean }
2618
+ * | { outcome: "no_active_turn" }
2619
+ * | { outcome: "failed", reason: string }
2620
+ * >}
2621
+ */
2467
2622
  async interrupt({ stream_id }) {
2468
2623
  const session = this._require(stream_id)
2469
2624
  this._touch(session, stream_id)
2470
- if (!session.activeTurnId) return
2471
- await session.client.request("turn/interrupt", {
2472
- threadId: session.threadId,
2473
- turnId: session.activeTurnId,
2474
- })
2625
+ if (!session.activeTurnId) return { outcome: "no_active_turn", children: [] }
2626
+
2627
+ const turnId = session.activeTurnId
2628
+ let attemptedRootTurnId = turnId
2629
+ let result
2630
+ try {
2631
+ await session.client.request("turn/interrupt", {
2632
+ threadId: session.threadId,
2633
+ turnId,
2634
+ })
2635
+ result = { outcome: "interrupted", turnId, corrected: false }
2636
+ } catch (err) {
2637
+ const reason = err?.message || String(err)
2638
+ const mismatch = parseTurnMismatch(reason)
2639
+ if (!mismatch) result = { outcome: "failed", reason }
2640
+
2641
+ if (mismatch) {
2642
+ const correctedTurnId = mismatch.found
2643
+ // 是正は再送前に行い、再送中の corrected turn の完了通知を正しく照合できるようにする。
2644
+ // 初回リクエスト中に状態が進んでいた場合は、その新しい状態を上書きしない。
2645
+ if (session.activeTurnId === turnId) {
2646
+ session.activeTurnId = correctedTurnId
2647
+ }
2648
+ try {
2649
+ attemptedRootTurnId = correctedTurnId
2650
+ await session.client.request("turn/interrupt", {
2651
+ threadId: session.threadId,
2652
+ turnId: correctedTurnId,
2653
+ })
2654
+ result = { outcome: "interrupted", turnId: correctedTurnId, corrected: true }
2655
+ } catch (retryErr) {
2656
+ const retryReason = retryErr?.message || String(retryErr)
2657
+ const retryMismatch = parseTurnMismatch(retryReason)
2658
+ if (retryMismatch && session.activeTurnId === correctedTurnId) {
2659
+ session.activeTurnId = retryMismatch.found
2660
+ }
2661
+ result = { outcome: "failed", reason: retryReason }
2662
+ }
2663
+ }
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 }
2475
2675
  }
2476
2676
 
2477
2677
  /** サーバー発の承認/入力要求への応答。result の shape は method ごとに呼び出し側が組み立てる。 */
package/src/main.mjs CHANGED
@@ -3194,6 +3194,30 @@ export function handleCodexChatInput(msg, ctx) {
3194
3194
  return p
3195
3195
  }
3196
3196
 
3197
+ export async function handleCodexInterrupt(msg, ctx) {
3198
+ let result
3199
+ if (!ctx.codexBridge) {
3200
+ result = { outcome: "failed", reason: "bridge_unavailable", children: [] }
3201
+ } else {
3202
+ try {
3203
+ result = await ctx.codexBridge.interrupt({ stream_id: msg.stream_id })
3204
+ if (result.outcome === "failed") {
3205
+ ctx.logger?.warn?.({ err: result.reason }, "codex.interrupt failed")
3206
+ }
3207
+ } catch (err) {
3208
+ const reason = err?.message || String(err)
3209
+ ctx.logger?.warn?.({ err: reason }, "codex.interrupt failed")
3210
+ result = { outcome: "failed", reason, children: [] }
3211
+ }
3212
+ }
3213
+ ctx.client.send({
3214
+ type: "codex.interrupt.response",
3215
+ stream_id: msg.stream_id,
3216
+ request_id: msg.request_id ?? null,
3217
+ ...result,
3218
+ })
3219
+ }
3220
+
3197
3221
  /**
3198
3222
  * tmux.kill_session の処理 (症状3 根治)。要求された session を kill し、要求元へ
3199
3223
  * tmux.kill_session.result を返したうえで、kill できた各 session について
@@ -4333,9 +4357,10 @@ async function dispatch(msg, ctx) {
4333
4357
  })
4334
4358
  return
4335
4359
  case "codex.interrupt":
4336
- if (!ctx.codexBridge) return
4337
- ctx.codexBridge.interrupt({ stream_id: msg.stream_id }).catch((err) => {
4338
- ctx.logger?.warn?.({ err: err.message }, "codex.interrupt failed")
4360
+ // fire-and-forget だが async なので、送信失敗を未処理 rejection にしない
4361
+ // (handleBundleSyncRequest と同じ作法)。停止経路が例外で無言死しないための保険。
4362
+ handleCodexInterrupt(msg, ctx).catch((err) => {
4363
+ ctx.logger?.warn?.({ err: err?.message || String(err) }, "codex.interrupt handler failed")
4339
4364
  })
4340
4365
  return
4341
4366
  case "codex.detach":