@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.32",
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",
@@ -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
- 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 が