@cocorograph/hub-agent 0.8.41 → 0.8.43

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.43",
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({
@@ -3791,11 +3791,11 @@ async function dispatch(msg, ctx) {
3791
3791
  })
3792
3792
  if (resumeSessionId) {
3793
3793
  void ctx.siblingInjectBridge
3794
- ?.replayActiveDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
3794
+ ?.replayDelegations(cwdArg || process.env.HOME || process.cwd(), resumeSessionId)
3795
3795
  .catch((err) => {
3796
3796
  ctx.logger?.warn?.(
3797
3797
  { err: err?.message || String(err), stream_id, session_id: resumeSessionId },
3798
- "failed to replay active delegations after Claude attach",
3798
+ "failed to replay delegations after Claude attach",
3799
3799
  )
3800
3800
  })
3801
3801
  }
@@ -75,20 +75,68 @@ export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
75
75
  export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
76
76
  const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
77
77
  const DELEGATE_PROMPT_MAX_LENGTH = 64_000
78
+ const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
79
+ const TERMINAL_DELEGATE_AGENT_MAX_LENGTH = 200
80
+ const TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH = 2_000
81
+ const DELEGATION_ID_MAX_LENGTH = 200
78
82
 
79
83
  const execFile = promisify(execFileCallback)
80
84
 
81
- function truncateDelegatePrompt(prompt) {
82
- if (prompt.length <= DELEGATE_PROMPT_MAX_LENGTH) return prompt
83
- let end = DELEGATE_PROMPT_MAX_LENGTH
84
- const lastCodeUnit = prompt.charCodeAt(end - 1)
85
+ function truncateString(value, maxLength, truncationNote) {
86
+ if (value.length <= maxLength) return value
87
+ let end = maxLength
88
+ const lastCodeUnit = value.charCodeAt(end - 1)
85
89
  if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1
86
- return (
87
- prompt.slice(0, end) +
88
- `\n\n[依頼本文はここで打ち切られています(全 ${prompt.length} 文字)]`
90
+ return value.slice(0, end) + truncationNote(value.length)
91
+ }
92
+
93
+ function truncateDelegatePrompt(prompt) {
94
+ return truncateString(
95
+ prompt,
96
+ DELEGATE_PROMPT_MAX_LENGTH,
97
+ (length) => `\n\n[依頼本文はここで打ち切られています(全 ${length} 文字)]`,
89
98
  )
90
99
  }
91
100
 
101
+ function compactTerminalEvent(event, includePrompt = false) {
102
+ const storedEvent = {}
103
+ for (const field of [
104
+ "type",
105
+ "subtype",
106
+ "delegation_id",
107
+ "phase",
108
+ "background",
109
+ "timestamp",
110
+ ]) {
111
+ if (event[field] !== undefined) storedEvent[field] = event[field]
112
+ }
113
+
114
+ if (typeof event.agent === "string") {
115
+ storedEvent.agent = truncateString(
116
+ event.agent,
117
+ TERMINAL_DELEGATE_AGENT_MAX_LENGTH,
118
+ (length) => `\n\n[エージェント名はここで打ち切られています(全 ${length} 文字)]`,
119
+ )
120
+ }
121
+ if (typeof event.description === "string") {
122
+ storedEvent.description = truncateString(
123
+ event.description,
124
+ TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH,
125
+ (length) => `\n\n[説明はここで打ち切られています(全 ${length} 文字)]`,
126
+ )
127
+ }
128
+ if (includePrompt && typeof event.prompt === "string") {
129
+ storedEvent.prompt = truncateString(
130
+ event.prompt,
131
+ TERMINAL_DELEGATE_PROMPT_MAX_LENGTH,
132
+ (length) =>
133
+ `\n\n[終了済み委譲の依頼本文はここで打ち切られています(全 ${length} 文字)]`,
134
+ )
135
+ }
136
+
137
+ return storedEvent
138
+ }
139
+
92
140
  /**
93
141
  * `ps` の起動時刻を送受信双方で比較する契約形へ正規化する。
94
142
  * 先頭・末尾の Unicode 空白だけを除去し、日時本体の内部空白は変更しない。
@@ -312,7 +360,7 @@ export class SiblingInjectBridge {
312
360
  this._pidStartedAt = pidStartedAt
313
361
  /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
314
362
  this._activeDelegations = new Map()
315
- /** @type {Map<string, Map<string, number>>} 終端→開始の逆転で遅着 started を復活させない墓標。 */
363
+ /** @type {Map<string, Map<string, {startedEvent?: object, terminalEvent: object, startedAt?: number, recordedAt: number}>>} 終了済み委譲の再送情報を兼ねた墓標。 */
316
364
  this._terminalDelegations = new Map()
317
365
  }
318
366
 
@@ -329,8 +377,8 @@ export class SiblingInjectBridge {
329
377
  if (entries.size === 0) this._activeDelegations.delete(sessionKey)
330
378
  }
331
379
  for (const [sessionKey, entries] of this._terminalDelegations) {
332
- for (const [delegationId, recordedAt] of entries) {
333
- if (recordedAt < cutoff) entries.delete(delegationId)
380
+ for (const [delegationId, entry] of entries) {
381
+ if (entry.recordedAt < cutoff) entries.delete(delegationId)
334
382
  }
335
383
  if (entries.size === 0) this._terminalDelegations.delete(sessionKey)
336
384
  }
@@ -340,6 +388,10 @@ export class SiblingInjectBridge {
340
388
  this._pruneDelegations()
341
389
  const sessionKey = this._delegationSessionKey(cwd, sessionId)
342
390
  if (event.phase !== "started") {
391
+ const activeEntry = this._activeDelegations.get(sessionKey)?.get(event.delegation_id)
392
+ const existingTerminalEntry = this._terminalDelegations
393
+ .get(sessionKey)
394
+ ?.get(event.delegation_id)
343
395
  this._activeDelegations.get(sessionKey)?.delete(event.delegation_id)
344
396
  if (this._activeDelegations.get(sessionKey)?.size === 0) {
345
397
  this._activeDelegations.delete(sessionKey)
@@ -350,7 +402,21 @@ export class SiblingInjectBridge {
350
402
  this._terminalDelegations.set(sessionKey, terminalEntries)
351
403
  }
352
404
  terminalEntries.delete(event.delegation_id)
353
- terminalEntries.set(event.delegation_id, this._now())
405
+ terminalEntries.set(event.delegation_id, {
406
+ ...(activeEntry
407
+ ? {
408
+ startedEvent: compactTerminalEvent(activeEntry.event, true),
409
+ startedAt: activeEntry.recordedAt,
410
+ }
411
+ : existingTerminalEntry?.startedEvent && existingTerminalEntry.startedAt !== undefined
412
+ ? {
413
+ startedEvent: existingTerminalEntry.startedEvent,
414
+ startedAt: existingTerminalEntry.startedAt,
415
+ }
416
+ : {}),
417
+ terminalEvent: compactTerminalEvent(event),
418
+ recordedAt: this._now(),
419
+ })
354
420
  while (terminalEntries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
355
421
  terminalEntries.delete(terminalEntries.keys().next().value)
356
422
  }
@@ -394,14 +460,35 @@ export class SiblingInjectBridge {
394
460
  }
395
461
  }
396
462
 
397
- /** attach/再接続時に同じ cwd+session の進行中委譲だけを再送する。 */
398
- async replayActiveDelegations(cwd, sessionId) {
463
+ /** attach/再接続時に同じ cwd+session の進行中・終了済み委譲を開始順で再送する。 */
464
+ async replayDelegations(cwd, sessionId) {
399
465
  if (!cwd || !sessionId) return
400
466
  this._pruneDelegations()
401
467
  const sessionKey = this._delegationSessionKey(cwd, sessionId)
402
- const entries = this._activeDelegations.get(sessionKey)
403
- if (!entries) return
404
- for (const [delegationId, entry] of [...entries]) {
468
+ const replayEntries = [
469
+ ...[...(this._activeDelegations.get(sessionKey) || [])].map(([delegationId, entry]) => ({
470
+ delegationId,
471
+ entry,
472
+ kind: "active",
473
+ startedAt: entry.recordedAt,
474
+ })),
475
+ ...[...(this._terminalDelegations.get(sessionKey) || [])]
476
+ .filter(([, entry]) => entry.startedEvent && entry.startedAt !== undefined)
477
+ .map(([delegationId, entry]) => ({
478
+ delegationId,
479
+ entry,
480
+ kind: "terminal",
481
+ startedAt: entry.startedAt,
482
+ })),
483
+ ].sort((a, b) => a.startedAt - b.startedAt)
484
+
485
+ for (const { delegationId, entry, kind } of replayEntries) {
486
+ if (kind === "terminal") {
487
+ if (this._terminalDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
488
+ this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.startedEvent)
489
+ this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.terminalEvent)
490
+ continue
491
+ }
405
492
  const alive = await this._delegateProcessAlive(entry.event)
406
493
  // 生存確認中に終端が届いた場合、古い snapshot の started/stopped を後から流さない。
407
494
  if (this._activeDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
@@ -571,6 +658,7 @@ export class SiblingInjectBridge {
571
658
  phase,
572
659
  agent,
573
660
  description,
661
+ background,
574
662
  prompt,
575
663
  timestamp,
576
664
  pid,
@@ -590,10 +678,12 @@ export class SiblingInjectBridge {
590
678
  const badDelegateBody =
591
679
  typeof delegation_id !== "string" ||
592
680
  !delegation_id ||
681
+ delegation_id.length > DELEGATION_ID_MAX_LENGTH ||
593
682
  !delegatePhases.has(phase) ||
594
683
  (phase === "started" && (typeof agent !== "string" || !agent)) ||
595
684
  (agent !== undefined && typeof agent !== "string") ||
596
685
  (description !== undefined && typeof description !== "string") ||
686
+ (background !== undefined && typeof background !== "boolean") ||
597
687
  (prompt !== undefined && typeof prompt !== "string") ||
598
688
  (pid !== undefined && (!Number.isInteger(pid) || pid <= 0)) ||
599
689
  (pid_started_at !== undefined && typeof pid_started_at !== "string") ||
@@ -656,6 +746,7 @@ export class SiblingInjectBridge {
656
746
  phase,
657
747
  ...(agent !== undefined ? { agent } : {}),
658
748
  ...(description !== undefined ? { description } : {}),
749
+ ...(background !== undefined ? { background } : {}),
659
750
  ...(phase === "started" && prompt ? { prompt: truncateDelegatePrompt(prompt) } : {}),
660
751
  ...(pid !== undefined ? { pid } : {}),
661
752
  ...(pid_started_at !== undefined ? { pid_started_at } : {}),