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

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 (143) hide show
  1. package/bin/bivy.mjs +100 -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/backend/compare.js +62 -0
  7. package/dist/apps/backend/http-file.js +66 -0
  8. package/dist/apps/backend/index.js +431 -0
  9. package/dist/apps/backend/logs.js +60 -0
  10. package/dist/apps/backend/rows.js +79 -0
  11. package/dist/apps/gateway.js +121 -3
  12. package/dist/apps/inspector.js +56 -1
  13. package/dist/apps/preview-shell.js +221 -9
  14. package/dist/apps/registry.js +60 -2
  15. package/dist/apps/scenario-proxy.js +65 -0
  16. package/dist/apps/scenarios.js +307 -0
  17. package/dist/apps/service.js +147 -8
  18. package/dist/apps/styles.css +70 -0
  19. package/dist/apps/types.js +9 -1
  20. package/dist/controllers/app-commands.js +13 -2
  21. package/dist/exec.js +4 -1
  22. package/dist/node-env.js +6 -0
  23. package/dist/protocol/client-command-routes.js +1 -1
  24. package/dist/protocol/client-command-schemas.js +10 -2
  25. package/dist/runtime/codex-sessions.js +158 -6
  26. package/dist/runtime/credential-provisioning.js +5 -0
  27. package/dist/runtime/grok-sessions.js +4 -2
  28. package/dist/runtime/index.js +26 -19
  29. package/dist/runtime/inherited-session-env.js +54 -0
  30. package/dist/runtime/model-ref.js +14 -1
  31. package/dist/runtime/opencode-auth.js +67 -0
  32. package/dist/runtime/opencode-preflight.js +10 -1
  33. package/dist/runtime/protocol.js +35 -1
  34. package/dist/runtime/tool-call-map.js +67 -3
  35. package/dist/server.js +51 -16
  36. package/dist/session/event-log.js +76 -5
  37. package/dist/session/fork-standup.js +1 -1
  38. package/dist/session/fork.js +6 -4
  39. package/dist/session/transcript-normal.js +52 -0
  40. package/dist/session/transcript-persistence.js +20 -3
  41. package/dist/session-ref.js +5 -2
  42. package/dist/terminal.js +17 -0
  43. package/dist/worktree.js +25 -3
  44. package/package.json +1 -1
  45. package/web/assets/{AutomationsView-CJSYk0ja.js → AutomationsView-DJibSXQw.js} +1 -1
  46. package/web/assets/{ChangesCard-Bp88fY6W.js → ChangesCard-s8Qtm7eu.js} +1 -1
  47. package/web/assets/{LibraryView-of9wmuzN.js → LibraryView-Bz8x-H8N.js} +2 -2
  48. package/web/assets/{ProviderConnect-DG7SP4l2.js → ProviderConnect-DZ-Rv8Aj.js} +1 -1
  49. package/web/assets/{Settings-DGH1U7wj.js → Settings-DMUdD3O5.js} +3 -3
  50. package/web/assets/{Sheet-D-EHCMlj.js → Sheet-2wuqvpxm.js} +1 -1
  51. package/web/assets/{Terminal-qqysrwl_.js → Terminal-DQeYK2KK.js} +1 -1
  52. package/web/assets/{VoiceSettings-aeqQkwcZ.js → VoiceSettings-BbqFD1Ee.js} +1 -1
  53. package/web/assets/{abnfDiagram-VCTEODGH-Ct-lmBn5.js → abnfDiagram-VCTEODGH-DLSpnM9y.js} +1 -1
  54. package/web/assets/architecture-7GRP2DOG-CefLtGFG.js +1 -0
  55. package/web/assets/{architectureDiagram-5GKGNRK7-CCq716Da.js → architectureDiagram-5GKGNRK7-DOQxmFrp.js} +1 -1
  56. package/web/assets/{blockDiagram-I7D4REHJ-Lka9F6ZB.js → blockDiagram-I7D4REHJ-BiCBLtEP.js} +1 -1
  57. package/web/assets/{c4Diagram-7LVT6UL2-nvhhyzri.js → c4Diagram-7LVT6UL2-yzmqXo9k.js} +1 -1
  58. package/web/assets/channel-kKuds40w.js +1 -0
  59. package/web/assets/{chunk-4HAMMTFA-TZ5hhHBz.js → chunk-4HAMMTFA-i-QksGpD.js} +1 -1
  60. package/web/assets/{chunk-75Z2AOVW-IahKw8kM.js → chunk-75Z2AOVW-DWjggoX1.js} +1 -1
  61. package/web/assets/{chunk-DU6HZSFF-BlcZUVOy.js → chunk-DU6HZSFF-jFwsC9ox.js} +1 -1
  62. package/web/assets/{chunk-F27PBJKO-3MGfEgKu.js → chunk-F27PBJKO-3ej2B9gg.js} +1 -1
  63. package/web/assets/{chunk-GMAD6QVW-Be72n678.js → chunk-GMAD6QVW-CzfhoJ04.js} +1 -1
  64. package/web/assets/{chunk-GVQU2GXP-GOVLT6xm.js → chunk-GVQU2GXP-DLhbmKuJ.js} +1 -1
  65. package/web/assets/{chunk-IMKFNOWR-ByOzewrT.js → chunk-IMKFNOWR-dR96ErOJ.js} +1 -1
  66. package/web/assets/{chunk-L3NEJ4N5-BCGhHmnW.js → chunk-L3NEJ4N5-1YX2kWHP.js} +1 -1
  67. package/web/assets/{chunk-OSK3NFVY-CAdliJE_.js → chunk-OSK3NFVY-kDveSID9.js} +1 -1
  68. package/web/assets/{chunk-P2QGCYS3-CM8cpgHa.js → chunk-P2QGCYS3-Cc3Uo4HQ.js} +1 -1
  69. package/web/assets/{chunk-POPQ4Y6H-CXLPhaI1.js → chunk-POPQ4Y6H-CxDXuH1x.js} +1 -1
  70. package/web/assets/{chunk-PWAF6VOD-BegFrwDe.js → chunk-PWAF6VOD-DZBN6HSl.js} +1 -1
  71. package/web/assets/{chunk-SHT3W25Y-iXj7Y5RI.js → chunk-SHT3W25Y-0W01Q-w7.js} +1 -1
  72. package/web/assets/{chunk-SVP7TREG-73pyTBZg.js → chunk-SVP7TREG-gqFFZT4I.js} +1 -1
  73. package/web/assets/{chunk-TICWLB2K-D5zwuuGI.js → chunk-TICWLB2K-BGdM7dkO.js} +1 -1
  74. package/web/assets/classDiagram-ZZMXUADV-Dg-2WnlQ.js +1 -0
  75. package/web/assets/classDiagram-v2-VYDZK3BY-Dg-2WnlQ.js +1 -0
  76. package/web/assets/{cynefin-OW5HDTMX-D3nRr9S_.js → cynefin-OW5HDTMX-D3KvEku4.js} +1 -1
  77. package/web/assets/{cynefinDiagram-5FMLGOSQ-C4CKMYMZ.js → cynefinDiagram-5FMLGOSQ-CLbDBHX9.js} +1 -1
  78. package/web/assets/{dagre-GXQ25YYZ-D-grkZ8X.js → dagre-GXQ25YYZ-D2kdt2lo.js} +1 -1
  79. package/web/assets/{diagram-S7CK7UJ4-BNPS3YQX.js → diagram-S7CK7UJ4-a-ev_USx.js} +1 -1
  80. package/web/assets/{diagram-UQ7AKVKN-Bkp_TyRg.js → diagram-UQ7AKVKN-BwYRbF-y.js} +1 -1
  81. package/web/assets/{diagram-VSXAHHWV-Dpxe_6MM.js → diagram-VSXAHHWV-Da7O55Aq.js} +1 -1
  82. package/web/assets/{diagram-VX7I27RA-DwGBvGn-.js → diagram-VX7I27RA-Bcq-P_8M.js} +1 -1
  83. package/web/assets/{diagram-Z3DM3KII-D-KvONMO.js → diagram-Z3DM3KII-DSXXMWvA.js} +1 -1
  84. package/web/assets/{ebnfDiagram-PWID7BFC-Di_D-a-0.js → ebnfDiagram-PWID7BFC-DxFerrqi.js} +1 -1
  85. package/web/assets/{erDiagram-RLTQ6QDP-2pnBfTU9.js → erDiagram-RLTQ6QDP-DfyXAe2v.js} +1 -1
  86. package/web/assets/eventmodeling-NTZA5JFV-tdyiDQoh.js +1 -0
  87. package/web/assets/flowDiagram-HODETNUW-BO23Cpex.js +1 -0
  88. package/web/assets/{ganttDiagram-EL5Y4UJY-DAkhMTlk.js → ganttDiagram-EL5Y4UJY-QkgrgMh9.js} +1 -1
  89. package/web/assets/{gitGraph-4MIJSDKK-BQvemX2W.js → gitGraph-4MIJSDKK-M1n5msce.js} +1 -1
  90. package/web/assets/{gitGraphDiagram-WWUBYQGX-Dx7ya_2q.js → gitGraphDiagram-WWUBYQGX-DC_8Cxet.js} +1 -1
  91. package/web/assets/{index-CFX8FhqJ.js → index-B0Bto4kI.js} +2 -2
  92. package/web/assets/{index-BXPRG7Pj.css → index-Da2-4YHh.css} +1 -1
  93. package/web/assets/{info-A6RAGUB7-DaDps2sq.js → info-A6RAGUB7-DmyYG6Ip.js} +1 -1
  94. package/web/assets/{infoDiagram-27XIBGKW-DBqMvPr2.js → infoDiagram-27XIBGKW-CmrMjsqT.js} +1 -1
  95. package/web/assets/{ishikawaDiagram-5VMMS53U-DzciF3O_.js → ishikawaDiagram-5VMMS53U-x_6jWkNq.js} +1 -1
  96. package/web/assets/{journeyDiagram-3NMN7TZE-D9WHgGe3.js → journeyDiagram-3NMN7TZE-COVPGUIl.js} +1 -1
  97. package/web/assets/{kanban-definition-UXKFOSKX-Diokj8By.js → kanban-definition-UXKFOSKX-BDZHzeOz.js} +1 -1
  98. package/web/assets/{line-DmBeTXFn.js → line-KdqoiWjk.js} +1 -1
  99. package/web/assets/{mermaid-parser.core-H_O1CweI.js → mermaid-parser.core-C5RHAle0.js} +3 -3
  100. package/web/assets/{mermaid.core-BAni15Ed.js → mermaid.core-2ujtWQt3.js} +4 -4
  101. package/web/assets/{mindmap-definition-YA3MSWOX-B_tXdzEa.js → mindmap-definition-YA3MSWOX-CCPoAcT0.js} +1 -1
  102. package/web/assets/mount-CaFlQTsV.js +30 -0
  103. package/web/assets/{packet-AYTQ26CC-Dt4bPH7I.js → packet-AYTQ26CC-BjPan2xl.js} +1 -1
  104. package/web/assets/{pegDiagram-XKGWAZYB-loAbaKUZ.js → pegDiagram-XKGWAZYB-CrARxusS.js} +1 -1
  105. package/web/assets/{pie-WAS4IAKB-DEmhJN7M.js → pie-WAS4IAKB-BdVo8hLO.js} +1 -1
  106. package/web/assets/{pieDiagram-E7YTZNPT-DfQvD2QY.js → pieDiagram-E7YTZNPT-DwdhmBCX.js} +1 -1
  107. package/web/assets/{quadrantDiagram-AXDQQJYC-Bhqo2rT-.js → quadrantDiagram-AXDQQJYC-DbG3R6xw.js} +1 -1
  108. package/web/assets/{radar-RG4KPBEZ-BmV5tXie.js → radar-RG4KPBEZ-pp-coSgM.js} +1 -1
  109. package/web/assets/{railroad-74A4TZTK-DoebyExe.js → railroad-74A4TZTK-CxNBhSbO.js} +1 -1
  110. package/web/assets/railroad-abnf-HS5TGJTU-g5eNqQ81.js +1 -0
  111. package/web/assets/railroad-ebnf-LZEXJU2U-BI5_cD5r.js +1 -0
  112. package/web/assets/railroad-peg-WCYAUIDC-CLl7RlXh.js +1 -0
  113. package/web/assets/{railroadDiagram-O6MQD6OU-BhlfIpCW.js → railroadDiagram-O6MQD6OU-CKbKBwCj.js} +1 -1
  114. package/web/assets/{requirementDiagram-BXWQKSXE-vkxH8U-2.js → requirementDiagram-BXWQKSXE-DRc316xB.js} +1 -1
  115. package/web/assets/{sankeyDiagram-P5KCCOFB-D2iFl0bc.js → sankeyDiagram-P5KCCOFB-C6O9zcIS.js} +1 -1
  116. package/web/assets/{sequenceDiagram-WJ2MYXX4-Ll_m9w0-.js → sequenceDiagram-WJ2MYXX4-D3Vz1DcK.js} +1 -1
  117. package/web/assets/{settingsRoute-C8p10bxH.js → settingsRoute-DxPtfqmn.js} +1 -1
  118. package/web/assets/{stateDiagram-D77RDMKH-B4KWuRz2.js → stateDiagram-D77RDMKH-DXcUXP-l.js} +1 -1
  119. package/web/assets/stateDiagram-v2-MP3YSRHH-CQER-whG.js +1 -0
  120. package/web/assets/{swimlanes-42K2YHIH-CVXwQL4b.js → swimlanes-42K2YHIH-tfw_N04-.js} +1 -1
  121. package/web/assets/swimlanesDiagram-VR7AAH4N-Cf__qhQx.js +8 -0
  122. package/web/assets/{timeline-definition-24CTP7MA-C0XuGVkJ.js → timeline-definition-24CTP7MA-BcAeG9F_.js} +1 -1
  123. package/web/assets/{treeView-Q6P3EWNA-BS4edB5m.js → treeView-Q6P3EWNA-DaZCFJDf.js} +1 -1
  124. package/web/assets/{treemap-WGGIJYW6-BZ54DPA4.js → treemap-WGGIJYW6-C3PybXBI.js} +1 -1
  125. package/web/assets/{useStore-DEfLrrMh.js → useStore-Ce5mzYro.js} +9 -7
  126. package/web/assets/{vennDiagram-4TSXK5OY-CAKXCk3I.js → vennDiagram-4TSXK5OY-BT7w_l5a.js} +1 -1
  127. package/web/assets/{wardley-WFR3VGLG-DM5IZXxI.js → wardley-WFR3VGLG-CXZlxUiB.js} +1 -1
  128. package/web/assets/{wardleyDiagram-VM6X3IG4--WWmycX2.js → wardleyDiagram-VM6X3IG4-B5muPnst.js} +1 -1
  129. package/web/assets/{xychartDiagram-S5SC5T6Z-D-_SgAQy.js → xychartDiagram-S5SC5T6Z-DSaMhJlg.js} +1 -1
  130. package/web/index.html +2 -2
  131. package/web/sw.js +1 -1
  132. package/web/assets/architecture-7GRP2DOG-CD4t1j2T.js +0 -1
  133. package/web/assets/channel-Dl0Aa963.js +0 -1
  134. package/web/assets/classDiagram-ZZMXUADV-BvMwIvP0.js +0 -1
  135. package/web/assets/classDiagram-v2-VYDZK3BY-BvMwIvP0.js +0 -1
  136. package/web/assets/eventmodeling-NTZA5JFV-CF677pnK.js +0 -1
  137. package/web/assets/flowDiagram-HODETNUW-8SiMcM5n.js +0 -1
  138. package/web/assets/mount-Fdy9Xoxs.js +0 -27
  139. package/web/assets/railroad-abnf-HS5TGJTU-C_F3QWV5.js +0 -1
  140. package/web/assets/railroad-ebnf-LZEXJU2U-BaNfyzXp.js +0 -1
  141. package/web/assets/railroad-peg-WCYAUIDC-BWyMWwxG.js +0 -1
  142. package/web/assets/stateDiagram-v2-MP3YSRHH-B_Xx-QG6.js +0 -1
  143. package/web/assets/swimlanesDiagram-VR7AAH4N-XYt-UA-z.js +0 -8
