@bivy/bivy 0.20.7-staging.3 → 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 (117) hide show
  1. package/bin/bivy.mjs +61 -0
  2. package/dist/apps/backend/compare.js +62 -0
  3. package/dist/apps/backend/http-file.js +66 -0
  4. package/dist/apps/backend/index.js +431 -0
  5. package/dist/apps/backend/logs.js +60 -0
  6. package/dist/apps/backend/rows.js +79 -0
  7. package/dist/apps/gateway.js +30 -0
  8. package/dist/apps/preview-shell.js +24 -4
  9. package/dist/apps/registry.js +56 -1
  10. package/dist/apps/service.js +37 -2
  11. package/dist/apps/styles.css +66 -0
  12. package/dist/apps/types.js +7 -0
  13. package/dist/controllers/app-commands.js +10 -0
  14. package/dist/protocol/client-command-routes.js +1 -1
  15. package/dist/protocol/client-command-schemas.js +7 -0
  16. package/dist/server.js +2 -1
  17. package/dist/terminal.js +17 -0
  18. package/package.json +1 -1
  19. package/web/assets/{AutomationsView-DuWxbo5B.js → AutomationsView-DJibSXQw.js} +1 -1
  20. package/web/assets/{ChangesCard-BnHllUb0.js → ChangesCard-s8Qtm7eu.js} +1 -1
  21. package/web/assets/{LibraryView-DpNuD-T7.js → LibraryView-Bz8x-H8N.js} +2 -2
  22. package/web/assets/{ProviderConnect-4vjf567A.js → ProviderConnect-DZ-Rv8Aj.js} +1 -1
  23. package/web/assets/{Settings-CHofnxyn.js → Settings-DMUdD3O5.js} +3 -3
  24. package/web/assets/{Sheet-DwouI-Tt.js → Sheet-2wuqvpxm.js} +1 -1
  25. package/web/assets/{Terminal-3mNEW7UT.js → Terminal-DQeYK2KK.js} +1 -1
  26. package/web/assets/{VoiceSettings-BI4B-zS1.js → VoiceSettings-BbqFD1Ee.js} +1 -1
  27. package/web/assets/{abnfDiagram-VCTEODGH-DPP1TRyh.js → abnfDiagram-VCTEODGH-DLSpnM9y.js} +1 -1
  28. package/web/assets/architecture-7GRP2DOG-CefLtGFG.js +1 -0
  29. package/web/assets/{architectureDiagram-5GKGNRK7-CvgInp30.js → architectureDiagram-5GKGNRK7-DOQxmFrp.js} +1 -1
  30. package/web/assets/{blockDiagram-I7D4REHJ-CZAAYK-I.js → blockDiagram-I7D4REHJ-BiCBLtEP.js} +1 -1
  31. package/web/assets/{c4Diagram-7LVT6UL2-BYJlvZiZ.js → c4Diagram-7LVT6UL2-yzmqXo9k.js} +1 -1
  32. package/web/assets/channel-kKuds40w.js +1 -0
  33. package/web/assets/{chunk-4HAMMTFA-Cj4-BAMD.js → chunk-4HAMMTFA-i-QksGpD.js} +1 -1
  34. package/web/assets/{chunk-75Z2AOVW-3eT-aS0t.js → chunk-75Z2AOVW-DWjggoX1.js} +1 -1
  35. package/web/assets/{chunk-DU6HZSFF-D_4JI7OY.js → chunk-DU6HZSFF-jFwsC9ox.js} +1 -1
  36. package/web/assets/{chunk-F27PBJKO-5wTG02yk.js → chunk-F27PBJKO-3ej2B9gg.js} +1 -1
  37. package/web/assets/{chunk-GMAD6QVW-IliCLU5B.js → chunk-GMAD6QVW-CzfhoJ04.js} +1 -1
  38. package/web/assets/{chunk-GVQU2GXP-CB3DCzUu.js → chunk-GVQU2GXP-DLhbmKuJ.js} +1 -1
  39. package/web/assets/{chunk-IMKFNOWR-Bg3eY-xc.js → chunk-IMKFNOWR-dR96ErOJ.js} +1 -1
  40. package/web/assets/{chunk-L3NEJ4N5-BEMaPYT9.js → chunk-L3NEJ4N5-1YX2kWHP.js} +1 -1
  41. package/web/assets/{chunk-OSK3NFVY-DMc-FLYk.js → chunk-OSK3NFVY-kDveSID9.js} +1 -1
  42. package/web/assets/{chunk-P2QGCYS3-CaqPruPs.js → chunk-P2QGCYS3-Cc3Uo4HQ.js} +1 -1
  43. package/web/assets/{chunk-POPQ4Y6H-3h-I9lV1.js → chunk-POPQ4Y6H-CxDXuH1x.js} +1 -1
  44. package/web/assets/{chunk-PWAF6VOD-CPLNMq3b.js → chunk-PWAF6VOD-DZBN6HSl.js} +1 -1
  45. package/web/assets/{chunk-SHT3W25Y-ErtwLBXR.js → chunk-SHT3W25Y-0W01Q-w7.js} +1 -1
  46. package/web/assets/{chunk-SVP7TREG-GNUjH0zF.js → chunk-SVP7TREG-gqFFZT4I.js} +1 -1
  47. package/web/assets/{chunk-TICWLB2K-DtbyVpEE.js → chunk-TICWLB2K-BGdM7dkO.js} +1 -1
  48. package/web/assets/classDiagram-ZZMXUADV-Dg-2WnlQ.js +1 -0
  49. package/web/assets/classDiagram-v2-VYDZK3BY-Dg-2WnlQ.js +1 -0
  50. package/web/assets/{cynefin-OW5HDTMX-CQt68k-b.js → cynefin-OW5HDTMX-D3KvEku4.js} +1 -1
  51. package/web/assets/{cynefinDiagram-5FMLGOSQ-BeEQcUmX.js → cynefinDiagram-5FMLGOSQ-CLbDBHX9.js} +1 -1
  52. package/web/assets/{dagre-GXQ25YYZ-CoekQT_h.js → dagre-GXQ25YYZ-D2kdt2lo.js} +1 -1
  53. package/web/assets/{diagram-S7CK7UJ4-DQBF8H5Z.js → diagram-S7CK7UJ4-a-ev_USx.js} +1 -1
  54. package/web/assets/{diagram-UQ7AKVKN-2Tw1WGQF.js → diagram-UQ7AKVKN-BwYRbF-y.js} +1 -1
  55. package/web/assets/{diagram-VSXAHHWV-C4Ns08EL.js → diagram-VSXAHHWV-Da7O55Aq.js} +1 -1
  56. package/web/assets/{diagram-VX7I27RA-CMUQSfon.js → diagram-VX7I27RA-Bcq-P_8M.js} +1 -1
  57. package/web/assets/{diagram-Z3DM3KII-Lqvr8Yf6.js → diagram-Z3DM3KII-DSXXMWvA.js} +1 -1
  58. package/web/assets/{ebnfDiagram-PWID7BFC-BAYJ-dgh.js → ebnfDiagram-PWID7BFC-DxFerrqi.js} +1 -1
  59. package/web/assets/{erDiagram-RLTQ6QDP-Dg8VYnq4.js → erDiagram-RLTQ6QDP-DfyXAe2v.js} +1 -1
  60. package/web/assets/eventmodeling-NTZA5JFV-tdyiDQoh.js +1 -0
  61. package/web/assets/flowDiagram-HODETNUW-BO23Cpex.js +1 -0
  62. package/web/assets/{ganttDiagram-EL5Y4UJY-ywnauJ7D.js → ganttDiagram-EL5Y4UJY-QkgrgMh9.js} +1 -1
  63. package/web/assets/{gitGraph-4MIJSDKK-bYI8lc-g.js → gitGraph-4MIJSDKK-M1n5msce.js} +1 -1
  64. package/web/assets/{gitGraphDiagram-WWUBYQGX-erBDwxX8.js → gitGraphDiagram-WWUBYQGX-DC_8Cxet.js} +1 -1
  65. package/web/assets/{index-DzT1Tnf2.js → index-B0Bto4kI.js} +2 -2
  66. package/web/assets/{index-Ch1zstZB.css → index-Da2-4YHh.css} +1 -1
  67. package/web/assets/{info-A6RAGUB7-BZbsPdZJ.js → info-A6RAGUB7-DmyYG6Ip.js} +1 -1
  68. package/web/assets/{infoDiagram-27XIBGKW-qqTv-8cL.js → infoDiagram-27XIBGKW-CmrMjsqT.js} +1 -1
  69. package/web/assets/{ishikawaDiagram-5VMMS53U-v-e3YR9X.js → ishikawaDiagram-5VMMS53U-x_6jWkNq.js} +1 -1
  70. package/web/assets/{journeyDiagram-3NMN7TZE-D61ZGndw.js → journeyDiagram-3NMN7TZE-COVPGUIl.js} +1 -1
  71. package/web/assets/{kanban-definition-UXKFOSKX-NeYWFrdx.js → kanban-definition-UXKFOSKX-BDZHzeOz.js} +1 -1
  72. package/web/assets/{line-BNxdvAy9.js → line-KdqoiWjk.js} +1 -1
  73. package/web/assets/{mermaid-parser.core-BjJ-qoss.js → mermaid-parser.core-C5RHAle0.js} +3 -3
  74. package/web/assets/{mermaid.core-DT4QwB_n.js → mermaid.core-2ujtWQt3.js} +4 -4
  75. package/web/assets/{mindmap-definition-YA3MSWOX-BxuVgGhr.js → mindmap-definition-YA3MSWOX-CCPoAcT0.js} +1 -1
  76. package/web/assets/mount-CaFlQTsV.js +30 -0
  77. package/web/assets/{packet-AYTQ26CC-DIff_vXS.js → packet-AYTQ26CC-BjPan2xl.js} +1 -1
  78. package/web/assets/{pegDiagram-XKGWAZYB-oDNo32c7.js → pegDiagram-XKGWAZYB-CrARxusS.js} +1 -1
  79. package/web/assets/{pie-WAS4IAKB-C0cdo_NU.js → pie-WAS4IAKB-BdVo8hLO.js} +1 -1
  80. package/web/assets/{pieDiagram-E7YTZNPT-BbThivUY.js → pieDiagram-E7YTZNPT-DwdhmBCX.js} +1 -1
  81. package/web/assets/{quadrantDiagram-AXDQQJYC-CONfSoMt.js → quadrantDiagram-AXDQQJYC-DbG3R6xw.js} +1 -1
  82. package/web/assets/{radar-RG4KPBEZ-teYstrbs.js → radar-RG4KPBEZ-pp-coSgM.js} +1 -1
  83. package/web/assets/{railroad-74A4TZTK-B4VusJHl.js → railroad-74A4TZTK-CxNBhSbO.js} +1 -1
  84. package/web/assets/railroad-abnf-HS5TGJTU-g5eNqQ81.js +1 -0
  85. package/web/assets/railroad-ebnf-LZEXJU2U-BI5_cD5r.js +1 -0
  86. package/web/assets/railroad-peg-WCYAUIDC-CLl7RlXh.js +1 -0
  87. package/web/assets/{railroadDiagram-O6MQD6OU-CKrYutQ_.js → railroadDiagram-O6MQD6OU-CKbKBwCj.js} +1 -1
  88. package/web/assets/{requirementDiagram-BXWQKSXE-COt2S2Y4.js → requirementDiagram-BXWQKSXE-DRc316xB.js} +1 -1
  89. package/web/assets/{sankeyDiagram-P5KCCOFB-D72oeNbf.js → sankeyDiagram-P5KCCOFB-C6O9zcIS.js} +1 -1
  90. package/web/assets/{sequenceDiagram-WJ2MYXX4-DFpVcNjb.js → sequenceDiagram-WJ2MYXX4-D3Vz1DcK.js} +1 -1
  91. package/web/assets/{settingsRoute-Clztmzl_.js → settingsRoute-DxPtfqmn.js} +1 -1
  92. package/web/assets/{stateDiagram-D77RDMKH-BKW8l6Ru.js → stateDiagram-D77RDMKH-DXcUXP-l.js} +1 -1
  93. package/web/assets/stateDiagram-v2-MP3YSRHH-CQER-whG.js +1 -0
  94. package/web/assets/{swimlanes-42K2YHIH-GRXgsapd.js → swimlanes-42K2YHIH-tfw_N04-.js} +1 -1
  95. package/web/assets/swimlanesDiagram-VR7AAH4N-Cf__qhQx.js +8 -0
  96. package/web/assets/{timeline-definition-24CTP7MA-C8DELzYs.js → timeline-definition-24CTP7MA-BcAeG9F_.js} +1 -1
  97. package/web/assets/{treeView-Q6P3EWNA-C1MDeOPi.js → treeView-Q6P3EWNA-DaZCFJDf.js} +1 -1
  98. package/web/assets/{treemap-WGGIJYW6-CcwgMCe9.js → treemap-WGGIJYW6-C3PybXBI.js} +1 -1
  99. package/web/assets/{useStore-CaQamKgO.js → useStore-Ce5mzYro.js} +8 -8
  100. package/web/assets/{vennDiagram-4TSXK5OY-Co-Nw6M-.js → vennDiagram-4TSXK5OY-BT7w_l5a.js} +1 -1
  101. package/web/assets/{wardley-WFR3VGLG-FbKVF2Pu.js → wardley-WFR3VGLG-CXZlxUiB.js} +1 -1
  102. package/web/assets/{wardleyDiagram-VM6X3IG4-Bt7NGXJX.js → wardleyDiagram-VM6X3IG4-B5muPnst.js} +1 -1
  103. package/web/assets/{xychartDiagram-S5SC5T6Z-BsgyT9WB.js → xychartDiagram-S5SC5T6Z-DSaMhJlg.js} +1 -1
  104. package/web/index.html +2 -2
  105. package/web/sw.js +1 -1
  106. package/web/assets/architecture-7GRP2DOG-DUHnGffN.js +0 -1
  107. package/web/assets/channel-vvvNMjoB.js +0 -1
  108. package/web/assets/classDiagram-ZZMXUADV-C3eKmWom.js +0 -1
  109. package/web/assets/classDiagram-v2-VYDZK3BY-C3eKmWom.js +0 -1
  110. package/web/assets/eventmodeling-NTZA5JFV-ChDRNWM1.js +0 -1
  111. package/web/assets/flowDiagram-HODETNUW-PojVb4l3.js +0 -1
  112. package/web/assets/mount-CK5h6xJ4.js +0 -27
  113. package/web/assets/railroad-abnf-HS5TGJTU-DIF_YU01.js +0 -1
  114. package/web/assets/railroad-ebnf-LZEXJU2U-BoHyJK5s.js +0 -1
  115. package/web/assets/railroad-peg-WCYAUIDC-uG1Bcrgs.js +0 -1
  116. package/web/assets/stateDiagram-v2-MP3YSRHH-C1t9bUgR.js +0 -1
  117. package/web/assets/swimlanesDiagram-VR7AAH4N-Dp35pflJ.js +0 -8
