@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 +1 -1
- package/src/codex-stream-bridge.mjs +283 -0
package/package.json
CHANGED
|
@@ -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**(生成時に振る
|