@@ -0,0 +1,431 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { parseHttpFile, resolveRequest } from "./http-file.js";
7
+ import { answerChanged, answerChanges, rowChanges } from "./compare.js";
8
+ import { parseRows, readQueryFile } from "./rows.js";
9
+ import { LogBuffer, LogMarks } from "./logs.js";
10
+ import { redactSecrets } from "../../redact.js";
11
+ const REQUEST_TIMEOUT_MS = 10_000;
12
+ const MAX_BODY = 1024 * 1024;
13
+ const QUERY_TIMEOUT_MS = 15_000;
14
+ const MAX_OUTPUT = 4 * 1024 * 1024;
15
+ const MAX_FILES = 50;
16
+ const MAX_FILE_BYTES = 256 * 1024;
17
+ const SHOWN_ROWS = 200;
18
+ const SAFE = ["GET", "HEAD"];
19
+ const LOOPBACK = ["127.0.0.1", "localhost", "[::1]", "::1"];
20
+ /** Files of one kind in a backend view's folder, read fresh (an agent may have just written one). */
21
+ function files(dir, extension) {
22
+ let names;
23
+ try {
24
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(extension)).sort().slice(0, MAX_FILES);
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ return names.map((name) => {
30
+ const file = path.join(dir, name);
31
+ try {
32
+ const stat = fs.lstatSync(file);
33
+ if (!stat.isFile())
34
+ return { file: name, error: "Not a regular file." };
35
+ if (stat.size > MAX_FILE_BYTES)
36
+ return { file: name, error: "Larger than 256 KiB." };
37
+ return { file: name, text: fs.readFileSync(file, "utf8") };
38
+ }
39
+ catch (error) {
40
+ return { file: name, error: error.message };
41
+ }
42
+ });
43
+ }
44
+ export class Backend {
45
+ registry;
46
+ terminals;
47
+ logs = new Map();
48
+ marks = new LogMarks();
49
+ taps = new Map();
50
+ /** A logs view's own command: its terminal, once started. */
51
+ sources = new Map();
52
+ answers = new Map();
53
+ results = new Map();
54
+ /** When the current agent run began, per session (logs count errors since). */
55
+ runs = new Map();
56
+ constructor(registry, terminals) {
57
+ this.registry = registry;
58
+ this.terminals = terminals;
59
+ // What happens in the preview (a page, a form) marks the logs: the gateway says.
60
+ registry.on("action", (viewId, label) => {
61
+ const entry = this.registry.getView(viewId);
62
+ if (entry)
63
+ this.marks.add(entry.app.id, label, { merge: true });
64
+ });
65
+ registry.serverErrors = (viewId, since) => this.serverErrors(viewId, since);
66
+ }
67
+ views(sessionId) {
68
+ return this.registry.list(sessionId).flatMap((app) => app.views.filter((view) => view.kind === "backend").map((view) => this.registry.getView(view.id))).filter(Boolean);
69
+ }
70
+ /** A backend view of a kind by app or view ID or name (`bivy app requests`); by default the first. */
71
+ pick(sessionId, kind, target) {
72
+ const views = this.views(sessionId).filter((entry) => entry.target.kind === kind);
73
+ if (!views.length)
74
+ throw new Error(`This session has no ${kind} view. Add {"kind":"${kind}","name":"…"} to the app's manifest and publish it again.`);
75
+ if (!target)
76
+ return views[0];
77
+ const wanted = target.trim().toLowerCase();
78
+ const found = views.find((entry) => [entry.view.id, entry.app.id].includes(wanted) || entry.view.name.toLowerCase() === wanted || entry.app.name.toLowerCase() === wanted);
79
+ if (!found)
80
+ throw new Error(`No ${kind} view called "${target}". Run bivy app list to see them.`);
81
+ return found;
82
+ }
83
+ // Logs ---------------------------------------------------------------------
84
+ /** A server Bivy runs has started: follow its output. Every managed server is
85
+ * followed, so its logs reach back before anyone opened them. */
86
+ attach(viewId, termId) {
87
+ this.taps.get(viewId)?.();
88
+ const buffer = this.buffer(viewId);
89
+ const stop = this.terminals.tap?.(termId, (data) => buffer.append(data));
90
+ if (stop)
91
+ this.taps.set(viewId, stop);
92
+ }
93
+ buffer(key) {
94
+ let buffer = this.logs.get(key);
95
+ if (!buffer) {
96
+ buffer = new LogBuffer();
97
+ this.logs.set(key, buffer);
98
+ }
99
+ return buffer;
100
+ }
101
+ /** Where a logs view reads: a view of its app that Bivy runs, or its own command. */
102
+ logSource(entry) {
103
+ const source = entry.target.source;
104
+ if ("command" in source)
105
+ return { key: entry.view.id, detail: entry.view.kind === "backend" ? entry.view.detail : "", command: source };
106
+ const servers = entry.app.views.map((view) => this.registry.getView(view.id)).filter((view) => view && ((view.target.kind === "service" && view.target.start) || view.target.kind === "display"));
107
+ const server = source.view ? servers.find((view) => view.view.name.toLowerCase() === source.view.toLowerCase()) : servers[0];
108
+ if (!server)
109
+ throw new Error(source.view ? `${entry.app.name} has no view called "${source.view}" that Bivy runs.` : `${entry.app.name} has no server that Bivy runs. Give the logs view a "source": a file or a command.`);
110
+ return { key: server.view.id, detail: `${server.view.name} output` };
111
+ }
112
+ /** Errors from the servers behind a web view since `since`: its own, if Bivy
113
+ * runs it, and what its app's logs views follow. For the preview's Console,
114
+ * so a server error shows up next to the page's own. Never starts a source. */
115
+ serverErrors(viewId, since) {
116
+ const now = Date.now();
117
+ const entry = this.registry.getView(viewId);
118
+ if (!entry)
119
+ return { now, lines: [] };
120
+ const keys = new Set([viewId]);
121
+ for (const view of entry.app.views) {
122
+ const logs = this.registry.getView(view.id);
123
+ if (logs?.target.kind !== "logs")
124
+ continue;
125
+ try {
126
+ keys.add(this.logSource(logs).key);
127
+ }
128
+ catch { /* no server to follow */ }
129
+ }
130
+ const lines = [...keys].flatMap((key) => this.logs.get(key)?.errorsBetween(since, now) ?? []).sort((a, b) => a.at - b.at);
131
+ return { now, lines: lines.slice(-20) };
132
+ }
133
+ async log(entry, since = 0) {
134
+ if (entry.target.kind !== "logs")
135
+ throw new Error("Not a logs view.");
136
+ const source = this.logSource(entry);
137
+ let running = this.taps.has(source.key);
138
+ if (source.command) {
139
+ // Started on first look and kept running, so the next look has what happened in between.
140
+ let started = this.sources.get(entry.view.id);
141
+ if (started && !this.terminals.has(await started.catch(() => ""))) {
142
+ this.sources.delete(entry.view.id);
143
+ started = undefined;
144
+ }
145
+ if (!started) {
146
+ started = this.terminals.start({ ...source.command, name: `${entry.app.name} · ${entry.view.name}` });
147
+ this.sources.set(entry.view.id, started);
148
+ this.attach(source.key, await started);
149
+ }
150
+ running = this.terminals.has(await started);
151
+ }
152
+ return { detail: source.detail, lines: this.buffer(source.key).read(since), marks: this.marks.read(entry.app.id), running };
153
+ }
154
+ // Requests -------------------------------------------------------------------
155
+ /** The address `{{base}}` stands for: the view's own, or a web server of its app. */
156
+ base(entry) {
157
+ const base = entry.target.base;
158
+ if (base && "url" in base)
159
+ return base.url;
160
+ const services = entry.app.views.map((view) => this.registry.getView(view.id)).filter((view) => view?.target.kind === "service");
161
+ const service = base ? services.find((view) => view.view.name.toLowerCase() === base.view.toLowerCase()) : services[0];
162
+ return service && service.target.kind === "service" ? `http://127.0.0.1:${service.target.port}` : "";
163
+ }
164
+ requestsOf(entry) {
165
+ if (entry.target.kind !== "requests")
166
+ throw new Error("Not a requests view.");
167
+ const requests = [], problems = [];
168
+ for (const file of files(entry.target.dir, ".http")) {
169
+ if (file.error !== undefined) {
170
+ problems.push({ file: file.file, error: file.error });
171
+ continue;
172
+ }
173
+ const parsed = parseHttpFile(file.text);
174
+ if (!parsed.requests.length)
175
+ problems.push({ file: file.file, error: "No requests in it. A request is a line like \"GET {{base}}/orders\"." });
176
+ const seen = new Map();
177
+ for (const spec of parsed.requests) {
178
+ // Stable while the file keeps its order: its file and its name.
179
+ const slug = spec.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "request";
180
+ const n = (seen.get(slug) ?? 0) + 1;
181
+ seen.set(slug, n);
182
+ requests.push({ id: `${file.file.replace(/\.http$/, "")}/${slug}${n > 1 ? `-${n}` : ""}`, file: file.file, spec, variables: parsed.variables });
183
+ }
184
+ }
185
+ return { requests, problems };
186
+ }
187
+ /** By ID, or by name for people and agents typing it. */
188
+ findRequest(entry, id) {
189
+ const { requests } = this.requestsOf(entry);
190
+ const found = requests.find((item) => item.id === id) ?? requests.find((item) => item.spec.name.toLowerCase() === id.trim().toLowerCase());
191
+ if (!found)
192
+ throw new Error(`No request "${id}"${requests.length ? `. Its requests: ${requests.map((item) => item.spec.name).join(", ")}` : ""}.`);
193
+ return found;
194
+ }
195
+ item(entry, request, base) {
196
+ const resolved = resolveRequest(request.spec, request.variables, { base });
197
+ let host;
198
+ try {
199
+ host = new URL(resolved.url).host;
200
+ }
201
+ catch { /* a path, or unresolved */ }
202
+ const own = base ? new URL(base).host : undefined;
203
+ const external = host && host !== own && !LOOPBACK.includes(host.replace(/:\d+$/, "")) ? host : undefined;
204
+ const kept = this.answers.get(entry.view.id)?.get(request.id);
205
+ return {
206
+ id: request.id, file: request.file, name: request.spec.name, method: request.spec.method, url: resolved.url,
207
+ auto: request.spec.auto || (SAFE.includes(request.spec.method) && !external),
208
+ ...(external ? { external } : {}), ...(kept?.last ? { last: kept.last } : {}), ...(kept?.before ? { before: kept.before } : {}),
209
+ };
210
+ }
211
+ requests(entry) {
212
+ const base = this.base(entry);
213
+ const { requests, problems } = this.requestsOf(entry);
214
+ return { base, requests: requests.map((request) => this.item(entry, request, base)), problems };
215
+ }
216
+ detail(entry, id) {
217
+ const base = this.base(entry);
218
+ const request = this.findRequest(entry, id);
219
+ const item = this.item(entry, request, base);
220
+ return { item, request: resolveRequest(request.spec, request.variables, { base }), ...(item.before && item.last ? { changes: answerChanges(item.before, item.last) } : {}) };
221
+ }
222
+ /** Runs one request now, from this machine. `as`: keep it as the run's "before". */
223
+ /** `quiet`: Bivy's own before/after runs leave no marker; someone's run does. */
224
+ async run(entry, id, as = "last", quiet = false) {
225
+ const base = this.base(entry);
226
+ const request = this.findRequest(entry, id);
227
+ const resolved = resolveRequest(request.spec, request.variables, { base });
228
+ if (!quiet)
229
+ this.marks.add(entry.app.id, `Ran “${request.spec.name}”`);
230
+ const answer = await send(resolved);
231
+ let kept = this.answers.get(entry.view.id);
232
+ if (!kept) {
233
+ kept = new Map();
234
+ this.answers.set(entry.view.id, kept);
235
+ }
236
+ kept.set(request.id, as === "before" ? { before: answer, last: answer } : { ...kept.get(request.id), last: answer });
237
+ return this.detail(entry, request.id);
238
+ }
239
+ /** Every request that runs on its own (GET/HEAD to the app's server, or @auto).
240
+ * `mark`: someone asked ("Run all"), so the log gets one marker for it. */
241
+ async runAuto(entry, as = "last", mark) {
242
+ if (mark)
243
+ this.marks.add(entry.app.id, mark);
244
+ for (const item of this.requests(entry).requests.filter((request) => request.auto))
245
+ await this.run(entry, item.id, as, true).catch(() => { });
246
+ return this.requests(entry);
247
+ }
248
+ // Data -----------------------------------------------------------------------
249
+ queriesOf(entry) {
250
+ if (entry.target.kind !== "data")
251
+ throw new Error("Not a data view.");
252
+ const queries = [], problems = [];
253
+ for (const file of files(entry.target.dir, ".sql")) {
254
+ if (file.error !== undefined)
255
+ problems.push({ file: file.file, error: file.error });
256
+ else
257
+ queries.push({ id: file.file.replace(/\.sql$/, ""), file: file.file, ...readQueryFile(file.text) });
258
+ }
259
+ return { queries, problems };
260
+ }
261
+ /** Runs a query through the view's client: the query on stdin, rows on stdout. */
262
+ query(entry, sql) {
263
+ const target = entry.target;
264
+ return new Promise((resolve, reject) => {
265
+ const child = spawn(target.command, target.args, { cwd: target.workspace, stdio: ["pipe", "pipe", "pipe"] });
266
+ let out = "", err = "";
267
+ const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`The query took longer than ${QUERY_TIMEOUT_MS / 1000} seconds.`)); }, QUERY_TIMEOUT_MS);
268
+ child.stdout.on("data", (chunk) => { out += chunk; if (out.length > MAX_OUTPUT)
269
+ child.kill("SIGKILL"); });
270
+ child.stderr.on("data", (chunk) => { err = (err + chunk).slice(-4000); });
271
+ child.on("error", (error) => { clearTimeout(timer); reject(new Error(error.code === "ENOENT" ? `"${target.command}" isn't installed on this machine.` : error.message)); });
272
+ child.on("close", (code) => {
273
+ clearTimeout(timer);
274
+ if (code !== 0)
275
+ reject(new Error(redactSecrets(err.trim()) || `${target.command} exited with ${code}.`));
276
+ else
277
+ resolve(parseRows(out));
278
+ });
279
+ child.stdin.on("error", () => { });
280
+ child.stdin.end(sql.endsWith(";") || /;\s*$/.test(sql) ? `${sql}\n` : `${sql};\n`);
281
+ });
282
+ }
283
+ /** Each saved query's rows and, once a run has begun, what changed since. `run`: query now. */
284
+ async data(entry, run = false, as = "last") {
285
+ const { queries, problems } = this.queriesOf(entry);
286
+ let kept = this.results.get(entry.view.id);
287
+ if (!kept) {
288
+ kept = new Map();
289
+ this.results.set(entry.view.id, kept);
290
+ }
291
+ const store = kept;
292
+ if (run || queries.some((query) => !store.get(query.id)?.last && !store.get(query.id)?.error)) {
293
+ for (const query of queries) {
294
+ if (!run && (store.get(query.id)?.last || store.get(query.id)?.error))
295
+ continue;
296
+ try {
297
+ const rows = await this.query(entry, query.sql);
298
+ store.set(query.id, as === "before" ? { before: rows, last: { ...rows, at: Date.now() } } : { ...store.get(query.id), last: { ...rows, at: Date.now() }, error: undefined });
299
+ }
300
+ catch (error) {
301
+ store.set(query.id, { ...store.get(query.id), error: error.message });
302
+ }
303
+ }
304
+ }
305
+ return {
306
+ detail: entry.view.kind === "backend" ? entry.view.detail : "", problems,
307
+ queries: queries.map((query) => {
308
+ const kept = store.get(query.id);
309
+ const columns = kept?.last?.columns ?? [];
310
+ const key = query.key ?? (columns.includes("id") ? "id" : columns[0] ?? "id");
311
+ return {
312
+ id: query.id, file: query.file, title: query.title ?? query.id, key, columns,
313
+ rows: kept?.last?.rows.slice(0, SHOWN_ROWS) ?? [], count: kept?.last?.rows.length ?? 0,
314
+ ...(kept?.last ? { at: kept.last.at } : {}), ...(kept?.error ? { error: kept.error } : {}),
315
+ ...(kept?.before && kept.last ? { changes: rowChanges(kept.before.rows, kept.last.rows, key) } : {}),
316
+ };
317
+ }),
318
+ };
319
+ }
320
+ // A run's evidence -----------------------------------------------------------
321
+ /** Before an agent run: the safe requests and every query, as "before". */
322
+ async baseline(sessionId) {
323
+ this.runs.set(sessionId, Date.now());
324
+ for (const app of this.registry.list(sessionId))
325
+ if (app.views.some((view) => view.kind === "backend"))
326
+ this.marks.add(app.id, "The agent started working");
327
+ for (const entry of this.views(sessionId)) {
328
+ if (entry.target.kind === "requests")
329
+ await this.runAuto(entry, "before").catch(() => { });
330
+ if (entry.target.kind === "data")
331
+ await this.data(entry, true, "before").catch(() => { });
332
+ }
333
+ }
334
+ /** After it: the same again, and one line per thing that changed, per app. */
335
+ async evidence(sessionId) {
336
+ const since = this.runs.get(sessionId) ?? Date.now();
337
+ const out = new Map();
338
+ const add = (entry, rows) => {
339
+ const kept = out.get(entry.app.id) ?? { rows: [], changed: false };
340
+ for (const row of rows)
341
+ kept.rows.push({ viewId: entry.view.id, view: entry.view.name, backend: entry.target.kind, ...row });
342
+ kept.changed ||= rows.some((row) => row.tone === "warn" || row.tone === "danger");
343
+ out.set(entry.app.id, kept);
344
+ };
345
+ for (const entry of this.views(sessionId)) {
346
+ try {
347
+ if (entry.target.kind === "requests") {
348
+ const items = (await this.runAuto(entry)).requests.filter((item) => item.auto && item.before && item.last);
349
+ const changed = items.filter((item) => answerChanged(item.before, item.last));
350
+ add(entry, [
351
+ ...changed.slice(0, 3).map((item) => ({
352
+ summary: `${item.method} ${item.name}`, item: item.id,
353
+ detail: item.before.status !== item.last.status ? `${item.before.status || "no answer"} → ${item.last.status || "no answer"}` : "answer changed",
354
+ tone: (item.last.error || item.last.status >= 500 ? "danger" : "warn"),
355
+ })),
356
+ ...(changed.length > 3 ? [{ summary: `${changed.length - 3} more answer differently`, tone: "warn" }] : []),
357
+ ...(items.length > changed.length ? [{ summary: `${items.length - changed.length} ${items.length - changed.length === 1 ? "request answers" : "requests answer"} the same`, tone: (changed.length ? "neutral" : "ok") }] : []),
358
+ ]);
359
+ }
360
+ else if (entry.target.kind === "data") {
361
+ const queries = (await this.data(entry, true)).queries;
362
+ const changed = queries.filter((query) => query.changes && (query.changes.added.length || query.changes.changed.length || query.changes.removed.length));
363
+ add(entry, changed.length ? changed.slice(0, 4).map((query) => ({
364
+ summary: query.title, item: query.id, tone: "warn",
365
+ detail: [query.changes.added.length && `+${query.changes.added.length}`, query.changes.changed.length && `~${query.changes.changed.length}`, query.changes.removed.length && `−${query.changes.removed.length}`].filter(Boolean).join(" "),
366
+ })) : queries.some((query) => query.changes) ? [{ summary: "No rows changed", tone: "ok" }] : []);
367
+ }
368
+ else {
369
+ const log = await this.log(entry, since);
370
+ const errors = log.lines.filter((line) => line.level === "error");
371
+ add(entry, [errors.length
372
+ ? { summary: `${errors.length} new ${errors.length === 1 ? "error" : "errors"}`, detail: errors[0].text.slice(0, 160), tone: "danger" }
373
+ : { summary: "No new errors", tone: "ok" }]);
374
+ }
375
+ }
376
+ catch { /* a view that can't answer adds nothing to the card */ }
377
+ }
378
+ return out;
379
+ }
380
+ /** An app was removed: stop following its sources and forget its results. */
381
+ forget(viewIds, appId) {
382
+ for (const id of viewIds) {
383
+ this.taps.get(id)?.();
384
+ this.taps.delete(id);
385
+ this.logs.delete(id);
386
+ this.answers.delete(id);
387
+ this.results.delete(id);
388
+ this.sources.delete(id);
389
+ }
390
+ this.marks.forget(appId);
391
+ }
392
+ }
393
+ /** One request, from this machine. Redirects are shown, not followed: a 302 is an answer. */
394
+ async function send(request) {
395
+ const at = Date.now();
396
+ let url;
397
+ try {
398
+ url = new URL(request.url);
399
+ }
400
+ catch {
401
+ return { status: 0, statusText: "", ms: 0, at, headers: {}, body: "", error: `“${request.url}” isn't an address. Use {{base}}/path, or a full http(s) URL.` };
402
+ }
403
+ if (!["http:", "https:"].includes(url.protocol))
404
+ return { status: 0, statusText: "", ms: 0, at, headers: {}, body: "", error: "Only http and https requests run." };
405
+ try {
406
+ const response = await fetch(url, { method: request.method, headers: request.headers, body: SAFE.includes(request.method) || !request.body ? undefined : request.body, redirect: "manual", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
407
+ const reader = response.body?.getReader();
408
+ const chunks = [];
409
+ let size = 0, truncated = false;
410
+ while (reader) {
411
+ const { done, value } = await reader.read();
412
+ if (done)
413
+ break;
414
+ size += value.length;
415
+ chunks.push(value);
416
+ if (size > MAX_BODY) {
417
+ truncated = true;
418
+ await reader.cancel();
419
+ break;
420
+ }
421
+ }
422
+ const body = Buffer.concat(chunks).subarray(0, MAX_BODY).toString("utf8");
423
+ const type = response.headers.get("content-type") ?? "";
424
+ const headers = Object.fromEntries([...response.headers].slice(0, 30));
425
+ return { status: response.status, statusText: response.statusText, ms: Date.now() - at, at, headers, body, ...(/json/i.test(type) ? { json: true } : {}), ...(truncated ? { truncated: true } : {}) };
426
+ }
427
+ catch (error) {
428
+ const reason = error.name === "TimeoutError" ? `No answer within ${REQUEST_TIMEOUT_MS / 1000} seconds.` : (error.cause?.code === "ECONNREFUSED" ? `Nothing is answering at ${url.host}.` : error.message);
429
+ return { status: 0, statusText: "", ms: Date.now() - at, at, headers: {}, body: "", error: reason };
430
+ }
431
+ }
@@ -0,0 +1,60 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { redactSecrets } from "../../redact.js";
4
+ const MAX_LINES = 2000;
5
+ const MAX_MARKS = 50;
6
+ const MAX_LINE = 2000;
7
+ /** How bad a line is, as rows tried in order. A server's own words decide:
8
+ * "error", an exception, a 5xx it answered. Adding a pattern is adding a row. */
9
+ const LEVELS = [
10
+ { level: "error", pattern: /\b(error|exception|fatal|panic|traceback|uncaught|unhandled|failed)\b|[a-z](?:Error|Exception)\b|\b(?:Completed|status[=: ]+|HTTP\/\d(?:\.\d)?"?\s+)5\d\d\b|\s5\d\d\s+in\s+\d/i },
11
+ { level: "warn", pattern: /\b(warn|warning|deprecat\w*)\b/i },
12
+ ];
13
+ // Terminal colour and cursor codes: a log is read as text.
14
+ // eslint-disable-next-line no-control-regex
15
+ const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
16
+ /** A stack frame or continuation belongs to the line above it. */
17
+ const CONTINUATION = /^(\s+\S|\s*at\s|\s*from\s|\s*File\s"|Caused by:|\s*\.\.\.\s\d+ more)/;
18
+ /** One source's recent lines, each stamped when it arrived. */
19
+ export class LogBuffer {
20
+ lines = [];
21
+ partial = "";
22
+ append(data, at = Date.now()) {
23
+ const text = this.partial + data.replace(ANSI, "").replace(/\r\n?/g, "\n");
24
+ const parts = text.split("\n");
25
+ this.partial = parts.pop().slice(-MAX_LINE);
26
+ for (const raw of parts) {
27
+ if (!raw.trim())
28
+ continue;
29
+ const line = raw.slice(0, MAX_LINE);
30
+ const previous = this.lines.at(-1);
31
+ const level = CONTINUATION.test(line) && previous && previous.level !== "info" ? previous.level : LEVELS.find((row) => row.pattern.test(line))?.level ?? "info";
32
+ this.lines.push({ at, text: line, level });
33
+ }
34
+ if (this.lines.length > MAX_LINES)
35
+ this.lines = this.lines.slice(-MAX_LINES);
36
+ }
37
+ /** Lines since `since`, with secrets taken out: they're shown, and may be sent to an agent. */
38
+ read(since = 0) {
39
+ return this.lines.filter((line) => line.at > since).map((line) => ({ ...line, text: redactSecrets(line.text) }));
40
+ }
41
+ errorsBetween(from, to = Infinity) {
42
+ return this.read(from).filter((line) => line.level === "error" && line.at <= to);
43
+ }
44
+ }
45
+ /** "Your last action" per app: what the person (or the agent) just did, so the
46
+ * log can say what came of it. */
47
+ export class LogMarks {
48
+ marks = new Map();
49
+ /** `merge`: traffic from the preview, where a page and the requests it makes
50
+ * in the same moment are one action. Something run on purpose always counts. */
51
+ add(appId, label, { at = Date.now(), merge = false } = {}) {
52
+ const list = this.marks.get(appId) ?? [];
53
+ if (merge && list.at(-1) && at - list.at(-1).at < 1000)
54
+ return;
55
+ list.push({ at, label: label.slice(0, 200) });
56
+ this.marks.set(appId, list.slice(-MAX_MARKS));
57
+ }
58
+ read(appId) { return [...this.marks.get(appId) ?? []]; }
59
+ forget(appId) { this.marks.delete(appId); }
60
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ const MAX_ROWS = 1000;
4
+ const text = (value) => value === null || value === undefined ? "null" : typeof value === "object" ? JSON.stringify(value) : String(value);
5
+ /** A delimited line, with quotes ("a ""quoted"" value") as CSV writes them. */
6
+ function splitLine(line, delimiter) {
7
+ const cells = [];
8
+ let cell = "", quoted = false;
9
+ for (let i = 0; i < line.length; i++) {
10
+ const char = line[i];
11
+ if (quoted) {
12
+ if (char === "\"" && line[i + 1] === "\"") {
13
+ cell += "\"";
14
+ i++;
15
+ }
16
+ else if (char === "\"")
17
+ quoted = false;
18
+ else
19
+ cell += char;
20
+ }
21
+ else if (char === "\"" && !cell)
22
+ quoted = true;
23
+ else if (char === delimiter) {
24
+ cells.push(cell);
25
+ cell = "";
26
+ }
27
+ else
28
+ cell += char;
29
+ }
30
+ return [...cells, cell];
31
+ }
32
+ function delimited(output, delimiter) {
33
+ const lines = output.split(/\r?\n/).filter((line) => line.length);
34
+ if (!lines.length || !lines[0].includes(delimiter))
35
+ return undefined;
36
+ const columns = splitLine(lines[0], delimiter);
37
+ return { columns, rows: lines.slice(1, MAX_ROWS + 1).map((line) => Object.fromEntries(splitLine(line, delimiter).map((cell, i) => [columns[i] ?? `column ${i + 1}`, cell]))) };
38
+ }
39
+ const FORMATS = [
40
+ { name: "JSON", read: (output) => {
41
+ let value;
42
+ try {
43
+ value = JSON.parse(output);
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ if (!Array.isArray(value))
49
+ return undefined;
50
+ const objects = value.slice(0, MAX_ROWS).filter((row) => !!row && typeof row === "object" && !Array.isArray(row));
51
+ const columns = [...new Set(objects.flatMap((row) => Object.keys(row)))];
52
+ return { columns, rows: objects.map((row) => Object.fromEntries(columns.map((column) => [column, text(row[column])]))) };
53
+ } },
54
+ { name: "TSV", read: (output) => delimited(output, "\t") },
55
+ { name: "CSV", read: (output) => delimited(output, ",") },
56
+ // One column, no delimiter: still a table.
57
+ { name: "lines", read: (output) => {
58
+ const lines = output.split(/\r?\n/).filter((line) => line.length);
59
+ if (!lines.length)
60
+ return undefined;
61
+ return { columns: [lines[0]], rows: lines.slice(1, MAX_ROWS + 1).map((line) => ({ [lines[0]]: line })) };
62
+ } },
63
+ ];
64
+ export function parseRows(output) {
65
+ const trimmed = output.trim();
66
+ if (!trimmed)
67
+ return { columns: [], rows: [] };
68
+ for (const format of FORMATS) {
69
+ const rows = format.read(trimmed);
70
+ if (rows)
71
+ return rows;
72
+ }
73
+ return { columns: [], rows: [] };
74
+ }
75
+ /** A saved query's file: the query, and its `-- title:` and `-- key:` comments. */
76
+ export function readQueryFile(text) {
77
+ const tag = (name) => new RegExp(`^\\s*--\\s*${name}\\s*:\\s*(.+)$`, "im").exec(text)?.[1]?.trim();
78
+ return { sql: text.trim(), ...(tag("title") ? { title: tag("title") } : {}), ...(tag("key") ? { key: tag("key") } : {}) };
79
+ }