@cocorograph/hub-agent 0.8.32 → 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 +1 -1
- package/src/codex-stream-bridge.mjs +334 -0
- package/src/main.mjs +40 -24
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,
|
|
@@ -583,6 +621,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
583
621
|
// 由来で表示がリセットされ得るため、live セッションが保持する高忠実度履歴を返す。
|
|
584
622
|
history,
|
|
585
623
|
historyTruncated: existing.historyTruncated === true,
|
|
624
|
+
subagents: this._subagentSnapshots(existing, history),
|
|
586
625
|
}
|
|
587
626
|
}
|
|
588
627
|
if (r.status === "superseded") {
|
|
@@ -709,6 +748,11 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
709
748
|
* deliveryId: string, sourceStreamId: string, deliveryPending: boolean,
|
|
710
749
|
* deliveryPromise: Promise<object>, settleDelivery: Function}>} */
|
|
711
750
|
pendingInjectSources: [],
|
|
751
|
+
/** 組み込み collaboration subagent の表示用台帳。ライフサイクルの安定キーは
|
|
752
|
+
* agentThreadId(item.id は dynamicToolCall とのメタデータ結合にだけ使う)。 */
|
|
753
|
+
subagents: new Map(),
|
|
754
|
+
/** dynamicToolCall(spawn_agent) の item.id -> 表示メタデータ。 */
|
|
755
|
+
subagentSpawnMetadata: new Map(),
|
|
712
756
|
/** 実行中の背景コマンド(commandExecution)の台帳。claude 側 `_liveTasks` と同型。
|
|
713
757
|
* key = `item.id`(`item/started` / `item/completed` の両方に載る唯一の安定 ID)、
|
|
714
758
|
* value = { threadId, startedAt, lastSeenAt }。
|
|
@@ -807,6 +851,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
807
851
|
session.historyItems = history.slice()
|
|
808
852
|
session.historyBytes = history.reduce((n, it) => n + estimateItemBytes(it), 0)
|
|
809
853
|
session.historyTruncated = historyTruncated
|
|
854
|
+
if (resuming) await this._restoreSubagentsFromHistory(session, history)
|
|
810
855
|
// 案C: 権威的なターン状態を resume 応答から取り出してフロントへ渡す。
|
|
811
856
|
// これによりフロントは会話末尾からの推測をやめ、Thread.status で turnActive を
|
|
812
857
|
// 決められる (生成中スピナー固着バグの根治)。新規スレッドは常に idle 相当。
|
|
@@ -863,6 +908,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
863
908
|
threadId: session.threadId,
|
|
864
909
|
history,
|
|
865
910
|
historyTruncated,
|
|
911
|
+
subagents: this._subagentSnapshots(session, history),
|
|
866
912
|
threadStatus: turnState.threadStatus,
|
|
867
913
|
lastTurnStatus: turnState.lastTurnStatus,
|
|
868
914
|
turnActive: turnState.turnActive,
|
|
@@ -1524,6 +1570,266 @@ 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
|
+
// background は spawn 時に確定する属性。後続通知に metadata が無くても true を失わない。
|
|
1627
|
+
background: metadata?.background === true || previous?.background === true,
|
|
1628
|
+
atMs: transitionAtMs,
|
|
1629
|
+
startedAtMs:
|
|
1630
|
+
phase === "started" && terminalSubagentPhase(previous?.phase)
|
|
1631
|
+
? transitionAtMs
|
|
1632
|
+
: previous?.startedAtMs ?? transitionAtMs,
|
|
1633
|
+
terminalAtMs: terminalSubagentPhase(phase) ? transitionAtMs : null,
|
|
1634
|
+
lastEmittedPhase: previous?.lastEmittedPhase || null,
|
|
1635
|
+
}
|
|
1636
|
+
session.subagents.set(agentThreadId, record)
|
|
1637
|
+
if (emit) this._emitSubagentState(session, record, { force })
|
|
1638
|
+
return record
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
_observeSubagentItem(session, item, atMs = Date.now(), { emit = true } = {}) {
|
|
1642
|
+
if (!item || typeof item !== "object") return
|
|
1643
|
+
if (item.type === "dynamicToolCall") {
|
|
1644
|
+
const namespace = item.namespace
|
|
1645
|
+
if (namespace !== "collaboration" || item.tool !== "spawn_agent" || !item.id) return
|
|
1646
|
+
const args = parseDynamicToolArguments(item.arguments)
|
|
1647
|
+
if (!args) return
|
|
1648
|
+
const metadata = {
|
|
1649
|
+
agent: typeof args.agent_type === "string" && args.agent_type ? args.agent_type : null,
|
|
1650
|
+
description: typeof args.task_name === "string" && args.task_name ? args.task_name : null,
|
|
1651
|
+
prompt: typeof args.message === "string" ? args.message : null,
|
|
1652
|
+
background: args.background === true,
|
|
1653
|
+
}
|
|
1654
|
+
session.subagentSpawnMetadata.set(item.id, metadata)
|
|
1655
|
+
while (session.subagentSpawnMetadata.size > 256) {
|
|
1656
|
+
session.subagentSpawnMetadata.delete(session.subagentSpawnMetadata.keys().next().value)
|
|
1657
|
+
}
|
|
1658
|
+
return
|
|
1659
|
+
}
|
|
1660
|
+
if (item.type !== "subAgentActivity") return
|
|
1661
|
+
const agentThreadId = item.agentThreadId
|
|
1662
|
+
if (typeof agentThreadId !== "string" || !agentThreadId) return
|
|
1663
|
+
if (agentThreadId === session.threadId || item.agentPath === "/root") return
|
|
1664
|
+
const previous = session.subagents.get(agentThreadId)
|
|
1665
|
+
let phase = null
|
|
1666
|
+
if (item.kind === "interrupted") phase = "stopped"
|
|
1667
|
+
if ((item.kind === "started" || item.kind === "interacted") && !terminalSubagentPhase(previous?.phase)) {
|
|
1668
|
+
phase = "started"
|
|
1669
|
+
}
|
|
1670
|
+
if (!phase) return
|
|
1671
|
+
const metadata = session.subagentSpawnMetadata.get(item.id) || null
|
|
1672
|
+
this._setSubagentPhase(
|
|
1673
|
+
session,
|
|
1674
|
+
agentThreadId,
|
|
1675
|
+
phase,
|
|
1676
|
+
{
|
|
1677
|
+
agentPath: item.agentPath,
|
|
1678
|
+
metadata,
|
|
1679
|
+
atMs,
|
|
1680
|
+
},
|
|
1681
|
+
{ emit },
|
|
1682
|
+
)
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
_observeSubagentTurn(session, msg) {
|
|
1686
|
+
const threadId = msg?.params?.threadId
|
|
1687
|
+
if (typeof threadId !== "string" || threadId === session.threadId) return false
|
|
1688
|
+
const record = session.subagents.get(threadId)
|
|
1689
|
+
if (!record) return true
|
|
1690
|
+
const turn = msg.params?.turn
|
|
1691
|
+
const atMs =
|
|
1692
|
+
typeof msg.params?.completedAtMs === "number"
|
|
1693
|
+
? msg.params.completedAtMs
|
|
1694
|
+
: typeof msg.params?.startedAtMs === "number"
|
|
1695
|
+
? msg.params.startedAtMs
|
|
1696
|
+
: typeof turn?.completedAt === "number"
|
|
1697
|
+
? Math.round(turn.completedAt * 1000)
|
|
1698
|
+
: typeof turn?.startedAt === "number"
|
|
1699
|
+
? Math.round(turn.startedAt * 1000)
|
|
1700
|
+
: Date.now()
|
|
1701
|
+
if (msg.method === "turn/started") {
|
|
1702
|
+
this._setSubagentPhase(session, threadId, "started", {
|
|
1703
|
+
agentPath: record.agentPath,
|
|
1704
|
+
atMs,
|
|
1705
|
+
})
|
|
1706
|
+
} else if (msg.method === "turn/completed") {
|
|
1707
|
+
const phase = terminalSubagentPhase(msg.params?.turn?.status)
|
|
1708
|
+
if (phase) {
|
|
1709
|
+
this._setSubagentPhase(session, threadId, phase, {
|
|
1710
|
+
agentPath: record.agentPath,
|
|
1711
|
+
atMs,
|
|
1712
|
+
})
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
return true
|
|
1716
|
+
}
|
|
1717
|
+
|
|
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)
|
|
1732
|
+
}
|
|
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
|
|
1753
|
+
}
|
|
1754
|
+
|
|
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 })
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const records = [...session.subagents.values()]
|
|
1764
|
+
let nextRecord = 0
|
|
1765
|
+
const readWorker = async () => {
|
|
1766
|
+
while (nextRecord < records.length) {
|
|
1767
|
+
const record = records[nextRecord++]
|
|
1768
|
+
try {
|
|
1769
|
+
const result = await session.client.request("thread/read", {
|
|
1770
|
+
threadId: record.agentThreadId,
|
|
1771
|
+
includeTurns: true,
|
|
1772
|
+
})
|
|
1773
|
+
// RPC 待ち中に live lifecycle が進んだ場合、stale read で上書き・削除しない。
|
|
1774
|
+
if (session.subagents.get(record.agentThreadId) !== record) continue
|
|
1775
|
+
const thread = result?.thread
|
|
1776
|
+
const turns = Array.isArray(thread?.turns) ? thread.turns : []
|
|
1777
|
+
const lastTurn = turns.at(-1)
|
|
1778
|
+
const phase = terminalSubagentPhase(lastTurn?.status)
|
|
1779
|
+
const lastTurnAtMs =
|
|
1780
|
+
typeof lastTurn?.completedAt === "number"
|
|
1781
|
+
? Math.round(lastTurn.completedAt * 1000)
|
|
1782
|
+
: typeof lastTurn?.startedAt === "number"
|
|
1783
|
+
? Math.round(lastTurn.startedAt * 1000)
|
|
1784
|
+
: Date.now()
|
|
1785
|
+
if (phase) {
|
|
1786
|
+
this._setSubagentPhase(
|
|
1787
|
+
session,
|
|
1788
|
+
record.agentThreadId,
|
|
1789
|
+
phase,
|
|
1790
|
+
{ agentPath: record.agentPath, atMs: lastTurnAtMs },
|
|
1791
|
+
{ emit: false },
|
|
1792
|
+
)
|
|
1793
|
+
} else if (
|
|
1794
|
+
thread?.status?.type === "active" &&
|
|
1795
|
+
(!lastTurn || lastTurn.status === "inProgress")
|
|
1796
|
+
) {
|
|
1797
|
+
this._setSubagentPhase(
|
|
1798
|
+
session,
|
|
1799
|
+
record.agentThreadId,
|
|
1800
|
+
"started",
|
|
1801
|
+
{
|
|
1802
|
+
agentPath: record.agentPath,
|
|
1803
|
+
atMs: record.startedAtMs,
|
|
1804
|
+
},
|
|
1805
|
+
{ emit: false },
|
|
1806
|
+
)
|
|
1807
|
+
} else {
|
|
1808
|
+
session.subagents.delete(record.agentThreadId)
|
|
1809
|
+
}
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
if (session.subagents.get(record.agentThreadId) === record) {
|
|
1812
|
+
session.subagents.delete(record.agentThreadId)
|
|
1813
|
+
}
|
|
1814
|
+
this.logger?.warn?.(
|
|
1815
|
+
{
|
|
1816
|
+
err: err?.message || String(err),
|
|
1817
|
+
thread_id: session.threadId,
|
|
1818
|
+
subagent_thread_id: record.agentThreadId,
|
|
1819
|
+
},
|
|
1820
|
+
"codex subagent thread/read failed during resume; excluded from ready snapshot",
|
|
1821
|
+
)
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
await Promise.all(
|
|
1826
|
+
Array.from(
|
|
1827
|
+
{ length: Math.min(SUBAGENT_READ_CONCURRENCY, records.length) },
|
|
1828
|
+
() => readWorker(),
|
|
1829
|
+
),
|
|
1830
|
+
)
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1527
1833
|
/** 実行中の背景コマンドを持つか(warm 境界の保護判定用)。**先に失効エントリを掃除してから**
|
|
1528
1834
|
* 判定する。失効は「最後に生存を確認してから CODEX_EXEC_PROTECT_TTL_MS」。
|
|
1529
1835
|
* ⚠️ 失効させる = そのセッションは kill されうる、という意味なので必ず warn を残す
|
|
@@ -1573,10 +1879,34 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1573
1879
|
// (タグ済み or 素) を history と emit の両方で使う。一致しなければ null → 素の params のまま。
|
|
1574
1880
|
// ※ hub-agent 再起動 (cold resume) では historyItems が揮発するため青バブルへ戻る (claude の
|
|
1575
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
|
+
}
|
|
1576
1895
|
const taggedParams = this._tagInjectSourceOnItem(session, msg)
|
|
1577
1896
|
const effectiveParams = taggedParams || msg.params
|
|
1897
|
+
if (msg.method === "item/started" || msg.method === "item/completed") {
|
|
1898
|
+
this._observeSubagentItem(
|
|
1899
|
+
session,
|
|
1900
|
+
effectiveParams?.item,
|
|
1901
|
+
effectiveParams?.completedAtMs || Date.now(),
|
|
1902
|
+
)
|
|
1903
|
+
}
|
|
1904
|
+
const childTurnNotification =
|
|
1905
|
+
(msg.method === "turn/started" || msg.method === "turn/completed") &&
|
|
1906
|
+
this._observeSubagentTurn(session, msg)
|
|
1578
1907
|
switch (msg.method) {
|
|
1579
1908
|
case "turn/started":
|
|
1909
|
+
if (childTurnNotification) break
|
|
1580
1910
|
session.activeTurnId = msg.params?.turn?.id || null
|
|
1581
1911
|
// round4 major#2: 世代を前進させる (pump が fast-turn を activeTurnId だけでなく世代前進でも検知)。
|
|
1582
1912
|
session.turnGeneration += 1
|
|
@@ -1595,6 +1925,7 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1595
1925
|
)
|
|
1596
1926
|
break
|
|
1597
1927
|
case "turn/completed":
|
|
1928
|
+
if (childTurnNotification) break
|
|
1598
1929
|
{
|
|
1599
1930
|
const completedTurnId = msg.params?.turn?.id || null
|
|
1600
1931
|
// App Server の対象 turn と現在の権威 activeTurnId が一致したときだけ idle 化する。
|
|
@@ -1644,6 +1975,9 @@ export class CodexStreamBridge extends EventEmitter {
|
|
|
1644
1975
|
default:
|
|
1645
1976
|
break
|
|
1646
1977
|
}
|
|
1978
|
+
// 子 turn lifecycle は cockpit/subagent_state へ正規化済み。raw turn 通知まで frontend へ流すと
|
|
1979
|
+
// root の turnActive/heal_turn と chat activity を子の状態で誤更新するため、ここで止める。
|
|
1980
|
+
if (childTurnNotification) return
|
|
1647
1981
|
this.emit("event", {
|
|
1648
1982
|
stream_id,
|
|
1649
1983
|
// producer_seq: この通知を出した **session(producer) の immutable ID**(生成時に振る
|
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
|
-
|
|
4136
|
-
|
|
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 が
|