@bivy/bivy 0.20.7-staging.2 → 0.20.7-staging.3

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.
Files changed (136) hide show
  1. package/bin/bivy.mjs +39 -5
  2. package/bin/codex-app-server-shim.mjs +78 -27
  3. package/dist/agent-instructions.js +2 -1
  4. package/dist/agents/claude-code/runtime.js +2 -1
  5. package/dist/agents/profiles.js +9 -6
  6. package/dist/apps/gateway.js +91 -3
  7. package/dist/apps/inspector.js +56 -1
  8. package/dist/apps/preview-shell.js +197 -5
  9. package/dist/apps/registry.js +4 -1
  10. package/dist/apps/scenario-proxy.js +65 -0
  11. package/dist/apps/scenarios.js +307 -0
  12. package/dist/apps/service.js +111 -7
  13. package/dist/apps/styles.css +4 -0
  14. package/dist/apps/types.js +2 -1
  15. package/dist/controllers/app-commands.js +3 -2
  16. package/dist/exec.js +4 -1
  17. package/dist/node-env.js +6 -0
  18. package/dist/protocol/client-command-routes.js +1 -1
  19. package/dist/protocol/client-command-schemas.js +3 -2
  20. package/dist/runtime/codex-sessions.js +158 -6
  21. package/dist/runtime/credential-provisioning.js +5 -0
  22. package/dist/runtime/grok-sessions.js +4 -2
  23. package/dist/runtime/index.js +26 -19
  24. package/dist/runtime/inherited-session-env.js +54 -0
  25. package/dist/runtime/model-ref.js +14 -1
  26. package/dist/runtime/opencode-auth.js +67 -0
  27. package/dist/runtime/opencode-preflight.js +10 -1
  28. package/dist/runtime/protocol.js +35 -1
  29. package/dist/runtime/tool-call-map.js +67 -3
  30. package/dist/server.js +49 -15
  31. package/dist/session/event-log.js +76 -5
  32. package/dist/session/fork-standup.js +1 -1
  33. package/dist/session/fork.js +6 -4
  34. package/dist/session/transcript-normal.js +52 -0
  35. package/dist/session/transcript-persistence.js +20 -3
  36. package/dist/session-ref.js +5 -2
  37. package/dist/worktree.js +25 -3
  38. package/package.json +1 -1
  39. package/web/assets/{AutomationsView-CJSYk0ja.js → AutomationsView-DuWxbo5B.js} +1 -1
  40. package/web/assets/{ChangesCard-Bp88fY6W.js → ChangesCard-BnHllUb0.js} +1 -1
  41. package/web/assets/{LibraryView-of9wmuzN.js → LibraryView-DpNuD-T7.js} +1 -1
  42. package/web/assets/{ProviderConnect-DG7SP4l2.js → ProviderConnect-4vjf567A.js} +1 -1
  43. package/web/assets/{Settings-DGH1U7wj.js → Settings-CHofnxyn.js} +3 -3
  44. package/web/assets/{Sheet-D-EHCMlj.js → Sheet-DwouI-Tt.js} +1 -1
  45. package/web/assets/{Terminal-qqysrwl_.js → Terminal-3mNEW7UT.js} +1 -1
  46. package/web/assets/{VoiceSettings-aeqQkwcZ.js → VoiceSettings-BI4B-zS1.js} +1 -1
  47. package/web/assets/{abnfDiagram-VCTEODGH-Ct-lmBn5.js → abnfDiagram-VCTEODGH-DPP1TRyh.js} +1 -1
  48. package/web/assets/architecture-7GRP2DOG-DUHnGffN.js +1 -0
  49. package/web/assets/{architectureDiagram-5GKGNRK7-CCq716Da.js → architectureDiagram-5GKGNRK7-CvgInp30.js} +1 -1
  50. package/web/assets/{blockDiagram-I7D4REHJ-Lka9F6ZB.js → blockDiagram-I7D4REHJ-CZAAYK-I.js} +1 -1
  51. package/web/assets/{c4Diagram-7LVT6UL2-nvhhyzri.js → c4Diagram-7LVT6UL2-BYJlvZiZ.js} +1 -1
  52. package/web/assets/channel-vvvNMjoB.js +1 -0
  53. package/web/assets/{chunk-4HAMMTFA-TZ5hhHBz.js → chunk-4HAMMTFA-Cj4-BAMD.js} +1 -1
  54. package/web/assets/{chunk-75Z2AOVW-IahKw8kM.js → chunk-75Z2AOVW-3eT-aS0t.js} +1 -1
  55. package/web/assets/{chunk-DU6HZSFF-BlcZUVOy.js → chunk-DU6HZSFF-D_4JI7OY.js} +1 -1
  56. package/web/assets/{chunk-F27PBJKO-3MGfEgKu.js → chunk-F27PBJKO-5wTG02yk.js} +1 -1
  57. package/web/assets/{chunk-GMAD6QVW-Be72n678.js → chunk-GMAD6QVW-IliCLU5B.js} +1 -1
  58. package/web/assets/{chunk-GVQU2GXP-GOVLT6xm.js → chunk-GVQU2GXP-CB3DCzUu.js} +1 -1
  59. package/web/assets/{chunk-IMKFNOWR-ByOzewrT.js → chunk-IMKFNOWR-Bg3eY-xc.js} +1 -1
  60. package/web/assets/{chunk-L3NEJ4N5-BCGhHmnW.js → chunk-L3NEJ4N5-BEMaPYT9.js} +1 -1
  61. package/web/assets/{chunk-OSK3NFVY-CAdliJE_.js → chunk-OSK3NFVY-DMc-FLYk.js} +1 -1
  62. package/web/assets/{chunk-P2QGCYS3-CM8cpgHa.js → chunk-P2QGCYS3-CaqPruPs.js} +1 -1
  63. package/web/assets/{chunk-POPQ4Y6H-CXLPhaI1.js → chunk-POPQ4Y6H-3h-I9lV1.js} +1 -1
  64. package/web/assets/{chunk-PWAF6VOD-BegFrwDe.js → chunk-PWAF6VOD-CPLNMq3b.js} +1 -1
  65. package/web/assets/{chunk-SHT3W25Y-iXj7Y5RI.js → chunk-SHT3W25Y-ErtwLBXR.js} +1 -1
  66. package/web/assets/{chunk-SVP7TREG-73pyTBZg.js → chunk-SVP7TREG-GNUjH0zF.js} +1 -1
  67. package/web/assets/{chunk-TICWLB2K-D5zwuuGI.js → chunk-TICWLB2K-DtbyVpEE.js} +1 -1
  68. package/web/assets/classDiagram-ZZMXUADV-C3eKmWom.js +1 -0
  69. package/web/assets/classDiagram-v2-VYDZK3BY-C3eKmWom.js +1 -0
  70. package/web/assets/{cynefin-OW5HDTMX-D3nRr9S_.js → cynefin-OW5HDTMX-CQt68k-b.js} +1 -1
  71. package/web/assets/{cynefinDiagram-5FMLGOSQ-C4CKMYMZ.js → cynefinDiagram-5FMLGOSQ-BeEQcUmX.js} +1 -1
  72. package/web/assets/{dagre-GXQ25YYZ-D-grkZ8X.js → dagre-GXQ25YYZ-CoekQT_h.js} +1 -1
  73. package/web/assets/{diagram-S7CK7UJ4-BNPS3YQX.js → diagram-S7CK7UJ4-DQBF8H5Z.js} +1 -1
  74. package/web/assets/{diagram-UQ7AKVKN-Bkp_TyRg.js → diagram-UQ7AKVKN-2Tw1WGQF.js} +1 -1
  75. package/web/assets/{diagram-VSXAHHWV-Dpxe_6MM.js → diagram-VSXAHHWV-C4Ns08EL.js} +1 -1
  76. package/web/assets/{diagram-VX7I27RA-DwGBvGn-.js → diagram-VX7I27RA-CMUQSfon.js} +1 -1
  77. package/web/assets/{diagram-Z3DM3KII-D-KvONMO.js → diagram-Z3DM3KII-Lqvr8Yf6.js} +1 -1
  78. package/web/assets/{ebnfDiagram-PWID7BFC-Di_D-a-0.js → ebnfDiagram-PWID7BFC-BAYJ-dgh.js} +1 -1
  79. package/web/assets/{erDiagram-RLTQ6QDP-2pnBfTU9.js → erDiagram-RLTQ6QDP-Dg8VYnq4.js} +1 -1
  80. package/web/assets/eventmodeling-NTZA5JFV-ChDRNWM1.js +1 -0
  81. package/web/assets/flowDiagram-HODETNUW-PojVb4l3.js +1 -0
  82. package/web/assets/{ganttDiagram-EL5Y4UJY-DAkhMTlk.js → ganttDiagram-EL5Y4UJY-ywnauJ7D.js} +1 -1
  83. package/web/assets/{gitGraph-4MIJSDKK-BQvemX2W.js → gitGraph-4MIJSDKK-bYI8lc-g.js} +1 -1
  84. package/web/assets/{gitGraphDiagram-WWUBYQGX-Dx7ya_2q.js → gitGraphDiagram-WWUBYQGX-erBDwxX8.js} +1 -1
  85. package/web/assets/{index-BXPRG7Pj.css → index-Ch1zstZB.css} +1 -1
  86. package/web/assets/{index-CFX8FhqJ.js → index-DzT1Tnf2.js} +2 -2
  87. package/web/assets/{info-A6RAGUB7-DaDps2sq.js → info-A6RAGUB7-BZbsPdZJ.js} +1 -1
  88. package/web/assets/{infoDiagram-27XIBGKW-DBqMvPr2.js → infoDiagram-27XIBGKW-qqTv-8cL.js} +1 -1
  89. package/web/assets/{ishikawaDiagram-5VMMS53U-DzciF3O_.js → ishikawaDiagram-5VMMS53U-v-e3YR9X.js} +1 -1
  90. package/web/assets/{journeyDiagram-3NMN7TZE-D9WHgGe3.js → journeyDiagram-3NMN7TZE-D61ZGndw.js} +1 -1
  91. package/web/assets/{kanban-definition-UXKFOSKX-Diokj8By.js → kanban-definition-UXKFOSKX-NeYWFrdx.js} +1 -1
  92. package/web/assets/{line-DmBeTXFn.js → line-BNxdvAy9.js} +1 -1
  93. package/web/assets/{mermaid-parser.core-H_O1CweI.js → mermaid-parser.core-BjJ-qoss.js} +3 -3
  94. package/web/assets/{mermaid.core-BAni15Ed.js → mermaid.core-DT4QwB_n.js} +4 -4
  95. package/web/assets/{mindmap-definition-YA3MSWOX-B_tXdzEa.js → mindmap-definition-YA3MSWOX-BxuVgGhr.js} +1 -1
  96. package/web/assets/{mount-Fdy9Xoxs.js → mount-CK5h6xJ4.js} +7 -7
  97. package/web/assets/{packet-AYTQ26CC-Dt4bPH7I.js → packet-AYTQ26CC-DIff_vXS.js} +1 -1
  98. package/web/assets/{pegDiagram-XKGWAZYB-loAbaKUZ.js → pegDiagram-XKGWAZYB-oDNo32c7.js} +1 -1
  99. package/web/assets/{pie-WAS4IAKB-DEmhJN7M.js → pie-WAS4IAKB-C0cdo_NU.js} +1 -1
  100. package/web/assets/{pieDiagram-E7YTZNPT-DfQvD2QY.js → pieDiagram-E7YTZNPT-BbThivUY.js} +1 -1
  101. package/web/assets/{quadrantDiagram-AXDQQJYC-Bhqo2rT-.js → quadrantDiagram-AXDQQJYC-CONfSoMt.js} +1 -1
  102. package/web/assets/{radar-RG4KPBEZ-BmV5tXie.js → radar-RG4KPBEZ-teYstrbs.js} +1 -1
  103. package/web/assets/{railroad-74A4TZTK-DoebyExe.js → railroad-74A4TZTK-B4VusJHl.js} +1 -1
  104. package/web/assets/railroad-abnf-HS5TGJTU-DIF_YU01.js +1 -0
  105. package/web/assets/railroad-ebnf-LZEXJU2U-BoHyJK5s.js +1 -0
  106. package/web/assets/railroad-peg-WCYAUIDC-uG1Bcrgs.js +1 -0
  107. package/web/assets/{railroadDiagram-O6MQD6OU-BhlfIpCW.js → railroadDiagram-O6MQD6OU-CKrYutQ_.js} +1 -1
  108. package/web/assets/{requirementDiagram-BXWQKSXE-vkxH8U-2.js → requirementDiagram-BXWQKSXE-COt2S2Y4.js} +1 -1
  109. package/web/assets/{sankeyDiagram-P5KCCOFB-D2iFl0bc.js → sankeyDiagram-P5KCCOFB-D72oeNbf.js} +1 -1
  110. package/web/assets/{sequenceDiagram-WJ2MYXX4-Ll_m9w0-.js → sequenceDiagram-WJ2MYXX4-DFpVcNjb.js} +1 -1
  111. package/web/assets/{settingsRoute-C8p10bxH.js → settingsRoute-Clztmzl_.js} +1 -1
  112. package/web/assets/{stateDiagram-D77RDMKH-B4KWuRz2.js → stateDiagram-D77RDMKH-BKW8l6Ru.js} +1 -1
  113. package/web/assets/stateDiagram-v2-MP3YSRHH-C1t9bUgR.js +1 -0
  114. package/web/assets/{swimlanes-42K2YHIH-CVXwQL4b.js → swimlanes-42K2YHIH-GRXgsapd.js} +1 -1
  115. package/web/assets/swimlanesDiagram-VR7AAH4N-Dp35pflJ.js +8 -0
  116. package/web/assets/{timeline-definition-24CTP7MA-C0XuGVkJ.js → timeline-definition-24CTP7MA-C8DELzYs.js} +1 -1
  117. package/web/assets/{treeView-Q6P3EWNA-BS4edB5m.js → treeView-Q6P3EWNA-C1MDeOPi.js} +1 -1
  118. package/web/assets/{treemap-WGGIJYW6-BZ54DPA4.js → treemap-WGGIJYW6-CcwgMCe9.js} +1 -1
  119. package/web/assets/{useStore-DEfLrrMh.js → useStore-CaQamKgO.js} +7 -5
  120. package/web/assets/{vennDiagram-4TSXK5OY-CAKXCk3I.js → vennDiagram-4TSXK5OY-Co-Nw6M-.js} +1 -1
  121. package/web/assets/{wardley-WFR3VGLG-DM5IZXxI.js → wardley-WFR3VGLG-FbKVF2Pu.js} +1 -1
  122. package/web/assets/{wardleyDiagram-VM6X3IG4--WWmycX2.js → wardleyDiagram-VM6X3IG4-Bt7NGXJX.js} +1 -1
  123. package/web/assets/{xychartDiagram-S5SC5T6Z-D-_SgAQy.js → xychartDiagram-S5SC5T6Z-BsgyT9WB.js} +1 -1
  124. package/web/index.html +2 -2
  125. package/web/sw.js +1 -1
  126. package/web/assets/architecture-7GRP2DOG-CD4t1j2T.js +0 -1
  127. package/web/assets/channel-Dl0Aa963.js +0 -1
  128. package/web/assets/classDiagram-ZZMXUADV-BvMwIvP0.js +0 -1
  129. package/web/assets/classDiagram-v2-VYDZK3BY-BvMwIvP0.js +0 -1
  130. package/web/assets/eventmodeling-NTZA5JFV-CF677pnK.js +0 -1
  131. package/web/assets/flowDiagram-HODETNUW-8SiMcM5n.js +0 -1
  132. package/web/assets/railroad-abnf-HS5TGJTU-C_F3QWV5.js +0 -1
  133. package/web/assets/railroad-ebnf-LZEXJU2U-BaNfyzXp.js +0 -1
  134. package/web/assets/railroad-peg-WCYAUIDC-BWyMWwxG.js +0 -1
  135. package/web/assets/stateDiagram-v2-MP3YSRHH-B_Xx-QG6.js +0 -1
  136. package/web/assets/swimlanesDiagram-VR7AAH4N-XYt-UA-z.js +0 -8
