@cocorograph/hub-agent 0.8.33 → 0.8.34

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.33",
3
+ "version": "0.8.34",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -621,6 +621,7 @@ export class CodexStreamBridge extends EventEmitter {
621
621
  // 由来で表示がリセットされ得るため、live セッションが保持する高忠実度履歴を返す。
622
622
  history,
623
623
  historyTruncated: existing.historyTruncated === true,
624
+ subagents: this._subagentSnapshots(existing, history),
624
625
  }
625
626
  }
626
627
  if (r.status === "superseded") {
@@ -850,8 +851,7 @@ export class CodexStreamBridge extends EventEmitter {
850
851
  session.historyItems = history.slice()
851
852
  session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
852
853
  session.historyTruncated = historyTruncated
853
- if (resuming) await this._restoreSubagentsFromHistory(session, result?.thread)
854
- if (resuming) this._scheduleSubagentReplay(session)
854
+ if (resuming) await this._restoreSubagentsFromHistory(session, history)
855
855
  // 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
856
856
  // これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
857
857
  // 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
@@ -908,6 +908,7 @@ export class CodexStreamBridge extends EventEmitter {
908
908
  threadId: session.threadId,
909
909
  history,
910
910
  historyTruncated,
911
+ subagents: this._subagentSnapshots(session, history),
911
912
  threadStatus: turnState.threadStatus,
912
913
  lastTurnStatus: turnState.lastTurnStatus,
913
914
  turnActive: turnState.turnActive,
@@ -1078,7 +1079,6 @@ export class CodexStreamBridge extends EventEmitter {
1078
1079
  // 出したカードは remount/端末切替で失われ、応答が来ないとターンが永久 pending になる。新 stream へ
1079
1080
  // 再 emit してカードを復元させる (frontend は request_id で重複排除するので二重化しない)。
1080
1081
  this._replayPendingApprovals(session)
1081
- this._scheduleSubagentReplay(session)
1082
1082
  // 履歴は返さない: reconnect では frontend が表示を保持している (resetDisplay=false) ため
1083
1083
  // 再 hydrate 不要。turnActive は session が保持する activeTurnId から権威的に決める。
1084
1084
  return {
@@ -1623,11 +1623,14 @@ export class CodexStreamBridge extends EventEmitter {
1623
1623
  description:
1624
1624
  metadata?.description || previous?.description || subagentFallbackName(agentPath),
1625
1625
  prompt: metadata?.prompt || previous?.prompt || "",
1626
+ // background は spawn 時に確定する属性。後続通知に metadata が無くても true を失わない。
1627
+ background: metadata?.background === true || previous?.background === true,
1626
1628
  atMs: transitionAtMs,
1627
1629
  startedAtMs:
1628
1630
  phase === "started" && terminalSubagentPhase(previous?.phase)
1629
1631
  ? transitionAtMs
1630
1632
  : previous?.startedAtMs ?? transitionAtMs,
1633
+ terminalAtMs: terminalSubagentPhase(phase) ? transitionAtMs : null,
1631
1634
  lastEmittedPhase: previous?.lastEmittedPhase || null,
1632
1635
  }
1633
1636
  session.subagents.set(agentThreadId, record)
@@ -1646,6 +1649,7 @@ export class CodexStreamBridge extends EventEmitter {
1646
1649
  agent: typeof args.agent_type === "string" && args.agent_type ? args.agent_type : null,
1647
1650
  description: typeof args.task_name === "string" && args.task_name ? args.task_name : null,
1648
1651
  prompt: typeof args.message === "string" ? args.message : null,
1652
+ background: args.background === true,
1649
1653
  }
1650
1654
  session.subagentSpawnMetadata.set(item.id, metadata)
1651
1655
  while (session.subagentSpawnMetadata.size > 256) {
@@ -1656,6 +1660,7 @@ export class CodexStreamBridge extends EventEmitter {
1656
1660
  if (item.type !== "subAgentActivity") return
1657
1661
  const agentThreadId = item.agentThreadId
1658
1662
  if (typeof agentThreadId !== "string" || !agentThreadId) return
1663
+ if (agentThreadId === session.threadId || item.agentPath === "/root") return
1659
1664
  const previous = session.subagents.get(agentThreadId)
1660
1665
  let phase = null
1661
1666
  if (item.kind === "interrupted") phase = "stopped"
@@ -1668,7 +1673,11 @@ export class CodexStreamBridge extends EventEmitter {
1668
1673
  session,
1669
1674
  agentThreadId,
1670
1675
  phase,
1671
- { agentPath: item.agentPath, metadata, atMs },
1676
+ {
1677
+ agentPath: item.agentPath,
1678
+ metadata,
1679
+ atMs,
1680
+ },
1672
1681
  { emit },
1673
1682
  )
1674
1683
  }
@@ -1690,49 +1699,65 @@ export class CodexStreamBridge extends EventEmitter {
1690
1699
  ? Math.round(turn.startedAt * 1000)
1691
1700
  : Date.now()
1692
1701
  if (msg.method === "turn/started") {
1693
- this._setSubagentPhase(session, threadId, "started", { agentPath: record.agentPath, atMs })
1702
+ this._setSubagentPhase(session, threadId, "started", {
1703
+ agentPath: record.agentPath,
1704
+ atMs,
1705
+ })
1694
1706
  } else if (msg.method === "turn/completed") {
1695
1707
  const phase = terminalSubagentPhase(msg.params?.turn?.status)
1696
1708
  if (phase) {
1697
- this._setSubagentPhase(session, threadId, phase, { agentPath: record.agentPath, atMs })
1709
+ this._setSubagentPhase(session, threadId, phase, {
1710
+ agentPath: record.agentPath,
1711
+ atMs,
1712
+ })
1698
1713
  }
1699
1714
  }
1700
1715
  return true
1701
1716
  }
1702
1717
 
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
- )
1718
+ _subagentSnapshots(session, history = session?.historyItems) {
1719
+ const items = Array.isArray(history) ? history : []
1720
+ const startedIndexByThread = new Map()
1721
+ for (let index = 0; index < items.length; index += 1) {
1722
+ const item = items[index]
1723
+ if (
1724
+ item?.type === "subAgentActivity" &&
1725
+ item.kind === "started" &&
1726
+ typeof item.agentThreadId === "string" &&
1727
+ item.agentPath !== "/root" &&
1728
+ item.agentThreadId !== session?.threadId &&
1729
+ !startedIndexByThread.has(item.agentThreadId)
1730
+ ) {
1731
+ startedIndexByThread.set(item.agentThreadId, index)
1711
1732
  }
1712
- this._emitSubagentState(session, record, { force: true })
1713
1733
  }
1734
+ const snapshots = []
1735
+ for (const record of session?.subagents?.values?.() || []) {
1736
+ const startedHistoryIndex = startedIndexByThread.get(record.agentThreadId)
1737
+ if (startedHistoryIndex === undefined) continue
1738
+ snapshots.push({
1739
+ delegationId: record.agentThreadId,
1740
+ agentThreadId: record.agentThreadId,
1741
+ agentPath: record.agentPath,
1742
+ agent: record.agent,
1743
+ description: record.description,
1744
+ prompt: record.prompt,
1745
+ background: record.background === true,
1746
+ startedHistoryIndex,
1747
+ phase: record.phase,
1748
+ startedAtMs: record.startedAtMs,
1749
+ terminalAtMs: record.terminalAtMs,
1750
+ })
1751
+ }
1752
+ return snapshots
1714
1753
  }
1715
1754
 
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 })
1755
+ async _restoreSubagentsFromHistory(session, history) {
1756
+ // ready に載せる表示用 history と同じ範囲だけを復元元にする。truncate 外の開始
1757
+ // anchor を拾うと、対応する履歴が無い lifecycle card が末尾へ浮くため全履歴は走査しない。
1758
+ for (const item of Array.isArray(history) ? history : []) {
1759
+ if (item?.type === "dynamicToolCall" || item?.kind === "started") {
1760
+ this._observeSubagentItem(session, item, item?.at_ms, { emit: false })
1736
1761
  }
1737
1762
  }
1738
1763
  const records = [...session.subagents.values()]
@@ -1745,6 +1770,8 @@ export class CodexStreamBridge extends EventEmitter {
1745
1770
  threadId: record.agentThreadId,
1746
1771
  includeTurns: true,
1747
1772
  })
1773
+ // RPC 待ち中に live lifecycle が進んだ場合、stale read で上書き・削除しない。
1774
+ if (session.subagents.get(record.agentThreadId) !== record) continue
1748
1775
  const thread = result?.thread
1749
1776
  const turns = Array.isArray(thread?.turns) ? thread.turns : []
1750
1777
  const lastTurn = turns.at(-1)
@@ -1763,23 +1790,34 @@ export class CodexStreamBridge extends EventEmitter {
1763
1790
  { agentPath: record.agentPath, atMs: lastTurnAtMs },
1764
1791
  { emit: false },
1765
1792
  )
1766
- } else if (thread?.status?.type === "active" && lastTurn?.status === "inProgress") {
1793
+ } else if (
1794
+ thread?.status?.type === "active" &&
1795
+ (!lastTurn || lastTurn.status === "inProgress")
1796
+ ) {
1767
1797
  this._setSubagentPhase(
1768
1798
  session,
1769
1799
  record.agentThreadId,
1770
1800
  "started",
1771
- { agentPath: record.agentPath, atMs: lastTurnAtMs },
1801
+ {
1802
+ agentPath: record.agentPath,
1803
+ atMs: record.startedAtMs,
1804
+ },
1772
1805
  { emit: false },
1773
1806
  )
1807
+ } else {
1808
+ session.subagents.delete(record.agentThreadId)
1774
1809
  }
1775
1810
  } catch (err) {
1811
+ if (session.subagents.get(record.agentThreadId) === record) {
1812
+ session.subagents.delete(record.agentThreadId)
1813
+ }
1776
1814
  this.logger?.warn?.(
1777
1815
  {
1778
1816
  err: err?.message || String(err),
1779
1817
  thread_id: session.threadId,
1780
1818
  subagent_thread_id: record.agentThreadId,
1781
1819
  },
1782
- "codex subagent thread/read failed during resume",
1820
+ "codex subagent thread/read failed during resume; excluded from ready snapshot",
1783
1821
  )
1784
1822
  }
1785
1823
  }