@@ -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
+ }
@@ -23,6 +23,7 @@ const REVISION_PATH = "/__bivy/revision";
23
23
  const INSPECTOR_PATH = "/__bivy/inspector.js";
24
24
  const COMPARE_PATH = "/__bivy/compare";
25
25
  const NOTES_PATH = "/__bivy/notes";
26
+ const SERVER_ERRORS_PATH = "/__bivy/server-errors";
26
27
  const SCENARIOS_PATH = "/__bivy/scenarios";
27
28
  const SCENARIO_PATH = "/__bivy/scenario";
28
29
  const scenarioKind = (entry) => entry.target.kind === "display" ? "desktop" : "web";
@@ -518,6 +519,10 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
518
519
  this.compare(req, res, entry);
519
520
  return;
520
521
  }
522
+ if (req.url.startsWith(SERVER_ERRORS_PATH) && req.method === "GET") {
523
+ this.serverErrors(req, res, entry, session);
524
+ return;
525
+ }
521
526
  if (req.url === SCENARIOS_PATH && req.method === "GET") {
522
527
  this.scenarios(res, entry, session);
523
528
  return;
@@ -545,6 +550,15 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
545
550
  if (rule && await simulate(req, res, rule, responseHeaders({}, this.ancestors(id))))
546
551
  return;
547
552
  const inspect = isPageLoad(req);
553
+ // What the person does in the preview marks the app's logs ("your last
554
+ // action"): opening a page, or sending something. Assets don't count.
555
+ const pathOnly = req.url.split("?")[0].slice(0, 200);
556
+ if (inspect) {
557
+ entry.loadedAt = Date.now();
558
+ this.registry.emit("action", id, `Opened ${pathOnly}`);
559
+ }
560
+ else if (!["GET", "HEAD", "OPTIONS"].includes(req.method ?? ""))
561
+ this.registry.emit("action", id, `${req.method} ${pathOnly} from the preview`);
548
562
  // Remember the framed page so a turn reload lands where the user was.
549
563
  if (req.method === "GET" && req.headers["sec-fetch-dest"] === "iframe" && req.url.length <= 2048)
550
564
  entry.lastPath = req.url;
@@ -885,6 +899,22 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
885
899
  res.writeHead(200, { "content-type": "application/json" });
886
900
  res.end(JSON.stringify({ screenshot: Boolean(shot) }));
887
901
  }
