@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
package/bin/bivy.mjs CHANGED
@@ -1221,6 +1221,18 @@ const AGENT_SESSION_ID_FLAG = {
1221
1221
  gemini: "--session-id",
1222
1222
  };
1223
1223
 
1224
+ // Args a native `bivy run <agent>` adds so the session stays one Bivy can take
1225
+ // over as a chat. Codex 0.160's TUI otherwise runs its threads in a shared
1226
+ // background app-server that outlives the terminal and keeps the thread's
1227
+ // writer lock, so "Use chat" failed with "thread … already has an active writer".
1228
+ const AGENT_RUN_ARGS = {
1229
+ codex: ["--no-daemon"],
1230
+ };
1231
+ function applyAgentRunArgs(agentId, spec) {
1232
+ const add = (AGENT_RUN_ARGS[agentId] ?? []).filter((arg) => !(spec.args ?? []).includes(arg));
1233
+ if (add.length) spec.args = [...add, ...(spec.args ?? [])];
1234
+ }
1235
+
1224
1236
  // Args that mean the caller already chose a session (pin or resume), so we must
1225
1237
  // not inject our own --session-id over the top.
1226
1238
  const SESSION_ID_CONFLICTS = ["--session-id", "--resume", "-r", "-c", "--continue"];
@@ -2057,6 +2069,7 @@ async function cmdRun(args = []) {
2057
2069
  // didn't already choose one), so the on-disk session is a known, deterministic
2058
2070
  // resume target — the anchor for later "continue as chat" adoption.
2059
2071
  const pinnedSessionId = bivySession ? undefined : pinRunSessionId(agentId, resolved.spec);
2072
+ applyAgentRunArgs(agentId, resolved.spec);
2060
2073
  if (bivySession) resolved.spec.sessionId = bivySession;
2061
2074
  if (pinnedSessionId) {
2062
2075
  console.log(c.dim(`session id ${pinnedSessionId} — resume in a terminal with '${agentId} --resume ${pinnedSessionId}'`));
@@ -3111,7 +3124,8 @@ async function cmdApp(args = []) {
3111
3124
  bivy app list [--session <id>]
3112
3125
  bivy app remove <app-id> [--session <id>]
3113
3126
  bivy app shot [app-id] [--widths 390,1280] [--themes light,dark] [--path /page] [--session <id>]
3114
- bivy app present [view] [--path /page] [--note "…"] [--session <id>]
3127
+ bivy app present [view] [--path /page] [--note "…"] [--try a,b] [--session <id>]
3128
+ bivy app scenarios [view] [--session <id>]
3115
3129
  bivy app share [app-id] [--view <view>] [--for 1h|1d|7d] [--view-only] [--session <id>]
3116
3130
  bivy app notes [app-id] [--view <view>] [--since <time>] [--session <id>]
3117
3131
  bivy app run [--name <name>] [--restart-on-change] [--session <id>] -- <command> [args…]
@@ -3157,6 +3171,25 @@ chooses an item — more reliable than clicking, and prints a screenshot after.
3157
3171
  the chat with the app at phone width (and before/after), and it opens the live
3158
3172
  preview. Use it when you finish a visible change, or when the user asks to see
3159
3173
  it. [view] is an app or view name or ID (default: the one opened last).
3174
+ --try lists scenarios (by file name) the card offers: each opens the live
3175
+ preview in that state.
3176
+ "scenarios" lists a view's scenarios and any file that can't be opened, with
3177
+ what's wrong. A scenario is a JSON file in .bivy/scenarios/<id>.json: a named
3178
+ starting point the user can open, use, and reset from the preview:
3179
+ {"name":"Payment API down","open":"/checkout",
3180
+ "steps":[{"click":"text=Pay"}],
3181
+ "network":[{"match":"POST /api/payments*","status":503,"json":{"error":"down"}}]}
3182
+ Optional: "description", "view" (app or view name), "from" (another scenario
3183
+ to start from), "fresh": true (start as a new visitor). Steps: {"click":sel},
3184
+ {"fill":sel,"with":"text"}, {"press":"Enter"}, {"wait":sel or ms}; sel is a CSS
3185
+ selector or text=Visible text. Network rules answer matching requests for that
3186
+ viewer only: "status", "json" or "body", "delayMs", or "offline": true.
3187
+ Desktop apps restart in a scenario: "args" and "env" are added to the app's
3188
+ command, "api": {"env":"API_URL","target":"http://127.0.0.1:4000"} points the
3189
+ app at a proxy that applies "network", and steps are {"click":[x,y]},
3190
+ {"type":"text"}, {"press":"cmd+s"}, {"menu":"File > Open"}, {"wait":ms}. When
3191
+ you finish a visible change, add scenarios for the states it affects (errors,
3192
+ empty, slow) and pass them to present --try.
3160
3193
  "share" mints a reusable link to one web view and prints its URL and expiry
3161
3194
  (--for: 1h, 1d or 7d, default 1d; sooner if the user stops sharing in Apps, the
3162
3195
  app is removed or the machine restarts). [app-id] is an app ID or name, --view
@@ -3174,7 +3207,7 @@ Web previews require operator setup; see docs/apps.md.`);
3174
3207
  }
3175
3208
  if (INPUT_ACTIONS[action]) return appInput(action, rest);
3176
3209
  if (action === "menu") return appMenu(rest, { json });
3177
- if (!["publish", "list", "remove", "shot", "run", "present", "share", "notes"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
3210
+ if (!["publish", "list", "remove", "shot", "run", "present", "scenarios", "share", "notes"].includes(action)) throw new Error("Unknown app command. Run bivy app --help.");
3178
3211
  // `run -- <command> [args…]`: publish a one-window desktop app without a manifest file.
3179
3212
  let runManifest;
3180
3213
  if (action === "run") {
@@ -3220,7 +3253,7 @@ Web previews require operator setup; see docs/apps.md.`);
3220
3253
  }
3221
3254
  }
