@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
@@ -190,18 +190,57 @@ export function mapToolCall(toolName, input, context = {}) {
190
190
  return decorate({ kind: "plan", ...(str(o, "plan", "text", "content", "message") ? { text: str(o, "plan", "text", "content", "message") } : {}) }, toolName, input, context);
191
191
  }
192
192
  if (inToolSet(DELEGATE, toolName, key)) {
193
- const label = str(o, "subagent_type", "subagentType", "agent", "agentType", "role", "name");
193
+ const label = str(o, "subagent_type", "subagentType", "agent", "agentType", "role", "name", "task_name", "taskName");
194
194
  const description = str(o, "description", "task", "prompt", "instructions", "goal", "message");
195
195
  return decorate({ kind: "delegation", ...(label ? { label } : {}), ...(description ? { description } : {}) }, toolName, input, context);
196
196
  }
197
197
  return undefined;
198
198
  }
199
+ /** The text of MCP-style content blocks (`[{type:"text",text}]`), the shape
200
+ * Claude, Pi and most MCP tools return. Undefined when there is no text block,
201
+ * so image-only or unknown payloads keep their JSON form. */
202
+ function blockText(content) {
203
+ if (!Array.isArray(content))
204
+ return undefined;
205
+ const texts = content
206
+ .map((block) => asRecord(block))
207
+ .filter((block) => block.type === "text" && typeof block.text === "string")
208
+ .map((block) => block.text);
209
+ return texts.length ? texts.join("\n") : undefined;
210
+ }
211
+ /** Harness frames an agent wraps around a sub-agent's report before handing it
212
+ * to the model. The user should read the report, not the frame. Each row
213
+ * matches the frame's opening (and closing, if any) and how the report inside
214
+ * is indented. */
215
+ const SUBAGENT_REPORT_FRAMES = [
216
+ // Claude Code 2.1.28x: "[Subagent hand-back] … The report follows:\n <report>"
217
+ { header: /^\[Subagent hand-back\][^\n]*The report follows:\n/, indent: " " },
218
+ // OpenCode's task tool: <task id=… state=…>\n<task_result>\n<report>\n</task_result>\n</task>
219
+ { header: /^<task\b[^>\n]*>\s*<task_result>\n?/, footer: /\n?<\/task_result>\s*<\/task>\s*$/, indent: "" },
220
+ ];
221
+ function unwrapSubagentReport(text) {
222
+ for (const frame of SUBAGENT_REPORT_FRAMES) {
223
+ const match = frame.header.exec(text);
224
+ if (!match)
225
+ continue;
226
+ const inner = text.slice(match[0].length);
227
+ const body = frame.footer ? inner.replace(frame.footer, "") : inner;
228
+ if (!frame.indent)
229
+ return body;
230
+ return body
231
+ .split("\n")
232
+ .map((line) => (line.startsWith(frame.indent) ? line.slice(frame.indent.length) : line))
233
+ .join("\n");
234
+ }
235
+ return text;
236
+ }
199
237
  export function mapToolResult(result, isError = false) {
200
238
  const o = asRecord(result);
239
+ const structured = asRecord(o.structuredContent ?? o.details);
201
240
  const content = o.content ?? o.output ?? o.text ?? o.aggregated_output ?? result;
202
- const rawText = typeof content === "string" ? content : content == null ? "" : JSON.stringify(boundedToolPayload(content));
241
+ const rawText = unwrapSubagentReport(typeof content === "string" ? content : content == null ? "" : blockText(content) ?? JSON.stringify(boundedToolPayload(content)));
203
242
  const text = rawText.length > RAW_LIMIT ? rawText.slice(0, RAW_LIMIT) : rawText;
204
- const exit = o.exitCode ?? o.exit_code ?? o.code;
243
+ const exit = o.exitCode ?? o.exit_code ?? o.code ?? structured.exitCode ?? structured.exit_code;
205
244
  return {
206
245
  ...(text ? { text } : {}),
207
246
  ...(typeof exit === "number" ? { exitCode: exit } : {}),
@@ -209,3 +248,28 @@ export function mapToolResult(result, isError = false) {
209
248
  ...(rawText.length > RAW_LIMIT ? { truncated: true } : {}),
210
249
  };
211
250
  }
251
+ /**
252
+ * Fill in `detail` on tool_use blocks that lack it. Live tool events carry a
253
+ * detail, but a transcript reloaded from an agent's own store (a native session
254
+ * taken over, a reopened rollout) has bare blocks, which the client can only
255
+ * label generically. Messages without such blocks are returned as they were.
256
+ */
257
+ export function withToolDetails(messages, provider = "unknown") {
258
+ return messages.map((message) => {
259
+ const content = message?.content;
260
+ if (!Array.isArray(content))
261
+ return message;
262
+ let changed = false;
263
+ const next = content.map((block) => {
264
+ const b = block;
265
+ if (b?.type !== "tool_use" || b.detail || typeof b.name !== "string")
266
+ return block;
267
+ const detail = mapToolCall(b.name, b.input, { provider, protocol: "unknown" });
268
+ if (!detail)
269
+ return block;
270
+ changed = true;
271
+ return { ...b, detail };
272
+ });
273
+ return changed ? { ...message, content: next } : message;
274
+ });
275
+ }
package/dist/server.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
+ // First: drop agent-session markers the node inherited before anything reads them.
4
+ import "./node-env.js";
3
5
  import fs from "node:fs";
4
6
  import { createNodeUpdateChecker, managedInstall, updateRegistryUrl } from "./node-update.js";
5
7
  import { createRemoteSessionAdmission, RemoteSessionAdmissionError } from "./session/remote-session-admission.js";
@@ -69,7 +71,7 @@ import { isNativeOAuthProvider, loginModelOAuth } from "./runtime/oauth/model-oa
69
71
  import { decideOAuthLoginSweep } from "./runtime/oauth/oauth-login-sweep.js";
70
72
  import { listCodexSessions, loadCodexTranscript } from "./runtime/codex-sessions.js";
71
73
  import { dedupeSessionSummaries } from "./session-identity.js";
72
- import { listNativePiSessions } from "./agents/pi/integration.js";
74
+ import { listNativePiSessions, piAgentDir } from "./agents/pi/integration.js";
73
75
  import { deriveSessionState } from "./session/session-state.js";
74
76
  import { resolveStreamingBehavior } from "./session/record.js";
75
77
  import { createSessionEngine } from "./session/engine.js";
@@ -149,7 +151,7 @@ import { MetadataStore } from "./metadata.js";
149
151
  import { resolveResumeRef, resumeRefFor, storedResumeRef } from "./session-ref.js";
150
152
  import { materializeFork } from "./session/fork.js";
151
153
  import { applyDirtyPatch } from "./session/fork-dirty.js";
152
- import { normalizeMessages } from "./session/transcript-normal.js";
154
+ import { lastTranscriptModel, normalizeMessages } from "./session/transcript-normal.js";
153
155
  import { buildNativeImportSeedPrompt } from "./session/native-import.js";
154
156
  import { EventLog } from "./session/event-log.js";
155
157
  import { SessionEventSequencer } from "./session/event-sequencer.js";
@@ -281,6 +283,9 @@ const piDir = path.join(appDir, "pi");
281
283
  // the plaintext auth.json its native TUI reads, and its sessions.
282
284
  const credsDir = path.join(appDir, "credentials");
283
285
  const sessionsDir = path.join(piDir, "sessions");
286
+ // Where a path-based session may be resumed from: Bivy's own Pi sessions, and
287
+ // the native Pi store `bivy run pi` writes to (taken over or opened as chat).
288
+ const resumeRoots = [sessionsDir, path.join(piAgentDir(), "sessions")];
284
289
  const intermediateMessagesDir = path.join(appDir, "intermediate-messages");
285
290
  const toolActivitiesDir = path.join(appDir, "tool-activities");
286
291
  // One append-only per-session log.
@@ -5189,6 +5194,13 @@ async function reconcileInterruptedSessions() {
5189
5194
  const record = await resolveOrResumeSession(id, meta.path);
5190
5195
  if (!record)
5191
5196
  continue; // transcript gone / unresolvable — nothing to resume
5197
+ // "Pick up where you left off" needs a conversation to pick up. A native
5198
+ // terminal session whose agent kept no transcript reopens empty, and the
5199
+ // prompt would only start a confused turn with no context.
5200
+ if (!transcripts.conversationMessages(record).length) {
5201
+ console.log(`[resume] skipped ${id}: no conversation to resume`);
5202
+ continue;
5203
+ }
5192
5204
  await runSessionTurn(record, buildInteractiveResumePrompt());
5193
5205
  }
5194
5206
  catch (error) {
@@ -6446,9 +6458,14 @@ async function applyRequestedModel(record, model) {
6446
6458
  if (!model)
6447
6459
  return;
6448
6460
  try {
6461
+ // Protocol agents only publish their catalog once running. A fork or a new
6462
+ // session asks for its model before that, so warm the catalog first or the
6463
+ // choice is dropped and the agent's default model answers instead.
6464
+ if (!(await record.session.getModels()).length && record.session.warmModels) {
6465
+ await record.session.warmModels().catch(() => { });
6466
+ }
6449
6467
  // A provider-less id (CLI --model, `bivy-model:`) binds against the
6450
- // session's catalog. Protocol agents only publish theirs once running, so
6451
- // warm it when the first lookup can't place the id.
6468
+ // session's catalog; warm it when the first lookup can't place the id.
6452
6469
  if (!model.provider) {
6453
6470
  const resolve = async () => resolveModelRef(await record.session.getModels(), model, record.session.getCurrentModel());
6454
6471
  model = await resolve();
@@ -8399,7 +8416,7 @@ function fastHistoryEvent(msg) {
8399
8416
  if (!ref)
8400
8417
  return null;
8401
8418
  const runtimeId = agentFrom(msg) ?? meta?.runtimeId;
8402
- const file = resolveResumeRef({ ref, resumesByPath: runtimeResumesByPath(runtimeId), sessionsDir });
8419
+ const file = resolveResumeRef({ ref, resumesByPath: runtimeResumesByPath(runtimeId), sessionsDir: resumeRoots });
8403
8420
  const rt = getRuntime(runtimeId);
8404
8421
  // Prefer the runtime's build-free read (pi/claude-code); otherwise use the
8405
8422
  // transcript Bivy persisted itself, so process agents (Codex, generic CLIs)
@@ -8619,7 +8636,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8619
8636
  // refs could likewise resume the wrong id. Explicit path refs remain unchanged.
8620
8637
  const durableRef = requestedRef ? storedResumeRef(requestedRef, requestedMeta) : undefined;
8621
8638
  const requestedSessionFile = durableRef
8622
- ? resolveResumeRef({ ref: durableRef, resumesByPath: runtimeResumesByPath(refRuntimeId), sessionsDir })
8639
+ ? resolveResumeRef({ ref: durableRef, resumesByPath: runtimeResumesByPath(refRuntimeId), sessionsDir: resumeRoots })
8623
8640
  : undefined;
8624
8641
  const storedMeta = requestedSessionFile ? metadata.getSession(requestedSessionFile) : undefined;
8625
8642
  const restoredWorktree = requestedSessionFile ? restoredWorktreeFromMetadata(storedMeta) : undefined;
@@ -8860,6 +8877,13 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
8860
8877
  // Best-effort; never block session creation.
8861
8878
  }
8862
8879
  }
8880
+ // A resumed session with no model of its own keeps the one its transcript
8881
+ // last answered with, instead of silently switching to the agent's default.
8882
+ if (requestedSessionFile && !record.session.getCurrentModel()) {
8883
+ const model = lastTranscriptModel(record.session.getMessages());
8884
+ if (model)
8885
+ await record.session.setModel(model.provider, model.id).catch(() => undefined);
8886
+ }
8863
8887
  rememberSession(record);
8864
8888
  rememberWorkspace(sessionWorkspace);
8865
8889
  attachSessionListeners(record);
@@ -8938,7 +8962,7 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
8938
8962
  const resumesByPath = runtimeResumesByPath(runtimeId);
8939
8963
  let key;
8940
8964
  try {
8941
- key = resolveResumeRef({ ref, resumesByPath, sessionsDir });
8965
+ key = resolveResumeRef({ ref, resumesByPath, sessionsDir: resumeRoots });
8942
8966
  }
8943
8967
  catch (error) {
8944
8968
  // The sessions-dir path-traversal guard rejected this ref (path-based
@@ -9293,13 +9317,23 @@ const forkStandUp = createForkStandUp({
9293
9317
  applyDirtyPatch,
9294
9318
  gitRepoRoot,
9295
9319
  materializeFork,
9296
- saveForkTranscript: (markdown) => {
9297
- // Outside the worktree, so the hand-off never shows up as a repo change.
9298
- const dir = path.join(appDir, "fork-transcripts");
9299
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
9300
- const file = path.join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}.md`);
9301
- fs.writeFileSync(file, markdown, { mode: 0o600 });
9302
- return file;
9320
+ saveForkTranscript: (markdown, cwd) => {
9321
+ // In the fork's own `.bivy/` (git-excluded, like its worktrees), so the
9322
+ // agent can read it even when sandboxed to its workspace, and it never
9323
+ // shows up as a repo change. The node's data dir is the fallback.
9324
+ const name = `fork-transcript-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}.md`;
9325
+ for (const dir of [path.join(cwd, ".bivy"), path.join(appDir, "fork-transcripts")]) {
9326
+ try {
9327
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
9328
+ const file = path.join(dir, name);
9329
+ fs.writeFileSync(file, markdown, { mode: 0o600 });
9330
+ return file;
9331
+ }
9332
+ catch {
9333
+ // try the next place
9334
+ }
9335
+ }
9336
+ return undefined;
9303
9337
  },
9304
9338
  getRuntime,
9305
9339
  listRuntimes,
@@ -10709,7 +10743,7 @@ app.get("/api/multiplexers", async (_req, res) => {
10709
10743
  // runtimes (`codex` or governed `codex-approvals`), while this endpoint stays a
10710
10744
  // safe read-side view plus a `codex resume <id>` terminal handoff.
10711
10745
  app.get("/api/codex/sessions", (_req, res) => {
10712
- const sessions = listCodexSessions().map((s) => ({
10746
+ const sessions = listCodexSessions().filter((s) => !s.parentThreadId).map((s) => ({
10713
10747
  id: s.id,
10714
10748
  cwd: s.cwd,
10715
10749
  createdAt: s.createdAt,
@@ -180,20 +180,91 @@ export function mergeBases(logged, runtime) {
180
180
  loggedTexts.set(text, (loggedTexts.get(text) ?? 0) - n);
181
181
  return true;
182
182
  };
183
+ // The runtime's order, as either a message the merged list already has (its
184
+ // anchor) or one only the runtime had (fresh). Placement below uses it.
185
+ const sequence = [];
186
+ let byAtom; // first logged message holding each atom, built on first use
187
+ const anchorOf = (m) => {
188
+ if (!byAtom) {
189
+ byAtom = new Map();
190
+ for (const other of merged) {
191
+ const theirs = messageContentAtoms(other);
192
+ for (const atom of [...theirs.toolIds, ...theirs.texts])
193
+ if (!byAtom.has(atom))
194
+ byAtom.set(atom, other);
195
+ }
196
+ }
197
+ const atoms = messageContentAtoms(m);
198
+ for (const atom of [...atoms.toolIds, ...atoms.texts]) {
199
+ const found = byAtom.get(atom);
200
+ if (found)
201
+ return found;
202
+ }
203
+ return undefined;
204
+ };
183
205
  for (const m of runtime) {
184
206
  const key = JSON.stringify(m);
185
- if (known.has(key))
207
+ if (known.has(key)) {
208
+ sequence.push({ anchor: merged[keys.indexOf(key)] });
186
209
  continue;
210
+ }
187
211
  const identity = identityOf(m);
188
212
  if (identity && identities.has(identity)) {
189
213
  add(m);
214
+ sequence.push({ anchor: m });
190
215
  continue;
191
216
  } // streaming refinement of a known turn
192
- if (coveredByLogged(m))
193
- continue; // reopen re-serialization already in the base
194
- add(m); // a genuinely new (resumed) turn
217
+ if (coveredByLogged(m)) { // reopen re-serialization already in the base
218
+ const anchor = anchorOf(m);
219
+ if (anchor)
220
+ sequence.push({ anchor });
221
+ continue;
222
+ }
223
+ if (add(m))
224
+ sequence.push({ fresh: m }); // not in the logged base
225
+ }
226
+ return placeFresh(merged, sequence);
227
+ }
228
+ /**
229
+ * Put messages only the runtime had where they happened, not at the end. A
230
+ * reload can carry turns or detail the logged base never stored (a native run
231
+ * taken over as a chat; a Codex rollout's tool calls under a text-only base):
232
+ * appended, they land after turns that came later. Each goes right before the
233
+ * next message the runtime shares with the base, else right after the previous
234
+ * one. With nothing shared to anchor to, its timestamp places it, and a message
235
+ * with neither (or a genuinely new turn) goes last.
236
+ */
237
+ function placeFresh(merged, sequence) {
238
+ const freshSet = new Set(sequence.flatMap((item) => ("fresh" in item ? [item.fresh] : [])));
239
+ if (!freshSet.size)
240
+ return merged;
241
+ const timeOf = (m) => {
242
+ const t = m.timestamp;
243
+ return typeof t === "number" && Number.isFinite(t) ? t : undefined;
244
+ };
245
+ const placed = merged.filter((m) => !freshSet.has(m));
246
+ let previous; // last anchor seen, or last fresh placed after it
247
+ for (let i = 0; i < sequence.length; i++) {
248
+ const item = sequence[i];
249
+ if ("anchor" in item) {
250
+ previous = item.anchor;
251
+ continue;
252
+ }
253
+ const next = sequence.slice(i + 1).find((later) => "anchor" in later)?.anchor;
254
+ let at;
255
+ if (previous && placed.includes(previous))
256
+ at = placed.indexOf(previous) + 1;
257
+ else if (next && placed.includes(next))
258
+ at = placed.indexOf(next);
259
+ else {
260
+ const t = timeOf(item.fresh);
261
+ const later = t === undefined ? -1 : placed.findIndex((other) => (timeOf(other) ?? -Infinity) > t);
262
+ at = later < 0 ? placed.length : later;
263
+ }
264
+ placed.splice(at, 0, item.fresh);
265
+ previous = item.fresh;
195
266
  }
196
- return merged;
267
+ return placed;
197
268
  }
198
269
  /** Content-block type carried by a folded outbound attachment. MUST match
199
270
  * `AGENT_ATTACHMENT_BLOCK` in packages/core/src/store-render.ts — the client's
@@ -202,7 +202,7 @@ export function createForkStandUp(deps) {
202
202
  }
203
203
  if (applied.warning)
204
204
  dirtyWarning = applied.warning;
205
- workspace = forkRepoRoot;
205
+ workspace = wt.repoRoot;
206
206
  cwd = wt.path;
207
207
  worktree = wt;
208
208
  }
@@ -1,4 +1,4 @@
1
- import { normalizeMessages, buildSeedPrompt, buildForkHistory, renderForkTranscript, } from "./transcript-normal.js";
1
+ import { normalizeMessages, buildSeedPrompt, buildForkHistory, relocateTranscript, renderForkTranscript, } from "./transcript-normal.js";
2
2
  /** Cap on the display transcript a bundle carries; larger sessions fall back to
3
3
  * showing the destination agent's own (text) replay. */
4
4
  export const FORK_DISPLAY_MAX_BYTES = 2 * 1024 * 1024;
@@ -91,6 +91,8 @@ export function resolveForkFidelity(bundle, targetRuntime) {
91
91
  export async function materializeFork(opts) {
92
92
  const { bundle, targetRuntime, ctx } = opts;
93
93
  const intended = resolveForkFidelity(bundle, targetRuntime);
94
+ // The portable history as it reads from the fork's own working directory.
95
+ const normalized = relocateTranscript(bundle.normalized, bundle.record.cwd, ctx.cwd);
94
96
  const validImport = (result) => typeof result?.sessionFile === "string" && result.sessionFile.trim().length > 0 &&
95
97
  typeof result.id === "string" && result.id.trim().length > 0;
96
98
  // Native transport is the preferred same-runtime route, but never a single
@@ -113,7 +115,7 @@ export async function materializeFork(opts) {
113
115
  typeof targetRuntime.importHistoryForFork === "function";
114
116
  if (canReplay) {
115
117
  try {
116
- const history = buildForkHistory(bundle.normalized);
118
+ const history = buildForkHistory(normalized);
117
119
  if (history.length > 0) {
118
120
  const imported = await targetRuntime.importHistoryForFork(history, ctx);
119
121
  if (validImport(imported))
@@ -128,12 +130,12 @@ export async function materializeFork(opts) {
128
130
  // its tools can read — an app URL is a web page it can't fetch.
129
131
  let transcriptFile;
130
132
  try {
131
- transcriptFile = opts.saveTranscript?.(renderForkTranscript(bundle.normalized));
133
+ transcriptFile = opts.saveTranscript?.(renderForkTranscript(normalized), ctx.cwd);
132
134
  }
133
135
  catch {
134
136
  transcriptFile = undefined;
135
137
  }
136
- const seedPrompt = buildSeedPrompt(bundle.normalized, {
138
+ const seedPrompt = buildSeedPrompt(normalized, {
137
139
  targetAgent: targetRuntime.displayName,
138
140
  context: { repoSlug: bundle.record.repoSlug, branch: bundle.record.branch, prUrl: bundle.record.prUrl },
139
141
  ...(transcriptFile ? { transcriptFile } : {}),
@@ -1,5 +1,36 @@
1
1
  import { handoffSeedOf } from "./handoff-seed.js";
2
2
  import { applyPlanUpdate, planChecklist, planUpdateOf } from "./plan.js";
3
+ function escapeRegExp(text) {
4
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
+ }
6
+ /**
7
+ * The transcript as it reads from a new working directory. A fork runs in its
8
+ * own copy of the workspace, but the history names files at the source's
9
+ * paths, and an agent that follows them edits the original checkout. Rewrite
10
+ * the source directory to the fork's wherever it appears as a path.
11
+ */
12
+ export function relocateTranscript(transcript, from, to) {
13
+ const source = from?.replace(/\/+$/, "");
14
+ const target = to?.replace(/\/+$/, "");
15
+ if (!source || !target || source === target)
16
+ return transcript;
17
+ const pattern = new RegExp(`${escapeRegExp(source)}(?=$|[/\\s"'\`)\\],:;])`, "g");
18
+ const move = (text) => text.replace(pattern, target);
19
+ return {
20
+ ...transcript,
21
+ turns: transcript.turns.map((turn) => ({
22
+ ...turn,
23
+ text: move(turn.text),
24
+ ...(turn.toolSummary ? { toolSummary: move(turn.toolSummary) } : {}),
25
+ })),
26
+ ...(transcript.plan ? { plan: transcript.plan.map((entry) => ({ ...entry, text: move(entry.text) })) } : {}),
27
+ workspace: { from: source, to: target },
28
+ };
29
+ }
30
+ /** One line telling the new agent where the work lives now. */
31
+ function workspaceNote(workspace) {
32
+ return `This conversation now continues in ${workspace.to}, a separate copy of ${workspace.from} where the earlier work happened. Paths above already point at the copy; work there.`;
33
+ }
3
34
  /** Cap a tool payload down to a short, log-safe one-liner. */
4
35
  function compactValue(value, max = 200) {
5
36
  let s;
@@ -209,6 +240,7 @@ export function buildSeedPrompt(transcript, opts = {}) {
209
240
  opts.context?.repoSlug ? `Repository: ${opts.context.repoSlug}` : null,
210
241
  opts.context?.branch ? `Branch: ${opts.context.branch}` : null,
211
242
  opts.context?.prUrl ? `PR: ${opts.context.prUrl}` : null,
243
+ transcript.workspace ? workspaceNote(transcript.workspace) : null,
212
244
  "",
213
245
  omitted > 0
214
246
  ? `Recent conversation (most recent last; ${omitted} earlier turn${omitted === 1 ? "" : "s"} omitted — see ${where}):`
@@ -274,6 +306,9 @@ export function buildForkHistory(transcript) {
274
306
  if (transcript.plan?.length && lastReply >= 0) {
275
307
  history[lastReply].text += `\n\n[plan] Where the plan stands:\n${planChecklist(transcript.plan)}`;
276
308
  }
309
+ if (transcript.workspace && lastReply >= 0) {
310
+ history[lastReply].text += `\n\n[workspace] ${workspaceNote(transcript.workspace)}`;
311
+ }
277
312
  return history;
278
313
  }
279
314
  /**
@@ -291,3 +326,20 @@ export function renderForkTranscript(transcript) {
291
326
  const turns = buildForkHistory(transcript).map((turn) => `## ${turn.role === "user" ? "User" : "Assistant"}\n\n${turn.text}`);
292
327
  return [...header, "", ...turns].join("\n\n").replace(/\n{3,}/g, "\n\n") + "\n";
293
328
  }
329
+ /**
330
+ * The model the agent last answered with, as its own transcript records it
331
+ * (`model`, and `provider` when known, on an assistant message). A session
332
+ * resumed from a native transcript (a `bivy run <agent> --model …` taken over
333
+ * in chat) should keep that model, not fall back to the agent's default.
334
+ */
335
+ export function lastTranscriptModel(messages) {
336
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
337
+ const message = messages[i];
338
+ if (message?.role !== "assistant" || message.bivyKind)
339
+ continue;
340
+ if (typeof message.model === "string" && message.model.trim()) {
341
+ return { provider: typeof message.provider === "string" ? message.provider : "", id: message.model.trim() };
342
+ }
343
+ }
344
+ return undefined;
345
+ }
@@ -19,6 +19,7 @@ import { mergeBases } from "./event-log.js";
19
19
  import { thinkingTextFromContent } from "./transcript-merge.js";
20
20
  import { extractInlineImageUrls, fetchInlineImage, isFetchImageError, inlineImageDisplayName, assistantTextForImageScan } from "./inline-image-fetch.js";
21
21
  import { historyDelta } from "../history-sync.js";
22
+ import { withToolDetails } from "../runtime/tool-call-map.js";
22
23
  // Display-only intermediate reasoning is persisted as a length-capped HEAD so a
23
24
  // runaway/looping agent can't grow the append-only log without bound. HEAD (not
24
25
  // tail): once capped, the persisted text stops changing, so the skip-when-
@@ -75,6 +76,11 @@ export function createTranscriptPersistence(deps) {
75
76
  const { eventLog, attachmentStore } = deps;
76
77
  const project = createClientProjection(attachmentStore, eventLog);
77
78
  const liveIntermediateBySession = new Map();
79
+ // `${sessionId}\0${callId}` -> what the call's first event recorded. Runtimes
80
+ // stream progress as updates that carry only partial output (Pi's bash), so
81
+ // an update without its own detail/parent keeps the call's, rather than
82
+ // overwriting the persisted card with a generic one.
83
+ const toolCallOverlay = new Map();
78
84
  const lastPersistedIntermediateText = new Map();
79
85
  // In-flight dedupe + failure cooldown so a repeated remote image URL only ever
80
86
  // triggers one fetch and a broken URL isn't retried every message_end.
@@ -135,12 +141,19 @@ export function createTranscriptPersistence(deps) {
135
141
  if (!["tool_call", "tool_execution_start", "tool_execution_update", "tool_execution_end", "tool_result", "function_call", "function_result"].includes(type))
136
142
  return;
137
143
  const callId = toolEventId(event);
144
+ // A turn-level failure some runtimes report on the tool channel (Claude's
145
+ // non-success `result`) names no tool and no call: it is not tool activity,
146
+ // and persisting it left a blank error card keyed "tool:".
147
+ if (callId === "tool:" && !event.toolName && !event.name)
148
+ return;
138
149
  const toolCall = event.toolCall;
139
150
  const input = (event.input || event.toolInput || event.args || toolCall?.input || {});
140
151
  const name = String(event.toolName || event.name || toolCall?.name || "tool");
141
152
  const now = Date.now();
142
153
  const base = { role: "assistant", bivyKind: "tool", afterMessageCount: record.session.getMessages().length, createdAt: now };
154
+ const overlayKey = `${record.id}\0${callId}`;
143
155
  if (type === "tool_result" || type === "tool_execution_end" || type === "function_result") {
156
+ toolCallOverlay.delete(overlayKey);
144
157
  eventLog.append(record.id, { ...base, id: `bivy-tool-result-${callId}`, content: [{ type: "tool_result", toolUseId: callId, tool_use_id: callId, content: event.message ?? event.result ?? event.output ?? input.output ?? "", isError: Boolean(event.error || event.errorMessage), ...(event.detail ? { detail: event.detail } : {}) }] });
145
158
  }
146
159
  else if (isProgressOnlyPing(type, event.detail, input)) {
@@ -163,8 +176,11 @@ export function createTranscriptPersistence(deps) {
163
176
  // `parentToolUseId` (Claude, protocol agents) or `parentToolCallId` (Pi's
164
177
  // nested calls from codemode / ctx.executeTool).
165
178
  const rawParent = event.parentToolUseId ?? event.parentToolCallId;
166
- const parentToolUseId = typeof rawParent === "string" && rawParent ? rawParent : undefined;
167
- eventLog.append(record.id, { ...base, id: `bivy-tool-call-${callId}`, content: [{ type: "tool_use", id: callId, name, input, ...(parentToolUseId ? { parentToolUseId } : {}), ...(event.detail ? { detail: event.detail } : {}) }] });
179
+ const prior = toolCallOverlay.get(overlayKey);
180
+ const parentToolUseId = typeof rawParent === "string" && rawParent ? rawParent : prior?.parentToolUseId;
181
+ const detail = event.detail ?? prior?.detail;
182
+ toolCallOverlay.set(overlayKey, { detail, parentToolUseId });
183
+ eventLog.append(record.id, { ...base, id: `bivy-tool-call-${callId}`, content: [{ type: "tool_use", id: callId, name, input, ...(parentToolUseId ? { parentToolUseId } : {}), ...(detail ? { detail } : {}) }] });
168
184
  }
169
185
  }
170
186
  function persistIntermediateFromEvent(record, event, final = false) {
@@ -212,7 +228,8 @@ export function createTranscriptPersistence(deps) {
212
228
  return eventLog.deriveBase(record.id, record.session.getMessages());
213
229
  }
214
230
  function buildHistoryEvent(opts) {
215
- const messages = opts.sessionId ? project(opts.sessionId, opts.messages) : opts.messages;
231
+ const described = withToolDetails(opts.messages, opts.runtimeId);
232
+ const messages = opts.sessionId ? project(opts.sessionId, described) : described;
216
233
  const delta = historyDelta(messages, opts.cursor);
217
234
  const record = opts.sessionId ? deps.getOpenSession(opts.sessionId) : undefined;
218
235
  const bSess = record ? deps.bivySessionEnvelope(record) : undefined;
@@ -31,11 +31,14 @@ export function sessionIdFromRef(ref) {
31
31
  * Throws for a path-based ref that escapes `sessionsDir`.
32
32
  */
33
33
  export function resolveResumeRef(opts) {
34
- const { ref, resumesByPath, sessionsDir } = opts;
34
+ const { ref, resumesByPath } = opts;
35
35
  if (!resumesByPath)
36
36
  return sessionIdFromRef(ref);
37
37
  const resolved = path.resolve(ref);
38
- if (!resolved.startsWith(path.resolve(sessionsDir) + path.sep)) {
38
+ // Every directory the node keeps or reads path-based transcripts in: its own,
39
+ // and the agent's native store (a `bivy run pi` session lives in Pi's).
40
+ const roots = typeof opts.sessionsDir === "string" ? [opts.sessionsDir] : opts.sessionsDir;
41
+ if (!roots.some((root) => resolved.startsWith(path.resolve(root) + path.sep))) {
39
42
  throw new Error("Session file is outside the sessions directory");
40
43
  }
41
44
  return resolved;
package/dist/worktree.js CHANGED
@@ -29,6 +29,22 @@ export async function gitRepoRoot(dir) {
29
29
  return undefined;
30
30
  }
31
31
  }
32
+ /**
33
+ * The main working tree of the repo containing `dir`. From inside a linked
34
+ * worktree (a session's or a fork's), `--show-toplevel` names that worktree;
35
+ * new worktrees and the `.bivy/` exclude rule belong to the main one, or a fork
36
+ * of a fork nests its checkout inside its parent's tree.
37
+ */
38
+ async function gitMainRoot(dir) {
39
+ try {
40
+ const { stdout } = await exec("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"]);
41
+ const common = stdout.trim();
42
+ return common && path.basename(common) === ".git" ? path.dirname(common) : undefined;
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
32
48
  async function currentRef(repoRoot) {
33
49
  try {
34
50
  const { stdout } = await exec("git", ["-C", repoRoot, "rev-parse", "--abbrev-ref", "HEAD"]);
@@ -57,14 +73,20 @@ function excludeMeshDir(repoRoot) {
57
73
  * `<repoRoot>/.bivy/worktrees/<slug>` by default (excluded from git).
58
74
  */
59
75
  export async function createWorktree(opts) {
60
- const repoRoot = await gitRepoRoot(opts.repoDir);
61
- if (!repoRoot)
76
+ const sourceRoot = await gitRepoRoot(opts.repoDir);
77
+ if (!sourceRoot)
62
78
  throw new Error(`Not a git repository: ${opts.repoDir}`);
79
+ const repoRoot = (await gitMainRoot(sourceRoot)) ?? sourceRoot;
63
80
  const slug = branchSlug(opts.id);
64
81
  const branch = opts.branch ?? `bivy/${slug}`;
65
82
  const root = opts.root ?? path.join(repoRoot, ".bivy", "worktrees");
66
83
  const wtPath = path.join(root, slug);
67
- let base = opts.base ?? (await currentRef(repoRoot));
84
+ // Branch from the checkout asked for: a fork of a fork starts at its parent's HEAD.
85
+ let base = opts.base ?? (await currentRef(sourceRoot));
86
+ // A detached "HEAD" means the source's commit, not the main tree's HEAD.
87
+ if (base === "HEAD" && sourceRoot !== repoRoot) {
88
+ base = await exec("git", ["-C", sourceRoot, "rev-parse", "HEAD"]).then(({ stdout }) => stdout.trim() || "HEAD", () => "HEAD");
89
+ }
68
90
  excludeMeshDir(repoRoot);
69
91
  fs.mkdirSync(root, { recursive: true });
70
92
  // Reuse the registered checkout in place. Recovery must not force-remove
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.20.7-staging.2",
3
+ "version": "0.20.7-staging.3",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",