@@ -73,7 +73,8 @@ function metaFromFirstLine(line) {
73
73
  const id = typeof p.id === "string" ? p.id : typeof p.session_id === "string" ? p.session_id : undefined;
74
74
  const cwd = typeof p.cwd === "string" ? p.cwd : undefined;
75
75
  const createdAt = toEpoch(p.timestamp ?? rec.timestamp);
76
- return { id, cwd, createdAt };
76
+ const parentThreadId = typeof p.parent_thread_id === "string" && p.parent_thread_id ? p.parent_thread_id : undefined;
77
+ return { id, cwd, createdAt, ...(parentThreadId ? { parentThreadId } : {}) };
77
78
  }
78
79
  catch {
79
80
  return {};
@@ -95,6 +96,17 @@ function textOf(content) {
95
96
  .join("")
96
97
  .trim();
97
98
  }
99
+ /** Context Codex injects as "user" turns: its environment block, plugin hints,
100
+ * and AGENTS.md instructions. They are not something the user said, so a
101
+ * reopened transcript must not show them as user messages. */
102
+ const INJECTED_USER_TURNS = [
103
+ /^<(environment_context|recommended_plugins|user_instructions|permissions instructions)\b/,
104
+ /^# AGENTS\.md instructions for /,
105
+ ];
106
+ function isInjectedUserTurn(text) {
107
+ const start = text.trimStart();
108
+ return INJECTED_USER_TURNS.some((pattern) => pattern.test(start));
109
+ }
98
110
  /**
99
111
  * Reconstruct a session's conversation from its rollout file as normalized
100
112
  * RuntimeMessages (role + content + timestamp) — the same shape loadClaudeTranscript
@@ -103,6 +115,8 @@ function textOf(content) {
103
115
  */
104
116
  export function loadCodexTranscriptFile(file) {
105
117
  const messages = [];
118
+ // The model of the turn being read (from its turn_context record).
119
+ let model;
106
120
  let raw;
107
121
  try {
108
122
  raw = fs.readFileSync(file, "utf8");
@@ -121,18 +135,153 @@ export function loadCodexTranscriptFile(file) {
121
135
  continue;
122
136
  }
123
137
  const p = inner(rec);
138
+ const timestamp = toEpoch(p.timestamp ?? rec.timestamp) ?? Date.now();
139
+ if (rec.type === "turn_context" && typeof p.model === "string" && p.model) {
140
+ model = p.model;
141
+ continue;
142
+ }
143
+ // Tool activity: a call and its output, paired by call_id. Kept as the same
144
+ // tool_use / tool_result blocks every other transcript uses, so a reopened,
145
+ // taken-over or forked Codex session still shows what ran and what it printed.
146
+ const tools = toolBlocksOf(p);
147
+ if (tools.length) {
148
+ messages.push(tools[0].type === "tool_use"
149
+ ? { role: "assistant", content: tools, timestamp, ...(model ? { model } : {}) }
150
+ : { role: "user", content: tools, timestamp });
151
+ continue;
152
+ }
124
153
  // A message item: either {role, content} directly or an item with type "message".
125
154
  const role = p.role ?? (p.type === "message" ? p.role : undefined);
126
155
  if (role !== "user" && role !== "assistant")
127
156
  continue;
128
157
  const content = p.content ?? p.text;
129
158
  const text = textOf(content);
130
- if (!text)
159
+ if (!text || (role === "user" && isInjectedUserTurn(text)))
131
160
  continue;
132
- messages.push({ role, content: text, timestamp: toEpoch(p.timestamp ?? rec.timestamp) ?? Date.now() });
161
+ messages.push({ role, content: text, timestamp, ...(role === "assistant" && model ? { model } : {}) });
133
162
  }
134
163
  return messages;
135
164
  }
165
+ /** Rollout tool items as tool_use / tool_result blocks (empty when `p` is not
166
+ * one). Codex writes `function_call` (JSON `arguments`) and `custom_tool_call`
167
+ * (free-form `input`), each followed by an `…_output`. A code-mode `exec` call
168
+ * is a script of `tools.<name>(<arg>)` calls with one output chunk each; it is
169
+ * unwrapped into those calls, in the shapes the live app-server path uses, so a
170
+ * reloaded card reads like the live one. */
171
+ function toolBlocksOf(p) {
172
+ const id = typeof p.call_id === "string" ? p.call_id : undefined;
173
+ if (!id)
174
+ return [];
175
+ if (p.type === "custom_tool_call" && typeof p.input === "string") {
176
+ const calls = codeModeCalls(p.input);
177
+ if (calls.length)
178
+ return calls.map((call, i) => ({ type: "tool_use", id: codeModeId(id, i, calls.length), ...call }));
179
+ }
180
+ if (p.type === "function_call" || p.type === "custom_tool_call") {
181
+ let input = p.input !== undefined ? { input: p.input } : {};
182
+ if (typeof p.arguments === "string") {
183
+ try {
184
+ input = withoutOpaqueValues(JSON.parse(p.arguments));
185
+ }
186
+ catch {
187
+ input = { arguments: p.arguments };
188
+ }
189
+ }
190
+ return [{ type: "tool_use", id, name: String(p.name || "tool"), input }];
191
+ }
192
+ if (p.type === "function_call_output" || p.type === "custom_tool_call_output") {
193
+ const blocks = Array.isArray(p.output) ? p.output.map((block) => textOf([block])) : [];
194
+ const chunks = blocks.slice(1).map(codeModeResult);
195
+ if (blocks.length > 1 && /^Script completed\b/.test(blocks[0] ?? "") && chunks.every(Boolean)) {
196
+ return chunks.map((content, i) => ({ type: "tool_result", tool_use_id: codeModeId(id, i, chunks.length), content }));
197
+ }
198
+ const output = typeof p.output === "string" ? p.output : blocks.filter(Boolean).join("\n");
199
+ return [{ type: "tool_result", tool_use_id: id, content: output }];
200
+ }
201
+ return [];
202
+ }
203
+ /** The id of the i-th call in a code-mode script; a single call keeps the item's. */
204
+ function codeModeId(id, index, count) {
205
+ return count === 1 ? id : `${id}#${index}`;
206
+ }
207
+ /** Calls in a code-mode script, one `text(await tools.<name>(<arg>));` per line. */
208
+ function codeModeCalls(script) {
209
+ const calls = [];
210
+ for (const line of script.split("\n")) {
211
+ const match = /^\s*text\(await tools\.(\w+)\(([\s\S]*)\)\);?\s*$/.exec(line);
212
+ if (!match) {
213
+ if (line.trim())
214
+ return []; // any other code: keep the script as one opaque call
215
+ continue;
216
+ }
217
+ const arg = parseLooseJson(match[2]);
218
+ if (arg === undefined)
219
+ return [];
220
+ calls.push(codeModeCall(match[1], arg));
221
+ }
222
+ return calls;
223
+ }
224
+ function codeModeCall(name, arg) {
225
+ if (name === "exec_command" && arg && typeof arg === "object" && typeof arg.cmd === "string") {
226
+ const { cmd, workdir } = arg;
227
+ return { name: "shell", input: { command: cmd, ...(typeof workdir === "string" ? { cwd: workdir } : {}) } };
228
+ }
229
+ if (name === "apply_patch" && typeof arg === "string")
230
+ return { name: "apply_patch", input: { changes: patchChanges(arg) } };
231
+ return { name, input: arg && typeof arg === "object" ? withoutOpaqueValues(arg) : { input: arg } };
232
+ }
233
+ /** One code-mode output chunk as the live shell result shape, or the raw text. */
234
+ function codeModeResult(chunk) {
235
+ try {
236
+ const parsed = JSON.parse(chunk);
237
+ if (typeof parsed.output === "string") {
238
+ return { content: parsed.output, ...(typeof parsed.exit_code === "number" ? { exitCode: parsed.exit_code } : {}) };
239
+ }
240
+ return chunk === "{}" ? "completed" : chunk;
241
+ }
242
+ catch {
243
+ return chunk || undefined;
244
+ }
245
+ }
246
+ /** JSON, or the JS object literal code mode writes (bare keys like `{cmd:"…"}`). */
247
+ function parseLooseJson(text) {
248
+ try {
249
+ return JSON.parse(text);
250
+ }
251
+ catch { /* bare keys below */ }
252
+ try {
253
+ return JSON.parse(text.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":'));
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ }
259
+ /** Codex's own patch text as the app-server's `changes` list ({path, kind, diff}). */
260
+ function patchChanges(patch) {
261
+ const changes = [];
262
+ let current;
263
+ const flush = () => { if (current)
264
+ changes.push({ path: current.path, kind: { type: current.kind }, diff: current.lines.join("\n") }); };
265
+ for (const line of patch.split("\n")) {
266
+ const header = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line);
267
+ if (header) {
268
+ flush();
269
+ current = { path: header[2].trim(), kind: header[1].toLowerCase(), lines: [] };
270
+ }
271
+ else if (current && !/^\*\*\* (Begin|End) Patch/.test(line)) {
272
+ current.lines.push(line);
273
+ }
274
+ }
275
+ flush();
276
+ return changes;
277
+ }
278
+ /** Drop values that are opaque encrypted blobs (Codex encrypts some sub-agent
279
+ * messages in its rollout); they are noise on a card. */
280
+ function withoutOpaqueValues(value) {
281
+ if (!value || typeof value !== "object" || Array.isArray(value))
282
+ return value;
283
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => !(typeof v === "string" && /^gAAAAA[\w-]{40,}={0,2}$/.test(v))));
284
+ }
136
285
  /**
137
286
  * Materialise a **cross-runtime** fork's portable history as a fresh Codex
138
287
  * rollout so `codex ... resume <id>` (the app-server's `thread/resume`) opens on
@@ -291,7 +440,7 @@ export function listCodexSessions() {
291
440
  // etc. — which make useless list labels. Prefer the first turn that isn't one
292
441
  // of these XML-ish blocks (i.e. the user's real prompt), falling back to the
293
442
  // first turn only if that's genuinely all there is.
294
- const userTurns = transcript.filter((m) => m.role === "user");
443
+ const userTurns = transcript.filter((m) => m.role === "user" && typeof m.content === "string");
295
444
  const contentOf = (m) => String(m?.content ?? "");
296
445
  const isInjectedBlock = (text) => /^<[a-z][\w-]*>/i.test(text.trimStart());
297
446
  const firstUser = userTurns.find((m) => !isInjectedBlock(contentOf(m))) ?? userTurns[0];
@@ -302,6 +451,7 @@ export function listCodexSessions() {
302
451
  cwd: meta.cwd,
303
452
  createdAt: meta.createdAt ?? mtime,
304
453
  firstMessage,
454
+ ...(meta.parentThreadId ? { parentThreadId: meta.parentThreadId } : {}),
305
455
  });
306
456
  }
307
457
  return sessions.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
@@ -341,7 +491,9 @@ export function deleteCodexSession(sessionId) {
341
491
  export function discoverCodexSessionForCwd(cwd, since = 0) {
342
492
  const target = path.resolve(cwd);
343
493
  const skewMs = 5_000;
344
- return listCodexSessions().find((s) => s.cwd != null && path.resolve(s.cwd) === target && (s.createdAt ?? 0) >= since - skewMs);
494
+ // A sub-agent writes its own rollout in the same cwd, after its parent: the
495
+ // run is the parent's thread.
496
+ return listCodexSessions().find((s) => !s.parentThreadId && s.cwd != null && path.resolve(s.cwd) === target && (s.createdAt ?? 0) >= since - skewMs);
345
497
  }
346
498
  /**
347
499
  * Enumerate Codex's on-disk sessions as bounded discovery metadata (issue
@@ -353,7 +505,7 @@ export function discoverCodexSessionForCwd(cwd, since = 0) {
353
505
  */