902
+ /** Server errors for the shell's Console: since the page loaded ("page"),
903
+ * or since the last answer's `now`. The owner's only: a server's log is not
904
+ * for people with a shared link. */
905
+ serverErrors(req, res, entry, session) {
906
+ const cors = { "access-control-allow-origin": this.shellOrigin(entry.view.id), "access-control-allow-credentials": "true", vary: "Origin" };
907
+ if (session.reviewer) {
908
+ res.writeHead(403, cors);
909
+ res.end();
910
+ return;
911
+ }
912
+ const raw = new URL(req.url, "http://gateway").searchParams.get("since");
913
+ const since = raw === "page" || raw === null ? (entry.loadedAt ?? Date.now()) - 1 : Number(raw);
914
+ const found = Number.isFinite(since) ? this.registry.serverErrors?.(entry.view.id, since) : undefined;
915
+ res.writeHead(200, { ...cors, "content-type": "application/json", "cache-control": "no-store" });
916
+ res.end(JSON.stringify(found ?? { now: Date.now(), lines: [] }));
917
+ }
888
918
  /** The view's scenarios for the shell's switcher, and the one it's in: this
889
919
  * browser's for a web page, the app's for a desktop app. */
890
920
  scenarios(res, entry, session) {
@@ -132,7 +132,7 @@ body.badged #dock:not([data-edge="top"]) { bottom:calc(var(--space-2) + var(--sp
132
132
  <div id="dock">
133
133
  <section class="panel" id="console" hidden aria-labelledby="console-title">
134
134
  <h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
135
- <p class="muted" id="console-empty">No errors or warnings since this page loaded.</p>
135
+ <p class="muted" id="console-empty">No errors or warnings since this page loaded, from the page or its server.</p>
136
136
  <ul id="entries"></ul>
137
137
  <div class="panel-actions"><button class="btn sm" id="send-errors" disabled>Send to agent…</button></div>
138
138
  </section>
@@ -248,6 +248,7 @@ function show(data,launch){
248
248
  // Put them back where they were before an update they asked for.
249
249
  if(restore){const at=restore;restore=null;frame.contentWindow?.postMessage(Object.assign({type:'bivy:restore'},at),metadata.origin);}
250
250
  if(!watching){watching=true;latest=-1;shown=null;watch();firstHint();}else if(wake)wake();
251
+ if(!serverPolling)void pollServer();
251
252
  };
252
253
  // A Bivy client framing the shell says whether it can take dictation.
253
254
  if(embedded&&data.returnTo)toBivy({type:'hello'});
@@ -597,18 +598,37 @@ function failScenario(s,message){
597
598
  $('scn-stay').onclick=()=>{$('scn-fail').hidden=true;scnBtn.hidden?more.focus():scnBtn.focus();};
598
599
  // Console
599
600
  let entries=[];
600
- function resetConsole(){entries=[];renderConsole();}
601
+ function resetConsole(){entries=[];serverSince='page';renderConsole();}
602
+ // The server's errors join the page's: what the app's server logged since this
603
+ // page loaded (Bivy follows the output of servers it runs). The owner's only.
604
+ let serverSince='page',serverPolling=false;
605
+ async function pollServer(){
606
+ serverPolling=true;
607
+ if(metadata&&!metadata.reviewer&&metadata.inspect!==false){
608
+ try{
609
+ const r=await fetch(metadata.origin+'/__bivy/server-errors?since='+serverSince,{credentials:'include',cache:'no-store'});
610
+ if(r.ok){
611
+ const d=await r.json();
612
+ for(const line of (Array.isArray(d.lines)?d.lines:[]).slice(-20))entries.push({level:'error',source:'server',text:String(line.text).slice(0,500)});
613
+ if(entries.length>50)entries.splice(0,entries.length-50);
614
+ if(d.lines?.length)renderConsole();
615
+ if(Number.isFinite(d.now))serverSince=String(d.now);
616
+ }
617
+ }catch{}
618
+ }
619
+ setTimeout(()=>void pollServer(),document.hidden?10000:3000);
620
+ }
601
621
  function renderConsole(){
602
622
  const errors=entries.filter(e=>e.level==='error').length,count=$('error-count');
603
623
  count.hidden=!errors;count.textContent=String(errors);
604
624
  // The number is shown once, on the pill, so it is visible without opening it.
605
625
  $('more').setAttribute('aria-label',errors?'Preview options, '+errors+' error'+(errors===1?'':'s'):'Preview options');
606
626
  $('console-empty').hidden=entries.length>0;$('send-errors').disabled=!entries.length;
607
- $('entries').replaceChildren(...entries.map(e=>{const li=document.createElement('li');const tag=document.createElement('span');tag.className='badge';tag.dataset.tone=e.level==='error'?'danger':'warn';tag.textContent=e.level;const text=document.createElement('span');text.textContent=e.text;li.append(tag,text);return li;}));
627
+ $('entries').replaceChildren(...entries.map(e=>{const li=document.createElement('li');const tag=document.createElement('span');tag.className='badge';tag.dataset.tone=e.level==='error'?'danger':'warn';tag.textContent=e.source==='server'?'server':e.level;const text=document.createElement('span');text.textContent=e.text;li.append(tag,text);return li;}));
608
628
  }
609
629
  $('errors').onclick=()=>panels($('console').hidden?'console':null);
610
630
  $('clear').onclick=resetConsole;
611
- $('send-errors').onclick=()=>draft('Console output in the app preview "'+metadata.name+'" (page '+currentPath+'):\\n'+entries.slice(-20).map(e=>'- '+e.level+': '+e.text).join('\\n'));
631
+ $('send-errors').onclick=()=>draft('Console output in the app preview "'+metadata.name+'" (page '+currentPath+'):\\n'+entries.slice(-20).map(e=>'- '+(e.source==='server'?'server error':e.level)+': '+e.text).join('\\n'));
612
632
  // Marking: one gesture, two results. Pointing at an element and circling an
613
633
  // area were two modes with a button each; they are one mode now, because they
614
634
  // are one intention — "this, here".
@@ -98,9 +98,60 @@ function command(spec, workspace, label) {
98
98
  * preview addresses survive restarts. Views whose server Bivy doesn't own —
99
99
  * a service without `start` — are not restored: a reused local port could
100
100
  * belong to anything by then. Detection offers them again. */
101
+ /** A folder for a backend view's files: inside the workspace, never above it. */
102
+ function inside(workspace, dir, fallback) {
103
+ const relative = dir === undefined ? fallback : dir;
104
+ if (typeof relative !== "string" || !relative || relative.length > 300 || path.isAbsolute(relative))
105
+ throw new Error("A backend view's dir is a folder inside the workspace, like .bivy/requests.");
106
+ const resolved = path.resolve(workspace, relative);
107
+ const from = path.relative(path.resolve(workspace), resolved);
108
+ if (from === ".." || from.startsWith(`..${path.sep}`))
109
+ throw new Error("A backend view's dir must be inside the workspace.");
110
+ return resolved;
111
+ }
112
+ function backendTarget(spec, workspace) {
113
+ if (spec.kind === "requests") {
114
+ const base = spec.base;
115
+ if (base !== undefined) {
116
+ if (typeof base.url === "string") {
117
+ const url = new URL(base.url);
118
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
119
+ throw new Error("A requests view's base.url is an http(s) address without credentials.");
120
+ return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests"), base: { url: url.href.replace(/\/$/, "") } };
121
+ }
122
+ if (typeof base.view !== "string" || !base.view)
123
+ throw new Error("A requests view's base is {\"url\": …} or {\"view\": \"<web view name>\"}.");
124
+ return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests"), base: { view: base.view } };
125
+ }
126
+ return { kind: "requests", dir: inside(workspace, spec.dir, ".bivy/requests") };
127
+ }
128
+ if (spec.kind === "data")
129
+ return { kind: "data", dir: inside(workspace, spec.dir, ".bivy/queries"), ...command(spec, workspace, "Data view") };
130
+ const source = spec.source;
131
+ if (!source)
132
+ return { kind: "logs", source: {} };
133
+ if (typeof source.view === "string")
134
+ return { kind: "logs", source: { view: source.view } };
135
+ // A file is followed the way a person would: tail -F, from the last lines.
136
+ if (typeof source.file === "string")
137
+ return { kind: "logs", source: { command: "tail", args: ["-n", "200", "-F", path.relative(workspace, inside(workspace, source.file, ""))], workspace: fs.realpathSync(workspace) } };
138
+ return { kind: "logs", source: command(source, workspace, "Logs view") };
139
+ }
140
+ /** Where a backend view reads from, in words, for its row in the Apps sheet. */
141
+ function backendDetail(target) {
142
+ const line = (spec) => [spec.command, ...spec.args].join(" ").slice(0, 120);
143
+ if (target.kind === "requests")
144
+ return `${path.basename(target.dir)} · ${target.base && "url" in target.base ? target.base.url : target.base ? target.base.view : "the app's server"}`;
145
+ if (target.kind === "data")
146
+ return line(target);
147
+ return "command" in target.source ? line(target.source) : target.source.view ? `${target.source.view} output` : "the app's server output";
148
+ }
101
149
  export class AppRegistry extends EventEmitter {
102
150
  reservedPorts;
103
151
  file;
152
+ /** Errors a web view's server logged since `since` (set by the backend
153
+ * views, which follow server output; asked by the gateway for the Console). */
154
+ serverErrors;
104
155
  apps = new Map();
105
156
  views = new Map();
106
157
  persisted = new Map();
@@ -198,8 +249,12 @@ export class AppRegistry extends EventEmitter {
198
249
  target = { kind: "display", ...command(spec, workspace, "Display view"), restartOnChange: spec.restartOnChange === true };
199
250
  view = { ...base, kind: "web", source: "display", managed: true };
200
251
  }
252
+ else if (spec.kind === "requests" || spec.kind === "data" || spec.kind === "logs") {
253
+ target = backendTarget(spec, workspace);
254
+ view = { ...base, kind: "backend", backend: spec.kind, detail: backendDetail(target) };
255
+ }
201
256
  else
202
- throw new Error("Unsupported app view. Supported views: web, terminal and display.");
257
+ throw new Error("Unsupported app view. Supported views: web, terminal, display, requests, data and logs.");
203
258
  app.views.push(view);
204
259
  const stored = restore?.notes?.[view.id];
205
260
  const notes = view.kind === "web" && Array.isArray(stored) ? stored.filter(isNote).slice(-MAX_NOTES) : [];
@@ -13,6 +13,7 @@ import { captureFrame, encodePng, sendInput } from "./rfb.js";
13
13
  import { inputEvents, readAction } from "./input.js";
14
14
  import { pressMenu, readMenuPath, readMenus } from "./menu.js";
15
15
  import { pngSize } from "./review.js";
16
+ import { Backend } from "./backend/index.js";
16
17
  import { SCENARIO_DIR, loadScenarios, planScenario, scenariosFor, summarize } from "./scenarios.js";
17
18
  import { startScenarioProxy } from "./scenario-proxy.js";
18
19
  const scenarioKind = (entry) => entry.target.kind === "display" ? "desktop" : "web";
@@ -71,6 +72,8 @@ export class AppService {
71
72
  proxies = new Map();
72
73
  /** Screenshots in progress, so a card, Compare and a baseline share one browser run. */
73
74
  inflight = new Map();
75
+ /** Requests, data and logs views: what a backend change did. */
76
+ backend;
74
77
  constructor(registry, gateway, terminals, options = {}) {
75
78
  this.registry = registry;
76
79
  this.gateway = gateway;
@@ -83,6 +86,7 @@ export class AppService {
83
86
  this.reviews = options.reviews ?? NO_REVIEWS;
84
87
  this.pinSink = options.pins ?? NO_PINS;
85
88
  this.settleMs = options.settleMs ?? COMPARE_SETTLE_MS;
89
+ this.backend = new Backend(registry, terminals);
86
90
  registry.desktopScenario = (viewId, scenario) => this.desktopScenario(viewId, scenario);
87
91
  }
88
92
  /** A session's apps, or every app on this machine when no session is given. */
@@ -200,11 +204,16 @@ export class AppService {
200
204
  const views = this.reviewable(sessionId);
201
205
  const run = { start: new Map(views.map((entry) => [entry.view.id, entry.revision])), baseline: new Map(), reviews: new Map(), muted: false, ended: false };
202
206
  this.runs.set(sessionId, run);
207
+ if (this.backend.views(sessionId).length)
208
+ run.backend = this.backend.baseline(sessionId).catch(() => { });
209
+ // Settles when the backend's "before" is taken (tests wait on it; the server doesn't need to).
210
+ const ready = run.backend ?? Promise.resolve();
203
211
  if (!this.screenshots.enabled())
204
- return;
212
+ return ready;
205
213
  for (const entry of views)
206
214
  void this.shotOf(entry).then((png) => { if (png && entry.revision === run.start.get(entry.view.id))
207
215
  run.baseline.set(entry.view.id, png); });
216
+ return ready;
208
217
  }
209
218
  /** The run ended (done, or waiting for the user). Views whose revision
210
219
  * changed are screenshotted and compared with the page before the run; a
@@ -216,6 +225,12 @@ export class AppService {
216
225
  return undefined;
217
226
  run.ended = true;
218
227
  let latest;
228
+ // Backend views run again; what changed is evidence, on the app's card.
229
+ let evidence = new Map();
230
+ if (run.backend) {
231
+ await run.backend;
232
+ evidence = await this.backend.evidence(sessionId).catch(() => evidence);
233
+ }
219
234
  const changed = [...run.start].map(([id, revision]) => ({ entry: this.registry.getView(id), revision })).filter(({ entry, revision }) => entry && entry.revision !== revision);
220
235
  if (changed.length && this.screenshots.enabled() && !run.muted)
221
236
  await new Promise((r) => setTimeout(r, this.settleMs));
@@ -238,7 +253,7 @@ export class AppService {
238
253
  const presented = current && run.reviews.get(entry.app.id) === current.id;
239
254
  if (!shouldReview({ trigger: "run", mode, muted: run.muted, revisionChanged: true, change }) && !(presented && after))
240
255
  continue;
241
- latest = this.review(entry, run, { trigger: presented ? current.trigger : "run", note: presented ? current.note : undefined, try: presented ? current.try : undefined, path: entry.lastPath ?? "/", shot: after, before });
256
+ latest = this.review(entry, run, { trigger: presented ? current.trigger : "run", note: presented ? current.note : undefined, try: presented ? current.try : undefined, path: entry.lastPath ?? "/", shot: after, before, evidence: evidence.get(entry.app.id)?.changed ? evidence.get(entry.app.id).rows : undefined });
242
257
  }
243
258
  // Reviewer notes that arrived since the last hand-over ride on the view's
244
259
  // card for this run, or get a card of their own. A count only: the notes
@@ -263,6 +278,14 @@ export class AppService {
263
278
  latest ??= card;
264
279
  }
265
280
  }
281
+ // A backend-only change: the evidence is the card.
282
+ for (const [appId, found] of evidence) {
283
+ if (!found.changed || run.muted || run.reviews.has(appId) || this.registry.reviewMode(appId) === "off")
284
+ continue;
285
+ const entry = this.registry.getView(found.rows[0].viewId);
286
+ if (entry)
287
+ latest = this.review(entry, run, { trigger: "run", path: "/", evidence: found.rows });
288
+ }
266
289
  if (!latest) {
267
290
  const id = [...run.reviews.values()].at(-1);
268
291
  latest = [...this.latest.values()].find((review) => review.id === id);
@@ -776,6 +799,7 @@ export class AppService {
776
799
  throw new Error("App was removed while starting.");
777
800
  }
778
801
  server.termId = termId;
802
+ this.backend.attach(id, termId);
779
803
  return termId;
780
804
  })
781
805
  .finally(() => { server.pending = undefined; });
@@ -818,6 +842,16 @@ export class AppService {
818
842
  void server.pending?.then((termId) => this.terminals.close(termId)).catch(() => { });
819
843
  this.servers.delete(id);
820
844
  }
845
+ /** A backend view of a session, by exact IDs (the app) or by name (`bivy app requests`). */
846
+ backendView(sessionId, kind, input) {
847
+ if (input.appId && input.viewId) {
848
+ const entry = this.registry.requireView(sessionId, input.appId, input.viewId);
849
+ if (entry.target.kind !== kind)
850
+ throw new Error(`That isn't a ${kind} view.`);
851
+ return entry;
852
+ }
853
+ return this.backend.pick(sessionId, kind, input.target);
854
+ }
821
855
  share(sessionId, appId, viewId, options = {}) {
822
856
  if (this.registry.requireView(sessionId, appId, viewId).view.kind !== "web")
823
857
  throw new Error("Only web views have preview links.");
@@ -882,6 +916,7 @@ export class AppService {
882
916
  remove(sessionId, appId) {
883
917
  const app = this.registry.require(sessionId, appId);
884
918
  this.registry.remove(sessionId, appId);
919
+ this.backend.forget(app.views.map((view) => view.id), app.id);
885
920
  for (const view of app.views) {
886
921
  this.gateway?.revoke(view.id);
887
922
  this.stopServer(view.id);
@@ -3243,6 +3243,14 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3243
3243
  .review-side .seg-btn { min-block-size: 44px; min-inline-size: 72px; }
3244
3244
  .review-banner { margin: 0 var(--space-3); }
3245
3245
  .review-note { margin: var(--space-3) var(--space-3) 0; padding-inline-start: var(--space-3); border-inline-start: 2px solid var(--line-strong); color: var(--ink); font-size: var(--text-sm); white-space: pre-wrap; }
3246
+ /* Backend evidence on a review card: one line per thing a run changed. */
3247
+ .review-evidence { list-style: none; margin: 0 var(--space-3); padding: 0; border-top: 1px solid var(--line); }
3248
+ .review-evidence-row { display: grid; grid-template-columns: 4.5rem minmax(0, 1fr) auto; align-items: baseline; gap: var(--space-2); inline-size: 100%; min-block-size: var(--space-7); padding: var(--space-2) 0; border: 0; border-bottom: 1px solid var(--line); background: none; color: var(--ink); font: inherit; font-size: var(--text-sm); text-align: start; cursor: pointer; }
3249
+ .review-evidence li:last-child .review-evidence-row { border-bottom: 0; }
3250
+ .review-evidence-row:hover { background: var(--surface-2); }
3251
+ .review-evidence-kind { color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
3252
+ .review-evidence-text { display: flex; flex-wrap: wrap; gap: 0 var(--space-2); min-inline-size: 0; overflow-wrap: anywhere; }
3253
+ .review-evidence-text code { color: var(--muted); font-family: var(--font-mono); font-size: var(--text-xs); }
3246
3254
  /* "Try it": scenarios the agent offered, each opening the live preview in that state. */
3247
3255
  .review-try { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); margin: var(--space-3) var(--space-3) 0; }
3248
3256
  .review-try-label { margin-inline-end: var(--space-1); color: var(--muted); font-size: var(--text-sm); }
@@ -3278,6 +3286,64 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3278
3286
  .apps-note { display: flex; flex-direction: column; gap: 2px; padding-inline-start: var(--space-3); border-inline-start: 2px solid var(--line-strong); }
3279
3287
  .apps-note-text { color: var(--ink); font-size: var(--text-sm); }
3280
3288
  .apps-notes-actions { display: flex; gap: var(--space-2); }
3289
+ /* Backend views (BackendView.tsx): Requests, Data and Logs. Rows reuse the
3290
+ Apps sheet's density; answers and rows are the app's own text, in mono. */
3291
+ .backend-view { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-3) var(--space-4) var(--space-5); }
3292
+ .backend-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2); }
3293
+ .backend-source, .backend-meta { color: var(--muted); font-size: var(--text-xs); overflow-wrap: anywhere; }
3294
+ .backend-meta code, .backend-foot code, .backend-row-sub { font-family: var(--font-mono); }
3295
+ .backend-back { align-self: flex-start; }
3296
+ .backend-group { display: flex; flex-direction: column; gap: var(--space-2); }
3297
+ .backend-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
3298
+ .backend-row { display: flex; align-items: center; gap: var(--space-2); inline-size: 100%; min-block-size: var(--space-7); box-sizing: border-box; padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); color: var(--ink); font: inherit; text-align: start; cursor: pointer; }
3299
+ .backend-row:hover { background: var(--surface-2); }
3300
+ .backend-method { flex: none; min-inline-size: 3.25rem; font-family: var(--font-mono); font-size: var(--text-xs); font-weight: var(--weight-bold); color: var(--accent); }
3301
+ .backend-method[data-method="GET"], .backend-method[data-method="HEAD"] { color: var(--ok); }
3302
+ .backend-method[data-method="DELETE"] { color: var(--danger); }
3303
+ .backend-row-text { display: flex; flex-direction: column; flex: 1; min-inline-size: 0; gap: 2px; }
3304
+ .backend-row-name { font-size: var(--text-sm); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
3305
+ .backend-row-sub { color: var(--muted); font-size: var(--text-xs); overflow-wrap: anywhere; }
3306
+ .backend-foot, .backend-empty { margin: 0; color: var(--muted); font-size: var(--text-xs); }
3307
+ .backend-empty { font-size: var(--text-sm); }
3308
+ .backend-detail-head { display: flex; align-items: flex-start; gap: var(--space-2); }
3309
+ .backend-status { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); }
3310
+ .backend-switch { align-self: flex-start; }
3311
+ .backend-body { margin: 0; max-block-size: 50vh; overflow: auto; padding: var(--space-3); border-radius: var(--radius-md); background: var(--surface-2); color: var(--ink); font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
3312
+ .backend-request summary { color: var(--muted); font-size: var(--text-sm); cursor: pointer; margin-block-end: var(--space-2); }
3313
+ .backend-changes { list-style: none; margin: 0; padding: 0; border-radius: var(--radius-md); background: var(--surface-2); font-family: var(--font-mono); font-size: var(--text-xs); }
3314
+ .backend-changes li { display: flex; flex-wrap: wrap; gap: var(--space-1) var(--space-2); padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
3315
+ .backend-changes li:last-child { border-bottom: 0; }
3316
+ .backend-path { color: var(--muted); }
3317
+ .backend-was { color: var(--del); text-decoration: line-through; }
3318
+ .backend-now { color: var(--add); }
3319
+ .backend-gone { color: var(--del); }
3320
+ .backend-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); }
3321
+ .backend-actions .btn { min-block-size: var(--space-7); }
3322
+ .backend-query { display: flex; flex-direction: column; gap: var(--space-2); padding-block-start: var(--space-3); border-top: 1px solid var(--line); }
3323
+ .backend-query-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-2); }
3324
+ .backend-query-head h3 { margin: 0; font-size: var(--text-base); }
3325
+ .backend-counts { font-family: var(--font-mono); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
3326
+ .backend-counts [data-tone="ok"] { color: var(--ok); } .backend-counts [data-tone="warn"] { color: var(--warn); } .backend-counts [data-tone="danger"] { color: var(--danger); }
3327
+ .backend-rowcard { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: 1px solid var(--line); border-inline-start: 3px solid var(--line-strong); border-radius: var(--radius-md); }
3328
+ .backend-rowcard[data-kind="added"] { border-inline-start-color: var(--ok); }
3329
+ .backend-rowcard[data-kind="changed"] { border-inline-start-color: var(--warn); }
3330
+ .backend-rowcard[data-kind="removed"] { border-inline-start-color: var(--danger); }
3331
+ .backend-fields { display: grid; grid-template-columns: minmax(5rem, max-content) minmax(0, 1fr); gap: 2px var(--space-3); margin: 0; font-size: var(--text-xs); }
3332
+ .backend-fields div { display: contents; }
3333
+ .backend-fields dt { color: var(--muted); }
3334
+ .backend-fields dd { margin: 0; font-family: var(--font-mono); overflow-wrap: anywhere; }
3335
+ .backend-table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-md); }
3336
+ .backend-table { border-collapse: collapse; inline-size: 100%; font-size: var(--text-xs); }
3337
+ .backend-table th, .backend-table td { padding: var(--space-1) var(--space-2); border-bottom: 1px solid var(--line); text-align: start; white-space: nowrap; }
3338
+ .backend-table th { position: sticky; top: 0; background: var(--surface-2); color: var(--muted); font-weight: var(--weight-semibold); }
3339
+ .backend-table td { font-family: var(--font-mono); }
3340
+ .backend-log { list-style: none; margin: 0; padding: var(--space-2) 0; max-block-size: 60vh; overflow: auto; border-radius: var(--radius-md); background: var(--surface-2); font-family: var(--font-mono); font-size: var(--text-xs); line-height: 1.6; }
3341
+ .backend-line { padding: 1px var(--space-3); white-space: pre-wrap; overflow-wrap: anywhere; }
3342
+ .backend-line[data-level="error"] { background: var(--danger-soft); }
3343
+ .backend-line[data-level="warn"] { color: var(--warn); }
3344
+ .backend-line-time { color: var(--muted); }
3345
+ .backend-mark { display: flex; align-items: center; gap: var(--space-2); margin: var(--space-2) 0; padding: 0 var(--space-3); color: var(--accent); font-family: var(--font-sans); font-weight: var(--weight-semibold); scroll-margin-block-start: var(--space-2); }
3346
+ .backend-mark::before, .backend-mark::after { content: ""; flex: 1; block-size: 1px; background: color-mix(in srgb, var(--accent) 40%, var(--line)); }
3281
3347
  .apps-empty { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); padding: var(--space-6) var(--space-4); color: var(--muted); font-size: var(--text-sm); text-align: center; }
3282
3348
  .apps-empty strong { color: var(--ink); font-size: var(--text-base); }
3283
3349
  .apps-empty p { margin: 0; max-inline-size: 34ch; }
@@ -1,6 +1,12 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-only
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  export const REVIEW_CARD_MODES = ["ready", "every", "off"];
4
+ const isEvidence = (row) => {
5
+ const r = row;
6
+ return !!r && typeof r.viewId === "string" && typeof r.view === "string" && ["requests", "data", "logs"].includes(r.backend)
7
+ && typeof r.summary === "string" && r.summary.length <= 300 && (r.detail === undefined || (typeof r.detail === "string" && r.detail.length <= 300))
8
+ && ["ok", "warn", "danger", "neutral"].includes(r.tone) && (r.item === undefined || typeof r.item === "string");
9
+ };
4
10
  export const APP_REVIEW_BLOCK = "bivy_app_review";
5
11
  const isShot = (value) => {
6
12
  const shot = value;
@@ -16,6 +22,7 @@ export function isAppReview(value) {
16
22
  && (review.shot === undefined || isShot(review.shot)) && (review.before === undefined || isShot(review.before))
17
23
  && (review.note === undefined || typeof review.note === "string")
18
24
  && (review.notes === undefined || (Number.isInteger(review.notes) && review.notes >= 0))
25
+ && (review.evidence === undefined || (Array.isArray(review.evidence) && review.evidence.length <= 12 && review.evidence.every(isEvidence)))
19
26
  && (review.try === undefined || (Array.isArray(review.try) && review.try.length <= 6 && review.try.every((item) => !!item && typeof item.id === "string" && /^[a-z0-9][a-z0-9-]{0,63}$/.test(item.id) && typeof item.name === "string" && item.name.length <= 80)));
20
27
  }
21
28
  export const APP_PIN_STATES = ["open", "changed", "gone", "done"];
@@ -9,6 +9,7 @@ export function createAppCommands(service, workspaceFor, published = () => { },
9
9
  throw new Error("Open the session on this machine before publishing an app.");
10
10
  return workspace;
11
11
  };
12
+ const pick = (msg) => ({ appId: typeof msg.appId === "string" ? msg.appId : undefined, viewId: typeof msg.viewId === "string" ? msg.viewId : undefined, target: typeof msg.target === "string" ? msg.target : undefined });
12
13
  const operations = {
13
14
  "apps.list": (msg) => service.list(typeof msg.sessionId === "string" ? msg.sessionId : undefined),
14
15
  "apps.publish": (msg) => {
@@ -43,6 +44,15 @@ export function createAppCommands(service, workspaceFor, published = () => { },
43
44
  "apps.agentNotes": (msg) => service.setAgentNotes(String(msg.sessionId), String(msg.appId), msg.enabled === true),
44
45
  "apps.notes": (msg) => service.notes(String(msg.sessionId), { app: typeof msg.appId === "string" ? msg.appId : undefined, view: typeof msg.view === "string" ? msg.view : undefined, since: typeof msg.since === "number" ? msg.since : undefined }),
45
46
  "apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
47
+ // Backend views. The app passes exact IDs; `bivy app requests|data|logs` a name, or nothing.
48
+ "apps.requests": (msg) => service.backend.requests(service.backendView(String(msg.sessionId), "requests", pick(msg))),
49
+ "apps.request": (msg) => service.backend.detail(service.backendView(String(msg.sessionId), "requests", pick(msg)), String(msg.id)),
50
+ "apps.runRequest": (msg) => {
51
+ const entry = service.backendView(String(msg.sessionId), "requests", pick(msg));
52
+ return typeof msg.id === "string" ? service.backend.run(entry, msg.id) : service.backend.runAuto(entry, "last", "Ran all requests");
53
+ },
54
+ "apps.data": (msg) => service.backend.data(service.backendView(String(msg.sessionId), "data", pick(msg)), msg.run === true),
55
+ "apps.serverLog": (msg) => service.backend.log(service.backendView(String(msg.sessionId), "logs", pick(msg)), typeof msg.since === "number" ? msg.since : 0),
46
56
  // The app UI passes exact IDs; `bivy app share` passes an app and/or view by ID or name.
47
57
  "apps.share": (msg) => {
48
58
  const options = { duration: msg.duration, controls: msg.controls !== false };
@@ -3,7 +3,7 @@
3
3
  /** Direct-mode adapters for operations whose canonical implementation is the
4
4
  * client command registry. Relay and HTTP now differ only in framing. */
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
- ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "input", "menu", "present", "scenarios", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "unshare", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "offers", "adopt", "open", "logs", "requests", "request", "runRequest", "data", "serverLog", "shot", "input", "menu", "present", "scenarios", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "agentNotes", "notes", "share", "unshare", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
7
  { method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
8
8
  { method: "post", path: "/api/access/get", kind: "access.get" },
9
9
  { method: "post", path: "/api/tailnet/machines", kind: "tailnet.machines" },
@@ -4,6 +4,8 @@ import { Type } from "typebox";
4
4
  import { SHARE_DURATIONS } from "../apps/types.js";
5
5
  const request = { requestId: Type.Optional(Type.String()) };
6
6
  const session = { sessionId: Type.String() };
7
+ /** A backend view by exact IDs (the app), or by app or view name (`bivy app requests`). */
8
+ const backendView = { appId: Type.Optional(Type.String({ maxLength: 200 })), viewId: Type.Optional(Type.String({ maxLength: 200 })), target: Type.Optional(Type.String({ maxLength: 200 })) };
7
9
  const optionalSession = { sessionId: Type.Optional(Type.String()) };
8
10
  /** Declarative validation at the client-command boundary. A command absent from
9
11
  * this table remains migration-compatible; adding validation is one data row. */
@@ -36,6 +38,11 @@ export const CLIENT_COMMAND_SCHEMAS = {
36
38
  "apps.agentNotes": Type.Object({ ...request, ...session, appId: Type.String(), enabled: Type.Boolean() }),
37
39
  "apps.notes": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), view: Type.Optional(Type.String({ maxLength: 200 })), since: Type.Optional(Type.Number()) }),
38
40
  "apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
41
+ "apps.requests": Type.Object({ ...request, ...session, ...backendView }),
42
+ "apps.request": Type.Object({ ...request, ...session, ...backendView, id: Type.String({ maxLength: 200 }) }),
43
+ "apps.runRequest": Type.Object({ ...request, ...session, ...backendView, id: Type.Optional(Type.String({ maxLength: 200 })) }),
44
+ "apps.data": Type.Object({ ...request, ...session, ...backendView, run: Type.Optional(Type.Boolean()) }),
45
+ "apps.serverLog": Type.Object({ ...request, ...session, ...backendView, since: Type.Optional(Type.Number()) }),
39
46
  "apps.share": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String({ maxLength: 200 })), viewId: Type.Optional(Type.String()), view: Type.Optional(Type.String({ maxLength: 200 })),
40
47
  duration: Type.Optional(Type.Union(SHARE_DURATIONS.map((row) => Type.Literal(row.id)))), controls: Type.Optional(Type.Boolean()) }),
41
48
  "apps.unshare": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
package/dist/server.js CHANGED
@@ -1196,7 +1196,7 @@ function harnessDirFor(record) {
1196
1196
  * waits on git. Never throws into the prompt path. */
1197
1197
  function harnessBeginTurn(record) {
1198
1198
  // A run starts: review cards measure what the app looked like before it.
1199
- appService.runStarted(record.id);
1199
+ void appService.runStarted(record.id);
1200
1200
  record.runReview = undefined;
1201
1201
  const dir = harnessDirFor(record);
1202
1202
  record.harnessTurnReady = undefined;
@@ -2278,6 +2278,7 @@ const appService = new AppService(appRegistry, appGateway ?? remotePreview, {
2278
2278
  },
2279
2279
  has: (id) => terminals.has(id),
2280
2280
  close: (id) => { terminals.close(id); },
2281
+ tap: (id, onData) => terminals.tap(id, onData) ?? undefined,
2281
2282
  }, {
2282
2283
  scan: async (workspace) => notAgentServers(await scanListeners(workspace)),
2283
2284
  scanMachine: async (root) => notAgentServers(await scanMachineListeners(root)),