3222
3255
  if (action === "shot" || action === "present") {
3223
- for (const flag of action === "shot" ? ["--widths", "--themes", "--path"] : ["--path", "--note"]) {
3256
+ for (const flag of action === "shot" ? ["--widths", "--themes", "--path"] : ["--path", "--note", "--try"]) {
3224
3257
  const i = rest.indexOf(flag);
3225
3258
  if (i < 0) continue;
3226
3259
  const value = rest[i + 1];
@@ -3228,6 +3261,7 @@ Web previews require operator setup; see docs/apps.md.`);
3228
3261
  if (flag === "--widths") shot.widths = value.split(",").map((w) => Number(w.trim()));
3229
3262
  else if (flag === "--themes") shot.themes = value.split(",").map((t) => t.trim());
3230
3263
  else if (flag === "--note") shot.note = value;
3264
+ else if (flag === "--try") shot.try = value.split(",").map((id) => id.trim()).filter(Boolean);
3231
3265
  else shot.path = value;
3232
3266
  rest.splice(i, 2);
3233
3267
  }
@@ -3237,10 +3271,10 @@ Web previews require operator setup; see docs/apps.md.`);
3237
3271
  const sessionId = resolveAttachSessionId({ sessionFlag: sessionIndex >= 0 ? rest[sessionIndex + 1] : undefined, env: process.env });
3238
3272
  if (!sessionId) throw new Error("Set --session <id> or run inside a Bivy agent session.");
3239
3273
  const positional = rest.filter((_, i) => i !== sessionIndex && (sessionIndex < 0 || i !== sessionIndex + 1));
3240
- if (positional.some((a) => a.startsWith("-")) || (action === "shot" || action === "present" || action === "share" || action === "notes" ? positional.length > 1 : positional.length !== (action === "list" || action === "run" ? 0 : 1))) throw new Error("Invalid arguments. Run bivy app --help.");
3274
+ if (positional.some((a) => a.startsWith("-")) || (action === "shot" || action === "present" || action === "scenarios" || action === "share" || action === "notes" ? positional.length > 1 : positional.length !== (action === "list" || action === "run" ? 0 : 1))) throw new Error("Invalid arguments. Run bivy app --help.");
3241
3275
  const body = { sessionId, ...shot };
3242
3276
  if (action === "shot" && positional[0]) body.appId = positional[0];
3243
- if (action === "present" && positional[0]) body.target = positional[0];
3277
+ if ((action === "present" || action === "scenarios") && positional[0]) body.target = positional[0];
3244
3278
  if ((action === "share" || action === "notes") && positional[0]) body.appId = positional[0];
3245
3279
  if (action === "publish") {
3246
3280
  const file = path.resolve(positional[0]);
@@ -70,6 +70,10 @@ const itemOutputs = new Map();
70
70
  const agentMessageItems = new Set();
71
71
  const reasoningItems = new Set();
72
72
  const activeObservedItems = new Set();
73
+ // Child thread id -> the card that represents that sub-agent in the parent
74
+ // transcript, so the child's own tool calls nest under it instead of reading
75
+ // as the parent's work.
76
+ const childThreadCards = new Map();
73
77
  let pendingTurnCompletion = false;
74
78
  let turnCompletionTimer = null;
75
79
 
@@ -95,8 +99,31 @@ function paramsItemId(params, fallback) {
95
99
  return String(params?.itemId || params?.callId || item?.id || fallback || "");
96
100
  }
97
101
 
102
+ function rememberChildThreads(item) {
103
+ if (item.type === "subAgentActivity" && item.agentThreadId) {
104
+ childThreadCards.set(item.agentThreadId, `subagent-${item.agentThreadId}`);
105
+ } else if (item.type === "collabAgentToolCall" && item.id) {
106
+ for (const child of item.receiverThreadIds ?? []) {
107
+ if (!childThreadCards.has(child)) childThreadCards.set(child, String(item.id));
108
+ }
109
+ }
110
+ }
111
+
112
+ /** True when a notification belongs to a delegated child thread, not ours. */
113
+ function fromChildThread(params) {
114
+ return Boolean(params?.threadId && threadId && params.threadId !== threadId);
115
+ }
116
+
117
+ /** `{ parentToolCallId }` for an item a child thread ran, else `{}`. */
118
+ function childParent(params) {
119
+ if (!fromChildThread(params)) return {};
120
+ const card = childThreadCards.get(params.threadId) ?? `subagent-${params.threadId}`;
121
+ return { parentToolCallId: card };
122
+ }
123
+
98
124
  function rememberItem(item) {
99
125
  if (!item || typeof item !== "object") return;
126
+ rememberChildThreads(item);
100
127
  const id = item.type === "subAgentActivity" && item.agentThreadId
101
128
  ? `subagent-${item.agentThreadId}`
102
129
  : String(item.id || "");
@@ -146,18 +173,18 @@ function appendOutput(itemId, delta) {
146
173
  return output;
147
174
  }
148
175
 
149
- function emitToolUpdate(itemId, name, input) {
176
+ function emitToolUpdate(itemId, name, input, parent = {}) {
150
177
  if (!itemId) return;
151
- bivy({ type: "tool.update", toolCallId: itemId, name, input });
178
+ bivy({ type: "tool.update", toolCallId: itemId, name, input, ...parent });
152
179
  }
153
180
 
154
181
  // App-server item/started is observational: approval-capable shell/patch calls
155
182
  // were already surfaced by requestApproval, while MCP, dynamic, and collaboration
156
183
  // items may already be running. `tool.observe` gives all of them a live,
157
184
  // persisted card without asking the user to approve work that has already begun.
158
- function emitToolObserved(itemId, name, input) {
185
+ function emitToolObserved(itemId, name, input, parent = {}) {
159
186
  if (!itemId) return;
160
- bivy({ type: "tool.observe", toolCallId: itemId, name, input });
187
+ bivy({ type: "tool.observe", toolCallId: itemId, name, input, ...parent });
161
188
  }
162
189
 
163
190
  // `turn/plan/updated` (the update_plan tool) carries the whole plan with a
@@ -214,11 +241,12 @@ function handleCompletedItem(params) {
214
241
  rememberItem(item);
215
242
  const itemId = paramsItemId(params);
216
243
  if (!item || typeof item !== "object" || !itemId) return;
244
+ const parent = childParent(params);
217
245
  switch (item.type) {
218
246
  case "agentMessage":
219
247
  // Child-thread prose belongs to the delegated agent, not the parent's
220
248
  // answer stream. Its lifecycle is represented by the collaboration items.
221
- if (params?.threadId && threadId && params.threadId !== threadId) return;
249
+ if (fromChildThread(params)) return;
222
250
  if (!agentMessageItems.has(itemId) && item.text) {
223
251
  agentMessageItems.add(itemId);
224
252
  bivy({ type: "message.delta", text: String(item.text) });
@@ -240,7 +268,7 @@ function handleCompletedItem(params) {
240
268
  case "commandExecution": {
241
269
  const output = text(item.aggregatedOutput ?? itemOutputs.get(itemId));
242
270
  if (output) itemOutputs.set(itemId, output.slice(-4000));
243
- emitToolUpdate(itemId, "shell", updateInput(itemId, { output }));
271
+ emitToolUpdate(itemId, "shell", updateInput(itemId, { output }), parent);
244
272
  const exitCode = typeof item.exitCode === "number" ? item.exitCode : undefined;
245
273
  bivy({
246
274
  type: "tool.result",
@@ -248,36 +276,37 @@ function handleCompletedItem(params) {
248
276
  name: "shell",
249
277
  result: { content: output || text(item.status || "completed"), ...(exitCode === undefined ? {} : { exitCode }) },
250
278
  isError: (exitCode !== undefined && exitCode !== 0) || item.status === "failed",
279
+ ...parent,
251
280
  });
252
281
  return;
253
282
  }
254
283
  case "fileChange": {
255
284
  const status = text(item.status || "completed");
256
285
  itemInputs.set(itemId, updateInput(itemId, { changes: item.changes ?? [], status }));
257
- emitToolUpdate(itemId, "apply_patch", updateInput(itemId));
258
- bivy({ type: "tool.result", toolCallId: itemId, name: "apply_patch", result: status, isError: status === "failed" });
286
+ emitToolUpdate(itemId, "apply_patch", updateInput(itemId), parent);
287
+ bivy({ type: "tool.result", toolCallId: itemId, name: "apply_patch", result: status, isError: status === "failed", ...parent });
259
288
  return;
260
289
  }
261
290
  case "mcpToolCall": {
262
291
  const name = text(item.tool || "mcp");
263
292
  const result = text(item.result ?? item.error ?? "");
264
- bivy({ type: "tool.result", toolCallId: itemId, name, result, isError: Boolean(item.error) || item.status === "failed" });
293
+ bivy({ type: "tool.result", toolCallId: itemId, name, result, isError: Boolean(item.error) || item.status === "failed", ...parent });
265
294
  return;
266
295
  }
267
296
  case "dynamicToolCall": {
268
297
  const name = text(item.tool || "tool");
269
298
  const result = text(item.contentItems ?? item.result ?? item.status ?? "completed");
270
- bivy({ type: "tool.result", toolCallId: itemId, name, result, isError: item.success === false || item.status === "failed" });
299
+ bivy({ type: "tool.result", toolCallId: itemId, name, result, isError: item.success === false || item.status === "failed", ...parent });
271
300
  return;
272
301
  }
273
302
  case "collabAgentToolCall": {
274
303
  const result = text(item.status || "completed");
275
- bivy({ type: "tool.result", toolCallId: itemId, name: "spawn_agent", result, isError: item.status === "failed" });
304
+ bivy({ type: "tool.result", toolCallId: itemId, name: "spawn_agent", result, isError: item.status === "failed", ...parent });
276
305
  return;
277
306
  }
278
307
  case "subAgentActivity": {
279
308
  const result = text(item.kind || "completed");
280
- bivy({ type: "tool.result", toolCallId: itemId, name: "subagent_activity", result, isError: item.kind === "interrupted" });
309
+ bivy({ type: "tool.result", toolCallId: itemId, name: "subagent_activity", result, isError: item.kind === "interrupted", ...parent });
281
310
  return;
282
311
  }
283
312
  default:
@@ -356,7 +385,8 @@ function onNotification(m) {
356
385
  // both token streams together produces character-level corruption whenever a
357
386
  // delegated agent answers concurrently. Child lifecycle remains visible via
358
387
  // collabAgentToolCall/subAgentActivity; only prose/reasoning is scoped here.
359
- const fromCurrentThread = !params?.threadId || !threadId || params.threadId === threadId;
388
+ const fromCurrentThread = !fromChildThread(params);
389
+ const parent = childParent(params);
360
390
  switch (method) {
361
391
  case "item/agentMessage/delta": {
362
392
  const itemId = paramsItemId(params);
@@ -384,19 +414,19 @@ function onNotification(m) {
384
414
  case "item/commandExecution/outputDelta": {
385
415
  const itemId = paramsItemId(params);
386
416
  const output = appendOutput(itemId, params.delta);
387
- emitToolUpdate(itemId, "shell", updateInput(itemId, { output }));
417
+ emitToolUpdate(itemId, "shell", updateInput(itemId, { output }), parent);
388
418
  return;
389
419
  }
390
420
  case "item/fileChange/outputDelta": {
391
421
  const itemId = paramsItemId(params);
392
422
  const output = appendOutput(itemId, params.delta);
393
- emitToolUpdate(itemId, "apply_patch", updateInput(itemId, { output }));
423
+ emitToolUpdate(itemId, "apply_patch", updateInput(itemId, { output }), parent);
394
424
  return;
395
425
  }
396
426
  case "item/fileChange/patchUpdated": {
397
427
  const itemId = paramsItemId(params);
398
428
  itemInputs.set(itemId, updateInput(itemId, { changes: params.changes ?? [] }));
399
- emitToolUpdate(itemId, "apply_patch", updateInput(itemId));
429
+ emitToolUpdate(itemId, "apply_patch", updateInput(itemId), parent);
400
430
  return;
401
431
  }
402
432
  case "item/started": {
@@ -404,12 +434,12 @@ function onNotification(m) {
404
434
  rememberItem(item);
405
435
  const itemId = paramsItemId(params);
406
436
  if (["commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "collabAgentToolCall", "subAgentActivity"].includes(item?.type) && itemId) activeObservedItems.add(itemId);
407
- if (item?.type === "commandExecution") emitToolObserved(itemId, "shell", updateInput(itemId));
408
- if (item?.type === "fileChange") emitToolObserved(itemId, "apply_patch", updateInput(itemId));
409
- if (item?.type === "mcpToolCall") emitToolObserved(itemId, text(item.tool || "mcp"), updateInput(itemId));
410
- if (item?.type === "dynamicToolCall") emitToolObserved(itemId, text(item.tool || "tool"), updateInput(itemId));
411
- if (item?.type === "collabAgentToolCall") emitToolObserved(itemId, "spawn_agent", updateInput(itemId));
412
- if (item?.type === "subAgentActivity") emitToolObserved(itemId, "subagent_activity", updateInput(itemId));
437
+ if (item?.type === "commandExecution") emitToolObserved(itemId, "shell", updateInput(itemId), parent);
438
+ if (item?.type === "fileChange") emitToolObserved(itemId, "apply_patch", updateInput(itemId), parent);
439
+ if (item?.type === "mcpToolCall") emitToolObserved(itemId, text(item.tool || "mcp"), updateInput(itemId), parent);
440
+ if (item?.type === "dynamicToolCall") emitToolObserved(itemId, text(item.tool || "tool"), updateInput(itemId), parent);
441
+ if (item?.type === "collabAgentToolCall") emitToolObserved(itemId, "spawn_agent", updateInput(itemId), parent);
442
+ if (item?.type === "subAgentActivity") emitToolObserved(itemId, "subagent_activity", updateInput(itemId), parent);
413
443
  return;
414
444
  }
415
445
  case "item/completed": {
@@ -419,15 +449,20 @@ function onNotification(m) {
419
449
  return;
420
450
  }
421
451
  case "thread/tokenUsage/updated":
422
- if (params?.tokenUsage) bivy({ type: "usage", usage: params.tokenUsage.last ?? params.tokenUsage.total ?? params.tokenUsage });
452
+ if (fromCurrentThread && params?.tokenUsage) bivy({ type: "usage", usage: params.tokenUsage.last ?? params.tokenUsage.total ?? params.tokenUsage });
423
453
  return;
424
454
  case "rawResponse/completed":
425
455
  if (params?.usage) bivy({ type: "usage", usage: params.usage });
426
456
  return;
457
+ // A delegated child runs its own turns on the same connection. Only the
458
+ // parent thread's turn lifecycle opens and seals the Bivy turn: sealing on
459
+ // a child's turn/completed ended the parent turn early and the parent's
460
+ // remaining tools and answer landed after it was closed.
427
461
  case "turn/started":
428
- bivy({ type: "session.status", status: "working" });
462
+ if (fromCurrentThread) bivy({ type: "session.status", status: "working" });
429
463
  return;
430
464
  case "turn/completed": {
465
+ if (!fromCurrentThread) return;
431
466
  // The completion event is authoritative about a terminal failure: a turn
432
467
  // that hit a usage limit (or any provider error) arrives here as
433
468
  // `status: "failed"` carrying `turn.error`. Capture it directly instead of
@@ -448,6 +483,7 @@ function onNotification(m) {
448
483
  return;
449
484
  }
450
485
  case "turn/failed": {
486
+ if (!fromCurrentThread) return;
451
487
  // A failed turn is TERMINAL in the app-server — no `turn/completed` follows
452
488
  // it — so end the turn here rather than merely stashing the error for a
453
489
  // completion event that never arrives. Stashing (the old behavior) left the
@@ -470,7 +506,9 @@ function onNotification(m) {
470
506
  // A non-turn-scoped error notification (e.g. transient reconnect noise, per
471
507
  // the codex exec parser's read of this event). Stash it so a following
472
508
  // turn/completed surfaces it; a genuinely turn-ending failure arrives as
473
- // turn/failed above, which never leaves the turn open.
509
+ // turn/failed above, which never leaves the turn open. A child thread's
510
+ // error is reported on its sub-agent card, not as the parent's failure.
511
+ if (!fromCurrentThread) return;
474
512
  sawError = params?.error?.message || params?.message || "Codex turn failed";
475
513
  return;
476
514
  default:
@@ -485,6 +523,19 @@ async function ensureInitialized() {
485
523
  initialized = true;
486
524
  }
487
525
 
526
+ // Codex keeps one writer per thread. A thread still open in Codex elsewhere
527
+ // (its TUI, or the shared background app-server Codex 0.160 runs for it) can't
528
+ // be resumed here; say what to do instead of passing the bare error through.
529
+ async function resumeThread(params) {
530
+ try {
531
+ return await asRequest("thread/resume", params);
532
+ } catch (error) {
533
+ const message = error instanceof Error ? error.message : String(error);
534
+ if (!/active writer/i.test(message)) throw error;
535
+ throw new Error(`${message}. The thread is still open in Codex outside Bivy: close it there, or stop Codex's background server with \`codex app-server daemon stop\`, then try again.`);
536
+ }
537
+ }
538
+
488
539
  function threadParams(msg, extra = {}) {
489
540
  return {
490
541
  cwd: msg.workspace || process.cwd(),
@@ -511,7 +562,7 @@ async function onBivyCommand(msg) {
511
562
  // field on session.create. New hosts use the explicit session.resume
512
563
  // primitive below.
513
564
  if (typeof msg.resume === "string" && msg.resume) {
514
- const resumed = await asRequest("thread/resume", threadParams(msg, { threadId: msg.resume }));
565
+ const resumed = await resumeThread(threadParams(msg, { threadId: msg.resume }));
515
566
  threadId = resumed?.thread?.id ?? resumed?.threadId ?? msg.resume;
516
567
  bivy({ replyTo: id, ok: true, runtimeSessionRef: threadId });
517
568
  return;
@@ -525,7 +576,7 @@ async function onBivyCommand(msg) {
525
576
  await ensureInitialized();
526
577
  const resumeRef = String(msg.runtimeSessionRef || msg.resumeRef || msg.sessionId || "");
527
578
  if (!resumeRef) { bivy({ replyTo: id, ok: false, error: "missing resume ref" }); return; }
528
- const resumed = await asRequest("thread/resume", threadParams(msg, { threadId: resumeRef }));
579
+ const resumed = await resumeThread(threadParams(msg, { threadId: resumeRef }));
529
580
  threadId = resumed?.thread?.id ?? resumed?.threadId ?? resumeRef;
530
581
  bivy({ replyTo: id, ok: true, runtimeSessionRef: threadId });
531
582
  return;
@@ -36,7 +36,8 @@ export const BIVY_AGENT_NOTE = [
36
36
  "![](path) does not render a local path. A REMOTE `https://` image in markdown does render.",
37
37
  "- Something with a UI: `bivy app publish <manifest.json>` (a web server's port, a static build, a terminal) or " +
38
38
  "`bivy app run -- <command>` (a desktop app) gives the user a live preview; `bivy app shot` screenshots it so you " +
39
- "can check your work; `bivy app present` tells the user a visible change is ready to look at. " +
39
+ "can check your work; `bivy app present` tells the user a visible change is ready to look at, and " +
40
+ "`.bivy/scenarios/*.json` files let them open it in the states that matter (errors, empty, slow) with `--try`. " +
40
41
  "`bivy app --help` has the details.",
41
42
  "- Proposing tasks the user could hand you (next steps, ideas, options): post each with " +
42
43
  '`bivy suggest "<complete instruction>" [--title "short label"] [--run here|subagents|new]` instead of only ' +
@@ -548,7 +548,8 @@ function loadClaudeTranscript(sessionId) {
548
548
  }
549
549
  if (role === "user" && !(Array.isArray(content) && content.some((block) => block?.type === "tool_result")))
550
550
  lastPrompt = content;
551
- messages.push({ role, content, timestamp: new Date(entry?.timestamp ?? entry?.createdAt ?? Date.now()).getTime() });
551
+ const model = role === "assistant" && typeof entry?.message?.model === "string" && entry.message.model !== "<synthetic>" ? entry.message.model : undefined;
552
+ messages.push({ role, content, timestamp: new Date(entry?.timestamp ?? entry?.createdAt ?? Date.now()).getTime(), ...(model ? { model } : {}) });
552
553
  lastErrorIdx = entry?.isApiErrorMessage === true ? messages.length - 1 : -1;
553
554
  }
554
555
  }
@@ -32,7 +32,7 @@ export const AGENT_PROFILES = {
32
32
  },
33
33
  opencode: {
34
34
  displayName: "OpenCode",
35
- behaviors: { preflight: "opencode", slashCommands: "opencode", sessionStore: "opencode" },
35
+ behaviors: { preflight: "opencode", prepare: "opencode-auth", slashCommands: "opencode", sessionStore: "opencode" },
36
36
  command: "opencode",
37
37
  packageName: "opencode-ai",
38
38
  // `opencode run "<prompt>"` runs one non-interactive turn and streams the
@@ -44,6 +44,9 @@ export const AGENT_PROFILES = {
44
44
  // Claude Code, and Codex clear. See `acp` below for the version fallback.
45
45
  supportTier: "supported",
46
46
  testedVersion: "1.18.33",
47
+ // Mixed: OpenCode keeps its own logins, and Bivy adds what it holds — API
48
+ // keys as env vars, a connected ChatGPT plan via the opencode-auth prepare.
49
+ authOwner: "mixed",
47
50
  blurb: "The most widely used open-source coding harness (OpenCode CLI).",
48
51
  // `opencode run -s <id> "<prompt>"` continues a prior session by its own id
49
52
  // (`-s, --session session id to continue`, per `opencode run --help`).
@@ -403,12 +406,12 @@ export const AGENT_PROFILES = {
403
406
  model: {
404
407
  flag: "-m",
405
408
  models: [
406
- // Official Grok CLI (1.x) advertises grok-4.6 as the default subscription
407
- // model (verified against the authenticated `~/.grok/models_cache.json`,
408
- // origin cli-chat-proxy.grok.com/v1/models); the older grok-4.5 /
409
- // grok-4-latest / grok-code-fast-1 ids now return "unknown model id".
409
+ // Official Grok CLI (1.0.46) lists grok-4.7 as the default subscription
410
+ // model (`grok models`), with its faster build variant and grok-4.6.
410
411
  // Keep the list honest; operators can override with BIVY_GROK_MODELS if
411
- // their install exposes more.
412
+ // their install exposes more. The governed ACP path reads the live list.
413
+ { id: "grok-4.7", name: "Grok 4.7", provider: "xai" },
414
+ { id: "grok-4.7-build-fast", name: "Grok 4.7 Build Fast", provider: "xai" },
412
415
  { id: "grok-4.6", name: "Grok 4.6", provider: "xai" },
413
416
  ],
414
417
  },
@@ -14,6 +14,8 @@ import { pressMenu, readMenuPath, readMenus } from "./menu.js";
14
14
  import { inspectorScript } from "./inspector.js";
15
15
  import { readStrokes, readElementScrolls } from "./annotate.js";
16
16
  import { contentType } from "./mime.js";
17
+ import { loadScenarios, matchRule, planScenario, scenariosFor, summarize } from "./scenarios.js";
18
+ import { simulate } from "./scenario-proxy.js";
17
19
  const COOKIE = "__Host-bivy-preview";
18
20
  const OPEN_PATH = "/__bivy/open";
19
21
  const REDEEM_PATH = "/__bivy/redeem";
@@ -21,6 +23,9 @@ const REVISION_PATH = "/__bivy/revision";
21
23
  const INSPECTOR_PATH = "/__bivy/inspector.js";
22
24
  const COMPARE_PATH = "/__bivy/compare";
23
25
  const NOTES_PATH = "/__bivy/notes";
26
+ const SCENARIOS_PATH = "/__bivy/scenarios";
27
+ const SCENARIO_PATH = "/__bivy/scenario";
28
+ const scenarioKind = (entry) => entry.target.kind === "display" ? "desktop" : "web";
24
29
  /** Larger HTML documents pass through without the inspector. */
25
30
  const MAX_INJECT_BYTES = 5 * 1024 * 1024;
26
31
  /** Put the inspector first in <head>, so it sees errors from the app's own scripts. */
@@ -205,9 +210,6 @@ export class AppGateway {
205
210
  ["/__bivy/tokens.css", readFileSync(new URL("./tokens.css", import.meta.url))],
206
211
  ["/__bivy/styles.css", readFileSync(new URL("./styles.css", import.meta.url))],
207
212
  ]);
208
- /** `reviewer`: the browser session came from a copied (shared) link.
209
- * `viewOnly`: that link came without the reviewer tools.
210
- * `embedded`: it runs framed inside a Bivy client (a third-party frame). */
211
213
  sessions = new Map();
212
214
  sockets = new Map();
213
215
  /** Connections opened by people with a copied link, so Stop sharing can end
@@ -486,6 +488,11 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
486
488
  res.end("Preview access expired. Open a new preview link from Bivy.");
487
489
  return;
488
490
  }
491
+ // The shell (its own origin) chooses this browser's scenario; nothing else may.
492
+ if (req.url === SCENARIO_PATH && req.method === "POST") {
493
+ await this.enterScenario(req, res, entry, session);
494
+ return;
495
+ }
489
496
  // Block cross-site mutations even if a client sends the preview cookie.
490
497
  if (!["GET", "HEAD"].includes(req.method ?? "") && req.headers.origin !== this.origin(id)) {
491
498
  res.writeHead(403);
@@ -511,6 +518,10 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
511
518
  this.compare(req, res, entry);
512
519
  return;
513
520
  }
521
+ if (req.url === SCENARIOS_PATH && req.method === "GET") {
522
+ this.scenarios(res, entry, session);
523
+ return;
524
+ }
514
525
  if (req.url === INSPECTOR_PATH && req.method === "GET") {
515
526
  res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store" });
516
527
  res.end(inspectorScript(this.shellOrigin(id), session.reviewer === true && !session.viewOnly, session.embedded === true));
@@ -530,6 +541,9 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
530
541
  await this.display(req, res, entry);
531
542
  return;
532
543
  }
544
+ const rule = session.scenario && matchRule(session.scenario.network, req.method, req.url);
545
+ if (rule && await simulate(req, res, rule, responseHeaders({}, this.ancestors(id))))
546
+ return;
533
547
  const inspect = isPageLoad(req);
534
548
  // Remember the framed page so a turn reload lands where the user was.
535
549
  if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
@@ -871,6 +885,80 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
871
885
  res.writeHead(200, { "content-type": "application/json" });
872
886
  res.end(JSON.stringify({ screenshot: Boolean(shot) }));
873
887
  }
888
+ /** The view's scenarios for the shell's switcher, and the one it's in: this
889
+ * browser's for a web page, the app's for a desktop app. */
890
+ scenarios(res, entry, session) {
891
+ const { scenarios, problems } = loadScenarios(entry.workspace);
892
+ const kind = scenarioKind(entry);
893
+ const own = scenariosFor(scenarios, entry.app.name, entry.view.name, kind);
894
+ const active = kind === "desktop" ? entry.scenario : session.scenario;
895
+ res.writeHead(200, { "access-control-allow-origin": this.shellOrigin(entry.view.id), "access-control-allow-credentials": "true", vary: "Origin", "content-type": "application/json" });
896
+ res.end(JSON.stringify({ kind, scenarios: summarize(own, problems, entry.app.createdAt, kind), active: active ? { id: active.id, name: active.name, ...(active.simulated ? { simulated: active.simulated } : {}) } : null }));
897
+ }
898
+ /** Puts this browser in a scenario (or back in none, for an empty body) and
899
+ * returns the plan the shell walks through: pages to open and steps to take.
900
+ * A "fresh" scenario also expires the app's cookies this browser sent, so it
901
+ * starts as a new visitor. Only the shell's origin may ask. */
902
+ async enterScenario(req, res, entry, session) {
903
+ const shell = this.shellOrigin(entry.view.id);
904
+ const cors = { "access-control-allow-origin": shell, "access-control-allow-credentials": "true", vary: "Origin" };
905
+ if (req.headers.origin !== shell) {
906
+ res.writeHead(403);
907
+ res.end();
908
+ return;
909
+ }
910
+ let body = "";
911
+ for await (const chunk of req) {
912
+ body += chunk.toString();
913
+ if (body.length > 64) {
914
+ res.writeHead(413, cors);
915
+ res.end();
916
+ return;
917
+ }
918
+ }
919
+ const id = body.trim();
920
+ const json = (status, value) => { res.writeHead(status, { ...cors, "content-type": "application/json" }); res.end(JSON.stringify(value)); };
921
+ // A desktop app is one program for everyone watching it: its scenario
922
+ // restarts it, and the steps run in its window before this answers.
923
+ if (scenarioKind(entry) === "desktop") {
924
+ if (!this.registry.desktopScenario) {
925
+ json(400, { error: "Desktop scenarios aren't available on this machine." });
926
+ return;
927
+ }
928
+ try {
929
+ json(200, await this.registry.desktopScenario(entry.view.id, id));
930
+ }
931
+ catch (error) {
932
+ json(400, { error: error.message });
933
+ }
934
+ return;
935
+ }
936
+ if (!id) {
937
+ session.scenario = undefined;
938
+ res.writeHead(200, { ...cors, "content-type": "application/json" });
939
+ res.end(JSON.stringify({ plan: null }));
940
+ return;
941
+ }
942
+ let plan;
943
+ try {
944
+ const own = scenariosFor(loadScenarios(entry.workspace).scenarios, entry.app.name, entry.view.name, "web");
945
+ plan = planScenario(own, id, "web");
946
+ }
947
+ catch (error) {
948
+ res.writeHead(400, { ...cors, "content-type": "application/json" });
949
+ res.end(JSON.stringify({ error: error.message }));
950
+ return;
951
+ }
952
+ session.scenario = plan;
953
+ const headers = { ...cors, "content-type": "application/json" };
954
+ if (plan.fresh) {
955
+ const names = (req.headers.cookie ?? "").split(";").map((part) => part.split("=", 1)[0].trim()).filter((name) => name && name !== COOKIE && /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name));
956
+ const expire = (name) => `${name}=; Path=/; Max-Age=0`;
957
+ headers["set-cookie"] = names.map((name) => session.embedded ? partitionCookie(expire(name)) : expire(name));
958
+ }
959
+ res.writeHead(200, headers);
960
+ res.end(JSON.stringify({ plan }));
961
+ }
874
962
  /** Compare shots for the shell: the list, or one PNG by index. */
875
963
  compare(req, res, entry) {
876
964
  const shots = entry.shots ?? [];
@@ -128,10 +128,65 @@ const marked=r=>{
128
128
  text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,120),
129
129
  rect:{x:Math.round(b.left+scrollX),y:Math.round(b.top+scrollY),width:Math.round(b.width),height:Math.round(b.height)}};});
130
130
  };
131
+ // Scenarios: the shell walks a scenario's steps here, one at a time, the way a
132
+ // person would (find it, scroll to it, use it), and hears back after each one.
133
+ // A step that leaves the page ends this run; the shell picks up after it on the
134
+ // next page. "load" ties each answer to the page load it came from.
135
+ let leaving=false;
136
+ addEventListener('pagehide',()=>{leaving=true;});addEventListener('beforeunload',()=>{leaving=true;});
137
+ const sleep=ms=>new Promise(r=>setTimeout(r,ms));
138
+ const shown=el=>Boolean(el&&el.getClientRects().length);
139
+ const words=el=>(el.innerText||el.value||el.getAttribute('aria-label')||'').trim().replace(/\\s+/g,' ').toLowerCase();
140
+ const find=q=>{
141
+ if(typeof q!=='string')return null;
142
+ if(q.startsWith('text=')){
143
+ const want=q.slice(5).trim().toLowerCase();
144
+ const all=[...document.querySelectorAll('a,button,[role=button],[role=link],[role=tab],[role=menuitem],[role=option],label,summary,input[type=submit],input[type=button]')].filter(shown);
145
+ return all.find(el=>words(el)===want)||all.find(el=>words(el).includes(want))||null;
146
+ }
147
+ try{const el=document.querySelector(q);return shown(el)?el:null;}catch{throw Error('“'+q+'” isn’t a valid selector');}
148
+ };
149
+ const waitFor=async q=>{for(let t=0;t<50&&!leaving;t++){const el=find(q);if(el)return el;await sleep(100);}return null;};
150
+ const missing=q=>Error('Couldn’t find “'+q+'” within 5 seconds');
151
+ const STEPS={
152
+ click:async s=>{const el=await waitFor(s.click);if(!el)throw missing(s.click);el.scrollIntoView({block:'center'});el.click();},
153
+ fill:async s=>{
154
+ const el=await waitFor(s.fill);if(!el)throw missing(s.fill);
155
+ const proto=el.localName==='textarea'?HTMLTextAreaElement.prototype:el.localName==='select'?HTMLSelectElement.prototype:HTMLInputElement.prototype;
156
+ const set=Object.getOwnPropertyDescriptor(proto,'value')?.set;if(!set)throw Error('“'+s.fill+'” isn’t a field');
157
+ el.scrollIntoView({block:'center'});el.focus();set.call(el,String(s.with));
158
+ el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));
159
+ },
160
+ press:async s=>{
161
+ const el=document.activeElement||document.body,key=String(s.press),o={key,bubbles:true,cancelable:true};
162
+ const go=el.dispatchEvent(new KeyboardEvent('keydown',o));el.dispatchEvent(new KeyboardEvent('keyup',o));
163
+ if(go&&key==='Enter'&&el.form)el.form.requestSubmit();
164
+ },
165
+ wait:async s=>{if(typeof s.wait==='number')await sleep(Math.min(10000,Math.max(0,s.wait)));else if(!await waitFor(s.wait))throw missing(s.wait);},
166
+ };
167
+ async function runSteps(d){
168
+ const steps=Array.isArray(d.steps)?d.steps.slice(0,50):[],run=d.run,load=d.load;
169
+ for(let i=Math.max(0,Number(d.from)||0);i<steps.length;i++){
170
+ const step=steps[i]||{},kind=Object.keys(STEPS).find(k=>k in step);
171
+ try{if(!kind)throw Error('Unknown step');await STEPS[kind](step);}
172
+ catch(error){if(!leaving)post({type:'step',run,load,i,error:String(error?.message||error).slice(0,300)});return;}
173
+ post({type:'step',run,load,i,ok:true});
174
+ await sleep(250);if(leaving)return;
175
+ }
176
+ post({type:'steps-done',run,load});
177
+ }
178
+ // A fresh start: what this page keeps in the browser goes (the gateway expires
179
+ // the cookies the server can see).
180
+ const forget=()=>{
181
+ try{localStorage.clear();sessionStorage.clear();}catch{}
182
+ for(const c of document.cookie.split(';')){const name=c.split('=')[0].trim();if(name)document.cookie=name+'=; Max-Age=0; Path=/';}
183
+ };
131
184
  addEventListener('message',e=>{
132
185
  if(!framed||e.origin!==SHELL||e.source!==parent)return;
133
186
  const d=e.data||{},n=v=>Number.isFinite(+v)?+v:0;
134
- if(d.type==='bivy:note'&&REVIEWER){
187
+ if(d.type==='bivy:steps')void runSteps(d);
188
+ else if(d.type==='bivy:forget'){forget();post({type:'forgot'});}
189
+ else if(d.type==='bivy:note'&&REVIEWER){
135
190
  void fetch('/__bivy/notes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(d.note)})
136
191
  .then(async r=>{if(!r.ok)throw Error(r.status===429?'Please wait a moment before sending another picture.':r.status===400||r.status===502?(await r.text()).slice(0,200):'Could not send. Check that the link is still valid and try again.');return r.json();})
137
192
  .then(result=>post({type:'note-sent',id:d.id,screenshot:result.screenshot===true}),error=>post({type:'note-sent',id:d.id,error:error.message}));