@cocorograph/hub-agent 0.8.32 → 0.8.33

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.32",
3
+ "version": "0.8.33",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -183,6 +183,10 @@ const TURN_START_TIMEOUT_MS = readPositiveIntEnv(
183
183
  // 直近 input_id を bounded ring で保持する (Claude queue_state.accepted と同じ契約)。
184
184
  const ACCEPTED_INPUT_ID_RING_MAX = 256
185
185
 
186
+ // cold resume で復元する子 thread/read の同時実行上限。履歴上の子は欠落させず全件読むが、
187
+ // 長期 thread で一斉 RPC を発行して app-server を圧迫しないよう固定 worker pool にする。
188
+ const SUBAGENT_READ_CONCURRENCY = 8
189
+
186
190
  // sibling-inject の turn RPC が成功した後、app-server が同じ userMessage を
187
191
  // item/completed で echo するまで待つ上限。RPC accepted と userMessage delivered を
188
192
  // 分離し、旧 stream / 初期化競合で rollout に残らない入力を成功扱いしない。
@@ -381,6 +385,40 @@ function userInputFromMessage(message) {
381
385
  return [{ type: "text", text: String(content ?? "") }]
382
386
  }
383
387
 
388
+ function subagentFallbackName(agentPath) {
389
+ if (typeof agentPath !== "string" || !agentPath) return "subagent"
390
+ return agentPath.split("/").filter(Boolean).at(-1) || "subagent"
391
+ }
392
+
393
+ function parseDynamicToolArguments(value) {
394
+ if (value && typeof value === "object" && !Array.isArray(value)) return value
395
+ if (typeof value !== "string") return null
396
+ try {
397
+ const parsed = JSON.parse(value)
398
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null
399
+ } catch {
400
+ return null
401
+ }
402
+ }
403
+
404
+ function terminalSubagentPhase(status) {
405
+ switch (status) {
406
+ case "completed":
407
+ return "completed"
408
+ case "failed":
409
+ return "failed"
410
+ case "interrupted":
411
+ case "stopped":
412
+ case "cancelled":
413
+ case "canceled":
414
+ return "stopped"
415
+ case "killed":
416
+ return "killed"
417
+ default:
418
+ return null
419
+ }
420
+ }
421
+
384
422
  export class CodexStreamBridge extends EventEmitter {
385
423
  constructor({
386
424
  codexCmd,
@@ -709,6 +747,11 @@ export class CodexStreamBridge extends EventEmitter {
709
747
  * deliveryId: string, sourceStreamId: string, deliveryPending: boolean,
710
748
  * deliveryPromise: Promise<object>, settleDelivery: Function}>} */
711
749
  pendingInjectSources: [],
750
+ /** 組み込み collaboration subagent の表示用台帳。ライフサイクルの安定キーは
751
+ * agentThreadId(item.id は dynamicToolCall とのメタデータ結合にだけ使う)。 */
752
+ subagents: new Map(),
753
+ /** dynamicToolCall(spawn_agent) の item.id -> 表示メタデータ。 */
754
+ subagentSpawnMetadata: new Map(),
712
755
  /** 実行中の背景コマンド(commandExecution)の台帳。claude 側 `_liveTasks` と同型。
713
756
  * key = `item.id`(`item/started` / `item/completed` の両方に載る唯一の安定 ID)、
714
757
  * value = { threadId, startedAt, lastSeenAt }。
@@ -807,6 +850,8 @@ export class CodexStreamBridge extends EventEmitter {
807
850
  session.historyItems = history.slice()
808
851
  session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
809
852
  session.historyTruncated = historyTruncated
853
+ if (resuming) await this._restoreSubagentsFromHistory(session, result?.thread)
854
+ if (resuming) this._scheduleSubagentReplay(session)
810
855
  // 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
811
856
  // これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
812
857
  // 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
@@ -1033,6 +1078,7 @@ export class CodexStreamBridge extends EventEmitter {
1033
1078
  // 出したカードは remount/端末切替で失われ、応答が来ないとターンが永久 pending になる。新 stream へ
1034
1079
  // 再 emit してカードを復元させる (frontend は request_id で重複排除するので二重化しない)。
1035
1080
  this._replayPendingApprovals(session)
1081
+ this._scheduleSubagentReplay(session)
1036
1082
  // 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
1037
1083
  // 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
1038
1084
  return {
@@ -1524,6 +1570,228 @@ export class CodexStreamBridge extends EventEmitter {
1524
1570
  }
1525
1571
  }
1526
1572
 
1573
+ _emitSubagentState(session, record, { force = false } = {}) {
1574
+ if (!session || !record || session.dead) return
1575
+ if (!force && record.lastEmittedPhase === record.phase) return
1576
+ record.lastEmittedPhase = record.phase
1577
+ this.emit("event", {
1578
+ stream_id: session.stream_id,
1579
+ producer_seq: session.seq,
1580
+ thread_id: session.threadId,
1581
+ cwd: session.cwd,
1582
+ notification: {
1583
+ method: "cockpit/subagent_state",
1584
+ params: {
1585
+ delegationId: record.agentThreadId,
1586
+ agentThreadId: record.agentThreadId,
1587
+ agentPath: record.agentPath,
1588
+ phase: record.phase,
1589
+ agent: record.agent,
1590
+ description: record.description,
1591
+ prompt: record.prompt,
1592
+ atMs: record.atMs,
1593
+ },
1594
+ },
1595
+ })
1596
+ }
1597
+
1598
+ _setSubagentPhase(
1599
+ session,
1600
+ agentThreadId,
1601
+ phase,
1602
+ details = {},
1603
+ { force = false, emit = true } = {},
1604
+ ) {
1605
+ if (typeof agentThreadId !== "string" || !agentThreadId) return null
1606
+ const previous = session.subagents.get(agentThreadId)
1607
+ const agentPath =
1608
+ typeof details.agentPath === "string" && details.agentPath
1609
+ ? details.agentPath
1610
+ : previous?.agentPath || ""
1611
+ const metadata = details.metadata || null
1612
+ const transitionAtMs =
1613
+ previous?.phase === phase
1614
+ ? previous.atMs
1615
+ : typeof details.atMs === "number"
1616
+ ? details.atMs
1617
+ : Date.now()
1618
+ const record = {
1619
+ agentThreadId,
1620
+ agentPath,
1621
+ phase,
1622
+ agent: metadata?.agent || previous?.agent || subagentFallbackName(agentPath),
1623
+ description:
1624
+ metadata?.description || previous?.description || subagentFallbackName(agentPath),
1625
+ prompt: metadata?.prompt || previous?.prompt || "",
1626
+ atMs: transitionAtMs,
1627
+ startedAtMs:
1628
+ phase === "started" && terminalSubagentPhase(previous?.phase)
1629
+ ? transitionAtMs
1630
+ : previous?.startedAtMs ?? transitionAtMs,
1631
+ lastEmittedPhase: previous?.lastEmittedPhase || null,
1632
+ }
1633
+ session.subagents.set(agentThreadId, record)
1634
+ if (emit) this._emitSubagentState(session, record, { force })
1635
+ return record
1636
+ }
1637
+
1638
+ _observeSubagentItem(session, item, atMs = Date.now(), { emit = true } = {}) {
1639
+ if (!item || typeof item !== "object") return
1640
+ if (item.type === "dynamicToolCall") {
1641
+ const namespace = item.namespace
1642
+ if (namespace !== "collaboration" || item.tool !== "spawn_agent" || !item.id) return
1643
+ const args = parseDynamicToolArguments(item.arguments)
1644
+ if (!args) return
1645
+ const metadata = {
1646
+ agent: typeof args.agent_type === "string" && args.agent_type ? args.agent_type : null,
1647
+ description: typeof args.task_name === "string" && args.task_name ? args.task_name : null,
1648
+ prompt: typeof args.message === "string" ? args.message : null,
1649
+ }
1650
+ session.subagentSpawnMetadata.set(item.id, metadata)
1651
+ while (session.subagentSpawnMetadata.size > 256) {
1652
+ session.subagentSpawnMetadata.delete(session.subagentSpawnMetadata.keys().next().value)
1653
+ }
1654
+ return
1655
+ }
1656
+ if (item.type !== "subAgentActivity") return
1657
+ const agentThreadId = item.agentThreadId
1658
+ if (typeof agentThreadId !== "string" || !agentThreadId) return
1659
+ const previous = session.subagents.get(agentThreadId)
1660
+ let phase = null
1661
+ if (item.kind === "interrupted") phase = "stopped"
1662
+ if ((item.kind === "started" || item.kind === "interacted") && !terminalSubagentPhase(previous?.phase)) {
1663
+ phase = "started"
1664
+ }
1665
+ if (!phase) return
1666
+ const metadata = session.subagentSpawnMetadata.get(item.id) || null
1667
+ this._setSubagentPhase(
1668
+ session,
1669
+ agentThreadId,
1670
+ phase,
1671
+ { agentPath: item.agentPath, metadata, atMs },
1672
+ { emit },
1673
+ )
1674
+ }
1675
+
1676
+ _observeSubagentTurn(session, msg) {
1677
+ const threadId = msg?.params?.threadId
1678
+ if (typeof threadId !== "string" || threadId === session.threadId) return false
1679
+ const record = session.subagents.get(threadId)
1680
+ if (!record) return true
1681
+ const turn = msg.params?.turn
1682
+ const atMs =
1683
+ typeof msg.params?.completedAtMs === "number"
1684
+ ? msg.params.completedAtMs
1685
+ : typeof msg.params?.startedAtMs === "number"
1686
+ ? msg.params.startedAtMs
1687
+ : typeof turn?.completedAt === "number"
1688
+ ? Math.round(turn.completedAt * 1000)
1689
+ : typeof turn?.startedAt === "number"
1690
+ ? Math.round(turn.startedAt * 1000)
1691
+ : Date.now()
1692
+ if (msg.method === "turn/started") {
1693
+ this._setSubagentPhase(session, threadId, "started", { agentPath: record.agentPath, atMs })
1694
+ } else if (msg.method === "turn/completed") {
1695
+ const phase = terminalSubagentPhase(msg.params?.turn?.status)
1696
+ if (phase) {
1697
+ this._setSubagentPhase(session, threadId, phase, { agentPath: record.agentPath, atMs })
1698
+ }
1699
+ }
1700
+ return true
1701
+ }
1702
+
1703
+ _replaySubagentStates(session) {
1704
+ for (const record of session?.subagents?.values?.() || []) {
1705
+ if (terminalSubagentPhase(record.phase)) {
1706
+ this._emitSubagentState(
1707
+ session,
1708
+ { ...record, phase: "started", atMs: record.startedAtMs, lastEmittedPhase: null },
1709
+ { force: true },
1710
+ )
1711
+ }
1712
+ this._emitSubagentState(session, record, { force: true })
1713
+ }
1714
+ }
1715
+
1716
+ _scheduleSubagentReplay(session) {
1717
+ // main.mjs は attach() resolve 後に codex.ready(history hydrate/reset)を送る。同期 emit すると
1718
+ // state card が ready より先に届き、直後の hydrate で消えるため、必ず次 event-loop tick へ送る。
1719
+ setImmediate(() => {
1720
+ if (!session?.dead) this._replaySubagentStates(session)
1721
+ })
1722
+ }
1723
+
1724
+ async _restoreSubagentsFromHistory(session, thread) {
1725
+ // 表示用 history は巨大スレッド対策で末尾だけに truncate されるため、復元元には使わない。
1726
+ // root の全 turn を逐次走査し、subAgentActivity の安定 ID を取りこぼさない。
1727
+ for (const turn of Array.isArray(thread?.turns) ? thread.turns : []) {
1728
+ const atMs =
1729
+ typeof turn?.completedAt === "number"
1730
+ ? Math.round(turn.completedAt * 1000)
1731
+ : typeof turn?.startedAt === "number"
1732
+ ? Math.round(turn.startedAt * 1000)
1733
+ : Date.now()
1734
+ for (const item of Array.isArray(turn?.items) ? turn.items : []) {
1735
+ this._observeSubagentItem(session, item, atMs, { emit: false })
1736
+ }
1737
+ }
1738
+ const records = [...session.subagents.values()]
1739
+ let nextRecord = 0
1740
+ const readWorker = async () => {
1741
+ while (nextRecord < records.length) {
1742
+ const record = records[nextRecord++]
1743
+ try {
1744
+ const result = await session.client.request("thread/read", {
1745
+ threadId: record.agentThreadId,
1746
+ includeTurns: true,
1747
+ })
1748
+ const thread = result?.thread
1749
+ const turns = Array.isArray(thread?.turns) ? thread.turns : []
1750
+ const lastTurn = turns.at(-1)
1751
+ const phase = terminalSubagentPhase(lastTurn?.status)
1752
+ const lastTurnAtMs =
1753
+ typeof lastTurn?.completedAt === "number"
1754
+ ? Math.round(lastTurn.completedAt * 1000)
1755
+ : typeof lastTurn?.startedAt === "number"
1756
+ ? Math.round(lastTurn.startedAt * 1000)
1757
+ : Date.now()
1758
+ if (phase) {
1759
+ this._setSubagentPhase(
1760
+ session,
1761
+ record.agentThreadId,
1762
+ phase,
1763
+ { agentPath: record.agentPath, atMs: lastTurnAtMs },
1764
+ { emit: false },
1765
+ )
1766
+ } else if (thread?.status?.type === "active" && lastTurn?.status === "inProgress") {
1767
+ this._setSubagentPhase(
1768
+ session,
1769
+ record.agentThreadId,
1770
+ "started",
1771
+ { agentPath: record.agentPath, atMs: lastTurnAtMs },
1772
+ { emit: false },
1773
+ )
1774
+ }
1775
+ } catch (err) {
1776
+ this.logger?.warn?.(
1777
+ {
1778
+ err: err?.message || String(err),
1779
+ thread_id: session.threadId,
1780
+ subagent_thread_id: record.agentThreadId,
1781
+ },
1782
+ "codex subagent thread/read failed during resume",
1783
+ )
1784
+ }
1785
+ }
1786
+ }
1787
+ await Promise.all(
1788
+ Array.from(
1789
+ { length: Math.min(SUBAGENT_READ_CONCURRENCY, records.length) },
1790
+ () => readWorker(),
1791
+ ),
1792
+ )
1793
+ }
1794
+
1527
1795
  /** 実行中の背景コマンドを持つか(warm 境界の保護判定用)。**先に失効エントリを掃除してから**
1528
1796
  * 判定する。失効は「最後に生存を確認してから CODEX_EXEC_PROTECT_TTL_MS」。
1529
1797
  * ⚠️ 失効させる = そのセッションは kill されうる、という意味なので必ず warn を残す
@@ -1575,8 +1843,19 @@ export class CodexStreamBridge extends EventEmitter {
1575
1843
  // jsonl at-rest と同じ許容範囲。source 永続マップは TTL/ID 再利用の複雑さに見合わないため不採用)。
1576
1844
  const taggedParams = this._tagInjectSourceOnItem(session, msg)
1577
1845
  const effectiveParams = taggedParams || msg.params
1846
+ if (msg.method === "item/started" || msg.method === "item/completed") {
1847
+ this._observeSubagentItem(
1848
+ session,
1849
+ effectiveParams?.item,
1850
+ effectiveParams?.completedAtMs || Date.now(),
1851
+ )
1852
+ }
1853
+ const childTurnNotification =
1854
+ (msg.method === "turn/started" || msg.method === "turn/completed") &&
1855
+ this._observeSubagentTurn(session, msg)
1578
1856
  switch (msg.method) {
1579
1857
  case "turn/started":
1858
+ if (childTurnNotification) break
1580
1859
  session.activeTurnId = msg.params?.turn?.id || null
1581
1860
  // round4 major#2: 世代を前進させる (pump が fast-turn を activeTurnId だけでなく世代前進でも検知)。
1582
1861
  session.turnGeneration += 1
@@ -1595,6 +1874,7 @@ export class CodexStreamBridge extends EventEmitter {
1595
1874
  )
1596
1875
  break
1597
1876
  case "turn/completed":
1877
+ if (childTurnNotification) break
1598
1878
  {
1599
1879
  const completedTurnId = msg.params?.turn?.id || null
1600
1880
  // App Server の対象 turn と現在の権威 activeTurnId が一致したときだけ idle 化する。
@@ -1644,6 +1924,9 @@ export class CodexStreamBridge extends EventEmitter {
1644
1924
  default:
1645
1925
  break
1646
1926
  }
1927
+ // 子 turn lifecycle は cockpit/subagent_state へ正規化済み。raw turn 通知まで frontend へ流すと
1928
+ // root の turnActive/heal_turn と chat activity を子の状態で誤更新するため、ここで止める。
1929
+ if (childTurnNotification) return
1647
1930
  this.emit("event", {
1648
1931
  stream_id,
1649
1932
  // producer_seq: この通知を出した **session(producer) の immutable ID**(生成時に振る