354
506
  export function discoverNativeCodexSessions(hasLiveProcess = (cwd) => hasLiveProcessForCwd(cwd, CODEX_BIN_NAMES)) {
355
507
  return listCodexSessions()
356
- .filter((s) => Boolean(s.id))
508
+ .filter((s) => Boolean(s.id) && !s.parentThreadId)
357
509
  .map((s) => ({
358
510
  runtimeId: "codex-approvals",
359
511
  ref: s.id,
@@ -20,6 +20,7 @@ import { createCredentialVault } from "./credential-store.js";
20
20
  import { buildAgentCredentialEnv, createCredentialStore } from "./credentials.js";
21
21
  import { ensureCodexAuth, ensureCodexTrusted } from "./codex-auth.js";
22
22
  import { ensureGrokAuth } from "./grok-auth.js";
23
+ import { opencodeAuthEnv } from "./opencode-auth.js";
23
24
  import { refreshModelOAuth } from "./oauth/model-oauth.js";
24
25
  import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
25
26
  /**
@@ -80,6 +81,10 @@ export async function provisionAgentRun(credsDir, piDir, agentId, workspace) {
80
81
  if (workspace)
81
82
  ensureCodexTrusted(workspace);
82
83
  }
84
+ else if (agentId === "opencode") {
85
+ // A ChatGPT plan connected in Bivy serves OpenCode too (OPENCODE_AUTH_CONTENT).
86
+ Object.assign(env, await opencodeAuthEnv(credsDir).catch(() => ({})));
87
+ }
83
88
  else if (agentId === "grok") {
84
89
  // Official Grok CLI reads XAI_API_KEY/GROK_API_KEY (already in env for api
85
90
  // keys) or its own auth.json; mint the latter from a connected SuperGrok /
@@ -43,6 +43,7 @@ function readSummary(dir) {
43
43
  createdAt: toEpoch(raw.created_at),
44
44
  updatedAt: toEpoch(raw.last_active_at ?? raw.updated_at),
45
45
  name,
46
+ ...(raw.session_kind === "subagent" ? { subagent: true } : {}),
46
47
  };
47
48
  }
48
49
  catch {
@@ -181,6 +182,7 @@ export function listGrokSessions() {
181
182
  updatedAt,
182
183
  name: summary.name,
183
184
  firstMessage: firstUserMessage(dir),
185
+ ...(summary.subagent ? { subagent: true } : {}),
184
186
  });
185
187
  }
186
188
  }
@@ -254,7 +256,7 @@ export function discoverGrokSessionForCwd(cwd, since = 0) {
254
256
  const wanted = process.platform === "win32" ? target.toLowerCase() : target;
255
257
  return listGrokSessions()
256
258
  .filter((s) => {
257
- if (!s.cwd)
259
+ if (!s.cwd || s.subagent)
258
260
  return false;
259
261
  const resolved = process.platform === "win32" ? path.resolve(s.cwd).toLowerCase() : path.resolve(s.cwd);
260
262
  return resolved === wanted;
@@ -270,7 +272,7 @@ export function discoverGrokSessionForCwd(cwd, since = 0) {
270
272
  }
271
273
  /** Issue #156 discovery surface for Grok's on-disk store. */
272
274
  export function discoverNativeGrokSessions(hasLiveProcess = (cwd) => hasLiveProcessForCwd(cwd, GROK_BIN_NAMES)) {
273
- return listGrokSessions().map((s) => ({
275
+ return listGrokSessions().filter((s) => !s.subagent).map((s) => ({
274
276
  runtimeId: "grok",
275
277
  ref: s.id,
276
278
  file: s.dir,
@@ -47,6 +47,7 @@ import { codexCredentialPreflight } from "./codex-preflight.js";
47
47
  import { opencodeCredentialPreflight } from "./opencode-preflight.js";
48
48
  import { grokCredentialPreflight } from "./grok-preflight.js";
49
49
  import { ensureGrokAuth } from "./grok-auth.js";
50
+ import { opencodeAuthEnv } from "./opencode-auth.js";
50
51
  import { parserFactoryFor } from "./cli-parsers.js";
51
52
  import { sandboxTier, sandboxArgsFor } from "../harness/sandbox.js";
52
53
  import { serializeAcpMcpEnv } from "./acp-mcp.js";
@@ -61,6 +62,23 @@ const PREFLIGHT_BEHAVIORS = {
61
62
  opencode: (env, ctx) => opencodeCredentialPreflight(env, ctx),
62
63
  grok: (env) => grokCredentialPreflight(env),
63
64
  };
65
+ /** Readers for an agent's own session store, by the profile's `historyLoader`.
66
+ * The same reader serves the pipe path and the governed ACP path, so a session
67
+ * reopened or taken over from a native run shows its earlier turns either way. */
68
+ const HISTORY_LOADERS = {
69
+ grok: loadGrokTranscript,
70
+ gemini: (sessionId) => loadGeminiFamilyTranscript("gemini", sessionId),
71
+ qwen: (sessionId) => loadGeminiFamilyTranscript("qwen", sessionId),
72
+ };
73
+ /** Launch-env patches that project a Bivy-connected login into an agent's own
74
+ * credential store before it spawns (pipe and governed paths alike). */
75
+ const PREPARE_BEHAVIORS = {
76
+ "grok-auth": async (credsDir) => {
77
+ const home = await ensureGrokAuth(credsDir);
78
+ return home ? { GROK_HOME: home } : {};
79
+ },
80
+ "opencode-auth": (credsDir) => opencodeAuthEnv(credsDir),
81
+ };
64
82
  const SLASH_COMMAND_BEHAVIORS = {
65
83
  codex: codexSlashCommands,
66
84
  opencode: opencodeSlashCommands,
@@ -650,13 +668,8 @@ function acpRuntimeOptions(opts) {
650
668
  // the shim spawns, and Grok's prepare materializes `auth.json` from Bivy's
651
669
  // vault (pinning GROK_HOME) so `grok agent stdio` starts authenticated.
652
670
  ...(opts.behaviors?.preflight ? { preflight: PREFLIGHT_BEHAVIORS[opts.behaviors.preflight] } : {}),
653
- ...(opts.behaviors?.prepare === "grok-auth" && opts.credsDir
654
- ? {
655
- prepare: async () => {
656
- const home = await ensureGrokAuth(opts.credsDir);
657
- return home ? { GROK_HOME: home } : {};
658
- },
659
- }
671
+ ...(opts.behaviors?.prepare && opts.credsDir
672
+ ? { prepare: () => PREPARE_BEHAVIORS[opts.behaviors.prepare](opts.credsDir) }
660
673
  : {}),
661
674
  // OpenCode's own store is SQLite under $XDG_DATA_HOME, so an ACP-promoted
662
675
  // opencode can both read a resumed session's transcript back for history
@@ -665,6 +678,7 @@ function acpRuntimeOptions(opts) {
665
678
  // in that store (writeHistory → capabilities.forkHistoryImport → fidelity
666
679
  // "replayed" instead of a seeded summary prompt). Only opencode has this
667
680
  // layout; a bare ACP agent gets none of these hooks.
681
+ ...(opts.historyLoader ? { loadHistory: HISTORY_LOADERS[opts.historyLoader] } : {}),
668
682
  ...(opts.behaviors?.sessionStore === "opencode"
669
683
  ? {
670
684
  loadHistory: (sessionRef) => loadOpenCodeTranscript(sessionRef),
@@ -1145,6 +1159,7 @@ function makeCliRuntime(id, options, spec) {
1145
1159
  command: spec.command,
1146
1160
  agentArgs: spec.acp.args,
1147
1161
  behaviors: spec.behaviors,
1162
+ historyLoader: spec.resume?.historyLoader,
1148
1163
  ...(spec.authOwner && spec.authOwner !== "agent" ? { credsDir: options.credsDir } : {}),
1149
1164
  sandbox: options.sandbox,
1150
1165
  mcpConfig: options.mcpConfig,
@@ -1174,12 +1189,8 @@ function makeCliRuntime(id, options, spec) {
1174
1189
  // mixed auth and may materialize a Bivy-connected subscription; Codex and
1175
1190
  // OpenCode retain their native stores without credential replacement.
1176
1191
  const preflight = behaviors?.preflight ? PREFLIGHT_BEHAVIORS[behaviors.preflight] : undefined;
1177
- const prepare = behaviors?.prepare === "grok-auth"
1178
- ? async () => {
1179
- const home = await ensureGrokAuth(options.credsDir);
1180
- return home ? { GROK_HOME: home } : {};
1181
- }
1182
- : undefined;
1192
+ const prepareBehavior = behaviors?.prepare;
1193
+ const prepare = prepareBehavior ? () => PREPARE_BEHAVIORS[prepareBehavior](options.credsDir) : undefined;
1183
1194
  // Resume, the generic way. Codex keeps its verified path (rollout history +
1184
1195
  // tier-aware `codex exec resume <id> --json`). Every other CLI agent becomes
1185
1196
  // resumable purely as data: a spec.resume template (or a BIVY_<ID>_RESUME_
@@ -1199,11 +1210,7 @@ function makeCliRuntime(id, options, spec) {
1199
1210
  : resumeTemplate
1200
1211
  ? {
1201
1212
  resumable: true,
1202
- loadHistory: spec.resume?.historyLoader === "grok"
1203
- ? loadGrokTranscript
1204
- : spec.resume?.historyLoader === "gemini" || spec.resume?.historyLoader === "qwen"
1205
- ? (sessionId) => loadGeminiFamilyTranscript(spec.resume.historyLoader, sessionId)
1206
- : undefined,
1213
+ loadHistory: spec.resume?.historyLoader ? HISTORY_LOADERS[spec.resume.historyLoader] : undefined,
1207
1214
  // `{sandbox}` expands to that agent's native containment flags for
1208
1215
  // the tier (e.g. Gemini/Qwen's `--approval-mode <mode>`) — a whole
1209
1216
  // token, not a string substitution, since it can be multiple argv
@@ -1229,7 +1236,7 @@ function makeCliRuntime(id, options, spec) {
1229
1236
  sessionDiscovery: true,
1230
1237
  listDiskSessions: () => {
1231
1238
  if (nativeSessionBehavior === "grok") {
1232
- return listGrokSessions().map((s) => ({
1239
+ return listGrokSessions().filter((s) => !s.subagent).map((s) => ({
1233
1240
  id: s.id,
1234
1241
  path: s.dir,
1235
1242
  cwd: s.cwd,
@@ -0,0 +1,54 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Agents mark the shells they run with variables that say "you are inside my
5
+ // session". A node started from such a shell (an agent ran `bivy`, a dev node,
6
+ // a setup script) inherits them, and every agent the node launches would think
7
+ // it is a child of that session. Claude Code then stops saving its transcript
8
+ // (CLAUDE_CODE_CHILD_SESSION), which history, takeover and fork read. The node
9
+ // itself is never part of a session, so it drops these once at startup.
10
+ //
11
+ // Exact names, not prefixes: CLAUDE_CONFIG_DIR, CLAUDE_CODE_OAUTH_TOKEN and
12
+ // similar are the user's own configuration and must reach the agents.
13
+ /** Variables an agent sets in its own shells to mark its session. */
14
+ export const INHERITED_SESSION_MARKERS = [
15
+ // Claude Code (and the Claude Agent SDK)
16
+ "CLAUDECODE",
17
+ "CLAUDE_CODE_CHILD_SESSION",
18
+ "CLAUDE_CODE_SESSION_ID",
19
+ "CLAUDE_CODE_SESSION_ATTENDED",
20
+ "CLAUDE_CODE_ENTRYPOINT",
21
+ "CLAUDE_CODE_EXECPATH",
22
+ "CLAUDE_CODE_MESSAGING_SOCKET",
23
+ "CLAUDE_CODE_MESSAGING_TOKEN",
24
+ "CLAUDE_AGENT_SDK_VERSION",
25
+ "CLAUDE_PID",
26
+ "CLAUDE_EFFORT",
27
+ // Codex
28
+ "CODEX_SESSION_ID",
29
+ "CODEX_THREAD_ID",
30
+ "CODEX_CI",
31
+ // OpenCode
32
+ "OPENCODE",
33
+ "OPENCODE_PID",
34
+ // Grok
35
+ "GROK_AGENT",
36
+ "GROK_SESSION_ID",
37
+ // Generic "run by an agent" markers
38
+ "AGENT",
39
+ "AI_AGENT",
40
+ // Bivy's own per-session identity (see session-env.ts)
41
+ "BIVY_SESSION_ID",
42
+ "BIVY_SESSION_TOKEN",
43
+ ];
44
+ /** Remove inherited session markers from `env` in place; returns the names removed. */
45
+ export function scrubInheritedSessionEnv(env = process.env) {
46
+ const removed = [];
47
+ for (const name of INHERITED_SESSION_MARKERS) {
48
+ if (env[name] === undefined)
49
+ continue;
50
+ delete env[name];
51
+ removed.push(name);
52
+ }
53
+ return removed;
54
+ }
@@ -10,9 +10,15 @@
10
10
  // (Pi) rejected the bare form and the session silently kept its default model.
11
11
  // Resolving against the session's own catalog makes every spelling work for any
12
12
  // runtime without per-agent code.
13
+ /** Bivy provider ids another agent knows under a different name: a ChatGPT
14
+ * plan is `openai-codex` in Bivy and Pi, but plain `openai` in OpenCode. */
15
+ const PROVIDER_EQUIVALENTS = {
16
+ "openai-codex": "openai",
17
+ };
13
18
  /**
14
19
  * Resolve `ref` against `models`. An explicit provider is kept as-is. Otherwise
15
- * match, in order: the exact id, a `provider/id` split, then an id that ends in
20
+ * match, in order: the exact id, a `provider/id` split, the same split under
21
+ * the provider's name in this agent (PROVIDER_EQUIVALENTS), then an id that ends in
16
22
  * `/<ref.id>` (a bare name for an ACP slash id). Ties prefer the current
17
23
  * model's provider. Unknown refs are returned unchanged so the runtime reports
18
24
  * its own error.
@@ -25,9 +31,16 @@ export function resolveModelRef(models, ref, current) {
25
31
  const tiers = [
26
32
  models.filter((m) => m.id === id),
27
33
  slash > 0 ? models.filter((m) => m.provider === id.slice(0, slash) && m.id === id.slice(slash + 1)) : [],
34
+ slash > 0 ? providerEquivalent(models, id.slice(0, slash), id.slice(slash + 1)) : [],
28
35
  models.filter((m) => m.id.endsWith(`/${id}`)),
29
36
  ];
30
37
  const candidates = tiers.find((tier) => tier.length) ?? [];
31
38
  const pick = candidates.find((m) => m.provider === current?.provider) ?? candidates[0];
32
39
  return pick ? { provider: pick.provider, id: pick.id } : ref;
33
40
  }
41
+ function providerEquivalent(models, provider, id) {
42
+ const other = PROVIDER_EQUIVALENTS[provider];
43
+ if (!other)
44
+ return [];
45
+ return models.filter((m) => m.id === `${other}/${id}` || (m.provider === other && m.id === id));
46
+ }
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // OpenCode credential projection.
4
+ //
5
+ // A subscription a user connects inside Bivy (a ChatGPT plan, stored in the
6
+ // vault as `openai-codex`) should serve OpenCode too, without a separate
7
+ // `opencode auth login`. OpenCode reads its logins from `auth.json`, or, when
8
+ // set, from OPENCODE_AUTH_CONTENT, which REPLACES that file for the process. So
9
+ // the projection is the user's own auth.json plus any vault subscription it
10
+ // lacks, handed over in that env var. The user's file is never written and
11
+ // their own logins always win.
12
+ //
13
+ // The refresh token is withheld. OpenAI rotates it on every grant, so an
14
+ // OpenCode refresh would invalidate the vault's copy for Codex and Pi. OpenCode
15
+ // gets the access token Bivy keeps fresh; a later launch projects a new one.
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { createCredentialVault } from "./credential-store.js";
20
+ import { refreshExpiringOAuth } from "./credential-provisioning.js";
21
+ /** Vault OAuth provider -> the provider key OpenCode's auth.json uses for it. */
22
+ const OPENCODE_SUBSCRIPTIONS = [
23
+ { vault: "openai-codex", opencode: "openai" },
24
+ ];
25
+ /** OpenCode's auth.json, resolved the way the CLI resolves it. */
26
+ export function opencodeAuthPath(env = process.env) {
27
+ const data = env.XDG_DATA_HOME?.trim() || (process.platform === "win32" ? env.APPDATA?.trim() : "") || path.join(os.homedir(), ".local", "share");
28
+ return path.join(data, "opencode", "auth.json");
29
+ }
30
+ function readJsonObject(file) {
31
+ try {
32
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
33
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
34
+ }
35
+ catch {
36
+ return {};
37
+ }
38
+ }
39
+ /**
40
+ * `{ OPENCODE_AUTH_CONTENT }` carrying the user's OpenCode logins plus each
41
+ * Bivy-connected subscription they don't already have, or `{}` when there is
42
+ * nothing to add (OpenCode then reads its own file as usual).
43
+ */
44
+ export async function opencodeAuthEnv(credsDir, authPath = opencodeAuthPath()) {
45
+ const native = readJsonObject(authPath);
46
+ const missing = OPENCODE_SUBSCRIPTIONS.filter((row) => !native[row.opencode]);
47
+ if (!missing.length)
48
+ return {};
49
+ await refreshExpiringOAuth(credsDir).catch(() => undefined);
50
+ const vault = createCredentialVault(credsDir);
51
+ const projected = {};
52
+ for (const row of missing) {
53
+ const cred = (await vault.read(row.vault).catch(() => undefined));
54
+ if (cred?.type !== "oauth" || typeof cred.access !== "string" || !cred.access)
55
+ continue;
56
+ projected[row.opencode] = {
57
+ type: "oauth",
58
+ access: cred.access,
59
+ refresh: "",
60
+ expires: typeof cred.expires === "number" ? cred.expires : 0,
61
+ ...(typeof cred.accountId === "string" && cred.accountId ? { accountId: cred.accountId } : {}),
62
+ };
63
+ }
64
+ if (!Object.keys(projected).length)
65
+ return {};
66
+ return { OPENCODE_AUTH_CONTENT: JSON.stringify({ ...native, ...projected }) };
67
+ }
@@ -40,6 +40,15 @@ function hasNativeCredential(provider, ctx) {
40
40
  return false;
41
41
  }
42
42
  }
43
+ /** A login Bivy projected for this launch (OPENCODE_AUTH_CONTENT, see opencode-auth.ts). */
44
+ function hasProjectedCredential(provider, env) {
45
+ try {
46
+ return Boolean(JSON.parse(env.OPENCODE_AUTH_CONTENT ?? "{}")[provider]);
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
43
52
  /** Actionable message shown when OpenCode's selected provider has no credential. */
44
53
  export function opencodeNoCredentialMessage(provider, envVar) {
45
54
  const label = provider.charAt(0).toUpperCase() + provider.slice(1);
@@ -69,7 +78,7 @@ export function opencodeCredentialPreflight(env, ctx = {}) {
69
78
  if (provider === "anthropic" && env.CLAUDE_CODE_OAUTH_TOKEN?.trim())
70
79
  return undefined;
71
80
  const envVar = apiKeyEnvVar(provider);
72
- if (env[envVar]?.trim() || hasNativeCredential(provider, ctx))
81
+ if (env[envVar]?.trim() || hasNativeCredential(provider, ctx) || hasProjectedCredential(provider, env))
73
82
  return undefined;
74
83
  return opencodeNoCredentialMessage(provider, envVar);
75
84
  }
@@ -400,9 +400,12 @@ class ProtocolSession {
400
400
  });
401
401
  this.child = child;
402
402
  child.stdout.on("data", (chunk) => this.onData(chunk.toString("utf8")));
403
+ // A protocol agent reports its errors in-band (session.error), so its stderr
404
+ // is diagnostics: shim logs and the agent's own request dumps, which in the
405
+ // transcript read as a broken tool card. Keep it for when the shim dies
406
+ // without a word (see "close" below).
403
407
  child.stderr.on("data", (chunk) => {
404
408
  this.stderrOutput += chunk.toString("utf8");
405
- this.emit({ type: "tool_execution_update", toolName: "agent_output", toolCallId: "agent-output", input: { stream: "stderr", output: stripAnsi(this.stderrOutput.slice(-4000)) } });
406
409
  });
407
410
  // The agent's stdin pipe can break (EPIPE) when the shim exits mid-turn — for
408
411
  // example a dispose()/abort() racing an in-flight write (a tool.decision reply,
@@ -431,6 +434,10 @@ class ProtocolSession {
431
434
  if (this.child === child)
432
435
  this.markChildGone();
433
436
  this.failAll(new Error(`Protocol agent exited (${code ?? signal ?? "unknown"})`));
437
+ const stderr = stripAnsi(this.stderrOutput.slice(-4000)).trim();
438
+ if (typeof code === "number" && code !== 0 && stderr) {
439
+ this.emit({ type: "tool_execution_update", toolName: "agent_output", toolCallId: "agent-output", input: { stream: "stderr", output: stderr } });
440
+ }
434
441
  this.emit({ type: "agent_end", code, signal });
435
442
  });
436
443
  await new Promise((resolve, reject) => {
@@ -597,6 +604,23 @@ class ProtocolSession {
597
604
  this.messages[this.turnMessageIndex] = { ...this.messages[this.turnMessageIndex], content };
598
605
  }
599
606
  }
607
+ /** Persist the live turn's content so far (prose, tools, their results) and
608
+ * commit it on clients, so a steering message can follow it in order. */
609
+ sealStreamedSoFar() {
610
+ const hadTools = this.turnContent.some((b) => b.type !== "thinking") || this.turnToolResults.length > 0;
611
+ if (hadTools) {
612
+ this.flushPendingTurnText();
613
+ if (this.turnContent.length)
614
+ this.snapshotAssistantTurn([...this.turnContent]);
615
+ if (this.turnToolResults.length)
616
+ this.messages.push({ role: "user", content: this.turnToolResults, timestamp: Date.now() });
617
+ }
618
+ else if (this.assistantText) {
619
+ this.snapshotAssistantTurn(this.assistantText);
620
+ }
621
+ if (this.assistantText)
622
+ this.emit({ type: "message_boundary", message: { role: "assistant", content: this.assistantText } });
623
+ }
600
624
  /**
601
625
  * Fold assistant text that arrives after the turn was sealed (session.done)
602
626
  * onto the last persisted assistant message — the ACP end_turn race (see the
@@ -786,6 +810,9 @@ class ProtocolSession {
786
810
  }
787
811
  this.emit({ type: "message_end", message });
788
812
  this.streaming = false;
813
+ // The sealed turn owns its message slot. Anything that streams after this
814
+ // (a late tool or a turn the host never opened) must not overwrite it.
815
+ this.turnMessageIndex = undefined;
789
816
  this.assistantText = "";
790
817
  this.assistantItemBoundary = false;
791
818
  this.reasoningText = "";
@@ -821,6 +848,7 @@ class ProtocolSession {
821
848
  const errored = { role: "assistant", content: "", stopReason: "error", errorMessage: errorText, timestamp: Date.now() };
822
849
  this.messages.push(errored);
823
850
  this.streaming = false;
851
+ this.turnMessageIndex = undefined;
824
852
  this.assistantText = "";
825
853
  this.assistantItemBoundary = false;
826
854
  this.reasoningText = "";
@@ -1034,6 +1062,12 @@ class ProtocolSession {
1034
1062
  catch {
1035
1063
  textToSend = prompt;
1036
1064
  }
1065
+ // A message sent while a turn is still streaming steers into it. Keep what
1066
+ // the agent already said as its own message, before this one: resetting the
1067
+ // turn here dropped that text, and the rest of the reply then ran into the
1068
+ // answer to this message as one bubble.
1069
+ if (this.streaming)
1070
+ this.sealStreamedSoFar();
1037
1071
  this.messages.push({ role: "user", content: prompt, timestamp: Date.now() });
1038
1072
  this.streaming = true;
1039
1073
  this.turnMessageIndex = undefined;