@@ -1841,6 +1879,19 @@ export class CodexStreamBridge extends EventEmitter {
1841
1879
  // (タグ済み or 素) を history と emit の両方で使う。一致しなければ null → 素の params のまま。
1842
1880
  // ※ hub-agent 再起動 (cold resume) では historyItems が揮発するため青バブルへ戻る (claude の
1843
1881
  // jsonl at-rest と同じ許容範囲。source 永続マップは TTL/ID 再利用の複雑さに見合わないため不採用)。
1882
+ const eventThreadId = msg?.params?.threadId
1883
+ const backgroundChildItemNotification =
1884
+ typeof eventThreadId === "string" &&
1885
+ typeof msg?.method === "string" &&
1886
+ msg.method.startsWith("item/") &&
1887
+ session.subagents.get(eventThreadId)?.background === true
1888
+ if (backgroundChildItemNotification) {
1889
+ // 背景子の item は root の履歴・バブルへ混ぜない。子自身の lifecycle は
1890
+ // turn 通知と root 側 subAgentActivity が正本なので、子 item から孫を登録しない。
1891
+ // foreground / background 不明(spawn record 生成前の短い窓を含む)は fail-open で
1892
+ // 既存どおり root へ通す。背景出力の過剰抑止より既存表示の維持を優先する。
1893
+ return
1894
+ }
1844
1895
  const taggedParams = this._tagInjectSourceOnItem(session, msg)
1845
1896
  const effectiveParams = taggedParams || msg.params
1846
1897
  if (msg.method === "item/started" || msg.method === "item/completed") {
package/src/main.mjs CHANGED
@@ -2052,6 +2052,43 @@ export function buildCodexChatPatch(status, contextPct, threadId, isAuthority) {
2052
2052
  return patch
2053
2053
  }
2054
2054
 
2055
+ /** codex.ready の wire payload を組み立てる。history は同じ配列参照を保持し、
2056
+ * started_history_index が指す位置を main 側の加工でずらさない。表示を保持する reconnect
2057
+ * (`info.subagents` 未定義) では subagents キー自体を省略し、空 snapshot で既存表示を消さない。 */
2058
+ export function buildCodexReadyPayload(streamId, info, queueState) {
2059
+ const history = Array.isArray(info?.history) ? info.history : []
2060
+ const payload = {
2061
+ type: "codex.ready",
2062
+ stream_id: streamId,
2063
+ resuming: info?.resuming === true,
2064
+ thread_id: info?.threadId || null,
2065
+ history,
2066
+ history_truncated: info?.historyTruncated === true,
2067
+ turn_active: info?.turnActive === true,
2068
+ thread_status: info?.threadStatus || null,
2069
+ last_turn_status: info?.lastTurnStatus || null,
2070
+ queue_state: queueState,
2071
+ }
2072
+ if (Array.isArray(info?.subagents)) {
2073
+ payload.subagents = info.subagents.map((subagent) => ({
2074
+ // 現契約では delegation_id と agent_thread_id は同じ child thread ID の alias。
2075
+ delegation_id: subagent.delegationId,
2076
+ agent_thread_id: subagent.agentThreadId,
2077
+ agent_path: subagent.agentPath,
2078
+ agent: subagent.agent,
2079
+ // description は spawn_agent.task_name の既存 alias(人物の表示名ではない)。
2080
+ description: subagent.description,
2081
+ prompt: subagent.prompt,
2082
+ background: subagent.background === true,
2083
+ started_history_index: subagent.startedHistoryIndex,
2084
+ phase: subagent.phase,
2085
+ started_at_ms: subagent.startedAtMs,
2086
+ terminal_at_ms: subagent.terminalAtMs,
2087
+ }))
2088
+ }
2089
+ return payload
2090
+ }
2091
+
2055
2092
  /**
2056
2093
  * T06407 増分2 (codex-review round2 major#2): usage.response の codexSessions を、集計に
2057
2094
  * 使った producer が **今なお cwd の権威**である entry だけへ絞る post-await ガード。
@@ -4131,30 +4168,9 @@ async function dispatch(msg, ctx) {
4131
4168
  // すると bridge が rebind し、fresh の二重生成を防ぐ (browser の stream_id は都度変わる)。
4132
4169
  attachId: msg.attach_id || undefined,
4133
4170
  })
4134
- ctx.client.send({
4135
- type: "codex.ready",
4136
- stream_id,
4137
- resuming: info.resuming,
4138
- thread_id: info.threadId,
4139
- // Slice 6: 履歴 hydrate。resume 時のみ非空 (thread/start は常に [])。
4140
- // consumers.py の per-stream relay は content-blind なので backend 側の
4141
- // 変更は不要 (_AGENT_PER_STREAM_TYPES の codex.ready がそのまま運ぶ)。
4142
- history: info.history || [],
4143
- // T06499: 巨大スレッドで末尾 N ターンに絞った場合 true。browser は
4144
- // 「以前の履歴は省略されています」バナーを出す (会話は resume 済みで継続可)。
4145
- history_truncated: info.historyTruncated === true,
4146
- // 案C (権威的ターン状態): フロントは会話末尾からの推測 (deriveTurnActive
4147
- // FromHistory) をやめ、Codex が明示する Thread.status で turnActive を決める。
4148
- // resume 後は新プロセスで実行中ターンが無く thread_status='idle' が返るため、
4149
- // 生成中スピナー固着 (中断/ツールのみ終了ターンで末尾が user メッセージになる
4150
- // ケース) が根治する。
4151
- turn_active: info.turnActive === true,
4152
- thread_status: info.threadStatus || null,
4153
- last_turn_status: info.lastTurnStatus || null,
4154
- // Codex の通常送信 FIFO。ready と同じ封筒に初期 snapshot を載せ、
4155
- // reattach / reload でも送信待ち表示を即時復元する。
4156
- queue_state: ctx.codexBridge.queueState(stream_id),
4157
- })
4171
+ ctx.client.send(
4172
+ buildCodexReadyPayload(stream_id, info, ctx.codexBridge.queueState(stream_id)),
4173
+ )
4158
4174
  // S1 (2026-07): claude.attach と対称の identity 正本化。Codex は thread/start
4159
4175
  // 応答時点で threadId が確定するため (Claude の fresh とは異なり)、fresh でも
4160
4176
  // 既に分かっている threadId を sessionId として記録する (直後の turn/started が