@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.
- package/bin/bivy.mjs +39 -5
- package/bin/codex-app-server-shim.mjs +78 -27
- package/dist/agent-instructions.js +2 -1
- package/dist/agents/claude-code/runtime.js +2 -1
- package/dist/agents/profiles.js +9 -6
- package/dist/apps/gateway.js +91 -3
- package/dist/apps/inspector.js +56 -1
- package/dist/apps/preview-shell.js +197 -5
- package/dist/apps/registry.js +4 -1
- package/dist/apps/scenario-proxy.js +65 -0
- package/dist/apps/scenarios.js +307 -0
- package/dist/apps/service.js +111 -7
- package/dist/apps/styles.css +4 -0
- package/dist/apps/types.js +2 -1
- package/dist/controllers/app-commands.js +3 -2
- package/dist/exec.js +4 -1
- package/dist/node-env.js +6 -0
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +3 -2
- package/dist/runtime/codex-sessions.js +158 -6
- package/dist/runtime/credential-provisioning.js +5 -0
- package/dist/runtime/grok-sessions.js +4 -2
- package/dist/runtime/index.js +26 -19
- package/dist/runtime/inherited-session-env.js +54 -0
- package/dist/runtime/model-ref.js +14 -1
- package/dist/runtime/opencode-auth.js +67 -0
- package/dist/runtime/opencode-preflight.js +10 -1
- package/dist/runtime/protocol.js +35 -1
- package/dist/runtime/tool-call-map.js +67 -3
- package/dist/server.js +49 -15
- package/dist/session/event-log.js +76 -5
- package/dist/session/fork-standup.js +1 -1
- package/dist/session/fork.js +6 -4
- package/dist/session/transcript-normal.js +52 -0
- package/dist/session/transcript-persistence.js +20 -3
- package/dist/session-ref.js +5 -2
- package/dist/worktree.js +25 -3
- package/package.json +1 -1
- package/web/assets/{AutomationsView-CJSYk0ja.js → AutomationsView-DuWxbo5B.js} +1 -1
- package/web/assets/{ChangesCard-Bp88fY6W.js → ChangesCard-BnHllUb0.js} +1 -1
- package/web/assets/{LibraryView-of9wmuzN.js → LibraryView-DpNuD-T7.js} +1 -1
- package/web/assets/{ProviderConnect-DG7SP4l2.js → ProviderConnect-4vjf567A.js} +1 -1
- package/web/assets/{Settings-DGH1U7wj.js → Settings-CHofnxyn.js} +3 -3
- package/web/assets/{Sheet-D-EHCMlj.js → Sheet-DwouI-Tt.js} +1 -1
- package/web/assets/{Terminal-qqysrwl_.js → Terminal-3mNEW7UT.js} +1 -1
- package/web/assets/{VoiceSettings-aeqQkwcZ.js → VoiceSettings-BI4B-zS1.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-Ct-lmBn5.js → abnfDiagram-VCTEODGH-DPP1TRyh.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-DUHnGffN.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-CCq716Da.js → architectureDiagram-5GKGNRK7-CvgInp30.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-Lka9F6ZB.js → blockDiagram-I7D4REHJ-CZAAYK-I.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-nvhhyzri.js → c4Diagram-7LVT6UL2-BYJlvZiZ.js} +1 -1
- package/web/assets/channel-vvvNMjoB.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-TZ5hhHBz.js → chunk-4HAMMTFA-Cj4-BAMD.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-IahKw8kM.js → chunk-75Z2AOVW-3eT-aS0t.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-BlcZUVOy.js → chunk-DU6HZSFF-D_4JI7OY.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-3MGfEgKu.js → chunk-F27PBJKO-5wTG02yk.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-Be72n678.js → chunk-GMAD6QVW-IliCLU5B.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-GOVLT6xm.js → chunk-GVQU2GXP-CB3DCzUu.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-ByOzewrT.js → chunk-IMKFNOWR-Bg3eY-xc.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-BCGhHmnW.js → chunk-L3NEJ4N5-BEMaPYT9.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-CAdliJE_.js → chunk-OSK3NFVY-DMc-FLYk.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-CM8cpgHa.js → chunk-P2QGCYS3-CaqPruPs.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-CXLPhaI1.js → chunk-POPQ4Y6H-3h-I9lV1.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-BegFrwDe.js → chunk-PWAF6VOD-CPLNMq3b.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-iXj7Y5RI.js → chunk-SHT3W25Y-ErtwLBXR.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-73pyTBZg.js → chunk-SVP7TREG-GNUjH0zF.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-D5zwuuGI.js → chunk-TICWLB2K-DtbyVpEE.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-C3eKmWom.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-C3eKmWom.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-D3nRr9S_.js → cynefin-OW5HDTMX-CQt68k-b.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-C4CKMYMZ.js → cynefinDiagram-5FMLGOSQ-BeEQcUmX.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-D-grkZ8X.js → dagre-GXQ25YYZ-CoekQT_h.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-BNPS3YQX.js → diagram-S7CK7UJ4-DQBF8H5Z.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-Bkp_TyRg.js → diagram-UQ7AKVKN-2Tw1WGQF.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-Dpxe_6MM.js → diagram-VSXAHHWV-C4Ns08EL.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-DwGBvGn-.js → diagram-VX7I27RA-CMUQSfon.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-D-KvONMO.js → diagram-Z3DM3KII-Lqvr8Yf6.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-Di_D-a-0.js → ebnfDiagram-PWID7BFC-BAYJ-dgh.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-2pnBfTU9.js → erDiagram-RLTQ6QDP-Dg8VYnq4.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-ChDRNWM1.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-PojVb4l3.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-DAkhMTlk.js → ganttDiagram-EL5Y4UJY-ywnauJ7D.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-BQvemX2W.js → gitGraph-4MIJSDKK-bYI8lc-g.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-Dx7ya_2q.js → gitGraphDiagram-WWUBYQGX-erBDwxX8.js} +1 -1
- package/web/assets/{index-BXPRG7Pj.css → index-Ch1zstZB.css} +1 -1
- package/web/assets/{index-CFX8FhqJ.js → index-DzT1Tnf2.js} +2 -2
- package/web/assets/{info-A6RAGUB7-DaDps2sq.js → info-A6RAGUB7-BZbsPdZJ.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-DBqMvPr2.js → infoDiagram-27XIBGKW-qqTv-8cL.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-DzciF3O_.js → ishikawaDiagram-5VMMS53U-v-e3YR9X.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-D9WHgGe3.js → journeyDiagram-3NMN7TZE-D61ZGndw.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-Diokj8By.js → kanban-definition-UXKFOSKX-NeYWFrdx.js} +1 -1
- package/web/assets/{line-DmBeTXFn.js → line-BNxdvAy9.js} +1 -1
- package/web/assets/{mermaid-parser.core-H_O1CweI.js → mermaid-parser.core-BjJ-qoss.js} +3 -3
- package/web/assets/{mermaid.core-BAni15Ed.js → mermaid.core-DT4QwB_n.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-B_tXdzEa.js → mindmap-definition-YA3MSWOX-BxuVgGhr.js} +1 -1
- package/web/assets/{mount-Fdy9Xoxs.js → mount-CK5h6xJ4.js} +7 -7
- package/web/assets/{packet-AYTQ26CC-Dt4bPH7I.js → packet-AYTQ26CC-DIff_vXS.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-loAbaKUZ.js → pegDiagram-XKGWAZYB-oDNo32c7.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-DEmhJN7M.js → pie-WAS4IAKB-C0cdo_NU.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-DfQvD2QY.js → pieDiagram-E7YTZNPT-BbThivUY.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-Bhqo2rT-.js → quadrantDiagram-AXDQQJYC-CONfSoMt.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-BmV5tXie.js → radar-RG4KPBEZ-teYstrbs.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-DoebyExe.js → railroad-74A4TZTK-B4VusJHl.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-DIF_YU01.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-BoHyJK5s.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-uG1Bcrgs.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-BhlfIpCW.js → railroadDiagram-O6MQD6OU-CKrYutQ_.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-vkxH8U-2.js → requirementDiagram-BXWQKSXE-COt2S2Y4.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-D2iFl0bc.js → sankeyDiagram-P5KCCOFB-D72oeNbf.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-Ll_m9w0-.js → sequenceDiagram-WJ2MYXX4-DFpVcNjb.js} +1 -1
- package/web/assets/{settingsRoute-C8p10bxH.js → settingsRoute-Clztmzl_.js} +1 -1
- package/web/assets/{stateDiagram-D77RDMKH-B4KWuRz2.js → stateDiagram-D77RDMKH-BKW8l6Ru.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-C1t9bUgR.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-CVXwQL4b.js → swimlanes-42K2YHIH-GRXgsapd.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-Dp35pflJ.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-C0XuGVkJ.js → timeline-definition-24CTP7MA-C8DELzYs.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-BS4edB5m.js → treeView-Q6P3EWNA-C1MDeOPi.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-BZ54DPA4.js → treemap-WGGIJYW6-CcwgMCe9.js} +1 -1
- package/web/assets/{useStore-DEfLrrMh.js → useStore-CaQamKgO.js} +7 -5
- package/web/assets/{vennDiagram-4TSXK5OY-CAKXCk3I.js → vennDiagram-4TSXK5OY-Co-Nw6M-.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-DM5IZXxI.js → wardley-WFR3VGLG-FbKVF2Pu.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4--WWmycX2.js → wardleyDiagram-VM6X3IG4-Bt7NGXJX.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-D-_SgAQy.js → xychartDiagram-S5SC5T6Z-BsgyT9WB.js} +1 -1
- package/web/index.html +2 -2
- package/web/sw.js +1 -1
- package/web/assets/architecture-7GRP2DOG-CD4t1j2T.js +0 -1
- package/web/assets/channel-Dl0Aa963.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-BvMwIvP0.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-BvMwIvP0.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-CF677pnK.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-8SiMcM5n.js +0 -1
- package/web/assets/railroad-abnf-HS5TGJTU-C_F3QWV5.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-BaNfyzXp.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-BWyMWwxG.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-B_Xx-QG6.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-XYt-UA-z.js +0 -8
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
/** Scenarios: named starting points for a live app, one JSON file each in the
|
|
6
|
+
* project. Opening one puts the preview in that state and Reset puts it back.
|
|
7
|
+
* A web page's scenario is per viewer: a page, steps taken in it, responses
|
|
8
|
+
* the gateway simulates. A desktop app's is the app's: it restarts with the
|
|
9
|
+
* scenario's arguments and environment (its API pointed at a simulating proxy)
|
|
10
|
+
* and the steps are clicks, keys and menu items in its window. Neither needs
|
|
11
|
+
* anything from the app's framework. See docs/apps.md#scenarios. */
|
|
12
|
+
export const SCENARIO_DIR = path.join(".bivy", "scenarios");
|
|
13
|
+
const MAX_FILES = 100;
|
|
14
|
+
const MAX_BYTES = 64 * 1024;
|
|
15
|
+
const MAX_STEPS = 50;
|
|
16
|
+
const MAX_RULES = 20;
|
|
17
|
+
const MAX_CHAIN = 5;
|
|
18
|
+
const MAX_DELAY_MS = 30_000;
|
|
19
|
+
const MAX_WAIT_MS = 10_000;
|
|
20
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
21
|
+
const METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
22
|
+
const text = (value, label, max) => {
|
|
23
|
+
if (typeof value !== "string" || !value.trim() || value.length > max)
|
|
24
|
+
throw new Error(`${label} must be text of 1–${max} characters.`);
|
|
25
|
+
return value.trim();
|
|
26
|
+
};
|
|
27
|
+
const page = (value, label) => {
|
|
28
|
+
const at = text(value, label, 2048);
|
|
29
|
+
if (!at.startsWith("/") || at.startsWith("//"))
|
|
30
|
+
throw new Error(`${label} must be a path that starts with /.`);
|
|
31
|
+
return at;
|
|
32
|
+
};
|
|
33
|
+
/** Each step kind, as a row: the key that names it, how its value is read,
|
|
34
|
+
* and which preview it belongs to when only one can take it (a CSS selector
|
|
35
|
+
* means a page; pixels or a menu path mean a desktop window). Adding a kind of
|
|
36
|
+
* step means adding a row here and a case in the runner that takes it. */
|
|
37
|
+
const STEPS = [
|
|
38
|
+
{ key: "click", read: (raw) => {
|
|
39
|
+
if (Array.isArray(raw.click)) {
|
|
40
|
+
if (raw.click.length !== 2 || !raw.click.every((n) => Number.isInteger(n) && n >= 0 && n < 65536))
|
|
41
|
+
throw new Error("click in a desktop app takes [x, y]: whole pixels in its screenshot.");
|
|
42
|
+
return { step: { click: [raw.click[0], raw.click[1]] }, kind: "desktop" };
|
|
43
|
+
}
|
|
44
|
+
return { step: { click: text(raw.click, "click", 300) }, kind: "web" };
|
|
45
|
+
} },
|
|
46
|
+
{ key: "fill", read: (raw) => {
|
|
47
|
+
if (typeof raw.with !== "string" || raw.with.length > 2000)
|
|
48
|
+
throw new Error("fill needs \"with\": the text to put in the field (up to 2,000 characters).");
|
|
49
|
+
return { step: { fill: text(raw.fill, "fill", 300), with: raw.with }, kind: "web" };
|
|
50
|
+
} },
|
|
51
|
+
{ key: "type", read: (raw) => {
|
|
52
|
+
if (typeof raw.type !== "string" || !raw.type || raw.type.length > 2000)
|
|
53
|
+
throw new Error("type takes the text to type, up to 2,000 characters.");
|
|
54
|
+
return { step: { type: raw.type }, kind: "desktop" };
|
|
55
|
+
} },
|
|
56
|
+
{ key: "press", read: (raw) => ({ step: { press: text(raw.press, "press", 40) } }) },
|
|
57
|
+
{ key: "menu", read: (raw) => ({ step: { menu: text(raw.menu, "menu", 300) }, kind: "desktop" }) },
|
|
58
|
+
{ key: "wait", read: (raw) => {
|
|
59
|
+
if (typeof raw.wait === "number") {
|
|
60
|
+
if (!Number.isFinite(raw.wait) || raw.wait < 0 || raw.wait > MAX_WAIT_MS)
|
|
61
|
+
throw new Error(`wait takes a selector, or milliseconds up to ${MAX_WAIT_MS}.`);
|
|
62
|
+
return { step: { wait: Math.round(raw.wait) } };
|
|
63
|
+
}
|
|
64
|
+
return { step: { wait: text(raw.wait, "wait", 300) }, kind: "web" };
|
|
65
|
+
} },
|
|
66
|
+
];
|
|
67
|
+
/** Fields that only one kind of preview has. */
|
|
68
|
+
const FIELD_KINDS = { open: "web", fresh: "web", args: "desktop", env: "desktop", api: "desktop" };
|
|
69
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
70
|
+
function readStep(raw, index) {
|
|
71
|
+
const step = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
72
|
+
const kind = STEPS.find((row) => row.key in step);
|
|
73
|
+
if (!kind)
|
|
74
|
+
throw new Error(`Step ${index + 1} must be one of: ${STEPS.map((row) => row.key).join(", ")}.`);
|
|
75
|
+
try {
|
|
76
|
+
return kind.read(step);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new Error(`Step ${index + 1}: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function readArgs(raw) {
|
|
83
|
+
if (!Array.isArray(raw) || raw.length > 30 || !raw.every((arg) => typeof arg === "string" && arg.length <= 1000))
|
|
84
|
+
throw new Error("args must be a list of up to 30 arguments.");
|
|
85
|
+
return [...raw];
|
|
86
|
+
}
|
|
87
|
+
function readEnv(raw) {
|
|
88
|
+
const entries = raw && typeof raw === "object" && !Array.isArray(raw) ? Object.entries(raw) : undefined;
|
|
89
|
+
if (!entries || entries.length > 30 || !entries.every(([name, value]) => ENV_NAME.test(name) && typeof value === "string" && value.length <= 4000))
|
|
90
|
+
throw new Error("env maps up to 30 variable names to text values.");
|
|
91
|
+
return Object.fromEntries(entries);
|
|
92
|
+
}
|
|
93
|
+
function readApi(raw) {
|
|
94
|
+
const api = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
95
|
+
if (typeof api.env !== "string" || !ENV_NAME.test(api.env))
|
|
96
|
+
throw new Error("api.env is the environment variable the app reads its API address from, like API_URL.");
|
|
97
|
+
let target;
|
|
98
|
+
try {
|
|
99
|
+
target = new URL(String(api.target));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new Error("api.target is the API's real address, like http://127.0.0.1:4000.");
|
|
103
|
+
}
|
|
104
|
+
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password || target.search || target.hash)
|
|
105
|
+
throw new Error("api.target must be an http(s) address without credentials, query or fragment.");
|
|
106
|
+
return { env: api.env, target: target.href.replace(/\/$/, "") };
|
|
107
|
+
}
|
|
108
|
+
/** "POST /api/payments*" or "/api/orders": an optional method, then a path where * matches anything. */
|
|
109
|
+
function readRule(raw, index) {
|
|
110
|
+
const label = `Network rule ${index + 1}`;
|
|
111
|
+
const rule = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : undefined;
|
|
112
|
+
if (!rule)
|
|
113
|
+
throw new Error(`${label} must be an object.`);
|
|
114
|
+
const [first, second, ...extra] = text(rule.match, `${label} "match"`, 300).split(/\s+/);
|
|
115
|
+
const method = second ? first.toUpperCase() : undefined;
|
|
116
|
+
if (extra.length || (method && !METHODS.includes(method)))
|
|
117
|
+
throw new Error(`${label}: "match" is an optional method and a path, like "POST /api/payments*".`);
|
|
118
|
+
const result = { ...(method ? { method } : {}), path: page(second ?? first, `${label} path`) };
|
|
119
|
+
if (rule.status !== undefined) {
|
|
120
|
+
if (!Number.isInteger(rule.status) || rule.status < 200 || rule.status > 599)
|
|
121
|
+
throw new Error(`${label}: status must be an HTTP status from 200 to 599.`);
|
|
122
|
+
result.status = rule.status;
|
|
123
|
+
}
|
|
124
|
+
if (rule.json !== undefined)
|
|
125
|
+
result.json = rule.json;
|
|
126
|
+
if (rule.body !== undefined) {
|
|
127
|
+
if (typeof rule.body !== "string" || rule.body.length > 32_000)
|
|
128
|
+
throw new Error(`${label}: body must be text up to 32,000 characters.`);
|
|
129
|
+
result.body = rule.body;
|
|
130
|
+
}
|
|
131
|
+
if (result.json !== undefined && result.body !== undefined)
|
|
132
|
+
throw new Error(`${label}: use json or body, not both.`);
|
|
133
|
+
if (rule.delayMs !== undefined) {
|
|
134
|
+
if (typeof rule.delayMs !== "number" || !Number.isFinite(rule.delayMs) || rule.delayMs < 0 || rule.delayMs > MAX_DELAY_MS)
|
|
135
|
+
throw new Error(`${label}: delayMs must be 0–${MAX_DELAY_MS}.`);
|
|
136
|
+
result.delayMs = Math.round(rule.delayMs);
|
|
137
|
+
}
|
|
138
|
+
if (rule.offline !== undefined && rule.offline !== true)
|
|
139
|
+
throw new Error(`${label}: offline can only be true.`);
|
|
140
|
+
if (rule.offline) {
|
|
141
|
+
if (result.status !== undefined || result.json !== undefined || result.body !== undefined)
|
|
142
|
+
throw new Error(`${label}: an offline rule sends no response, so it has no status, json or body.`);
|
|
143
|
+
result.offline = true;
|
|
144
|
+
}
|
|
145
|
+
if (!result.offline && result.status === undefined && result.json === undefined && result.body === undefined && !result.delayMs)
|
|
146
|
+
throw new Error(`${label} does nothing: give it a status, json, body, delayMs or offline.`);
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
/** One scenario file's contents. Throws a message meant for whoever wrote it. */
|
|
150
|
+
export function readScenario(id, raw, changedAt = 0) {
|
|
151
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
152
|
+
throw new Error("A scenario is a JSON object with at least a \"name\".");
|
|
153
|
+
const input = raw;
|
|
154
|
+
const known = new Set(["$schema", "name", "description", "view", "from", "steps", "network", ...Object.keys(FIELD_KINDS)]);
|
|
155
|
+
const unknown = Object.keys(input).filter((key) => !known.has(key));
|
|
156
|
+
if (unknown.length)
|
|
157
|
+
throw new Error(`Unknown field${unknown.length === 1 ? "" : "s"} ${unknown.map((key) => `"${key}"`).join(", ")}. A scenario has: ${[...known].filter((key) => key !== "$schema").join(", ")}.`);
|
|
158
|
+
if (input.steps !== undefined && (!Array.isArray(input.steps) || input.steps.length > MAX_STEPS))
|
|
159
|
+
throw new Error(`steps must be a list of up to ${MAX_STEPS} steps.`);
|
|
160
|
+
if (input.network !== undefined && (!Array.isArray(input.network) || input.network.length > MAX_RULES))
|
|
161
|
+
throw new Error(`network must be a list of up to ${MAX_RULES} rules.`);
|
|
162
|
+
if (input.fresh !== undefined && typeof input.fresh !== "boolean")
|
|
163
|
+
throw new Error("fresh must be true or false.");
|
|
164
|
+
const from = input.from === undefined ? undefined : text(input.from, "from", 64);
|
|
165
|
+
if (from !== undefined && !ID.test(from))
|
|
166
|
+
throw new Error("from names another scenario by its file name, without .json.");
|
|
167
|
+
if (from === id)
|
|
168
|
+
throw new Error("A scenario can't start from itself.");
|
|
169
|
+
const steps = (input.steps ?? []).map(readStep);
|
|
170
|
+
// What the fields and steps say it's for: a page, a desktop window, or either.
|
|
171
|
+
const says = [
|
|
172
|
+
...Object.entries(FIELD_KINDS).filter(([key]) => input[key] !== undefined).map(([key, kind]) => ({ kind, what: `"${key}"` })),
|
|
173
|
+
...steps.flatMap((step, index) => step.kind ? [{ kind: step.kind, what: `step ${index + 1}` }] : []),
|
|
174
|
+
];
|
|
175
|
+
const kind = says[0]?.kind;
|
|
176
|
+
const other = says.find((item) => item.kind !== kind);
|
|
177
|
+
if (other)
|
|
178
|
+
throw new Error(`${says.find((item) => item.kind === "web").what} is for web pages and ${says.find((item) => item.kind === "desktop").what} for desktop apps: a scenario is for one or the other.`);
|
|
179
|
+
return {
|
|
180
|
+
id, name: text(input.name, "name", 80), changedAt, ...(kind ? { kind } : {}),
|
|
181
|
+
...(input.description === undefined ? {} : { description: text(input.description, "description", 200) }),
|
|
182
|
+
...(input.view === undefined ? {} : { view: text(input.view, "view", 100) }),
|
|
183
|
+
...(from ? { from } : {}),
|
|
184
|
+
...(input.open === undefined ? {} : { open: page(input.open, "open") }),
|
|
185
|
+
...(input.fresh ? { fresh: true } : {}),
|
|
186
|
+
steps: steps.map((item) => item.step),
|
|
187
|
+
network: (input.network ?? []).map(readRule),
|
|
188
|
+
...(input.args === undefined ? {} : { args: readArgs(input.args) }),
|
|
189
|
+
...(input.env === undefined ? {} : { env: readEnv(input.env) }),
|
|
190
|
+
...(input.api === undefined ? {} : { api: readApi(input.api) }),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Every scenario file in a workspace. Files are read fresh each time (there
|
|
194
|
+
* are few, and an agent may have just written one). A file that can't be read
|
|
195
|
+
* is a problem to report, never a reason to hide the others. */
|
|
196
|
+
export function loadScenarios(workspace) {
|
|
197
|
+
const scenarios = [];
|
|
198
|
+
const problems = [];
|
|
199
|
+
const dir = path.join(workspace, SCENARIO_DIR);
|
|
200
|
+
let names;
|
|
201
|
+
try {
|
|
202
|
+
if (!fs.lstatSync(dir).isDirectory())
|
|
203
|
+
return { scenarios, problems };
|
|
204
|
+
names = fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort().slice(0, MAX_FILES);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { scenarios, problems };
|
|
208
|
+
}
|
|
209
|
+
for (const name of names) {
|
|
210
|
+
const id = name.slice(0, -".json".length);
|
|
211
|
+
const file = path.join(SCENARIO_DIR, name);
|
|
212
|
+
try {
|
|
213
|
+
if (!ID.test(id))
|
|
214
|
+
throw new Error("Name the file with lowercase letters, digits and dashes, like checkout-api-down.json.");
|
|
215
|
+
const stat = fs.lstatSync(path.join(dir, name));
|
|
216
|
+
if (!stat.isFile())
|
|
217
|
+
throw new Error("Not a regular file.");
|
|
218
|
+
if (stat.size > MAX_BYTES)
|
|
219
|
+
throw new Error("Larger than 64 KiB.");
|
|
220
|
+
let raw;
|
|
221
|
+
try {
|
|
222
|
+
raw = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
throw new Error(`Not valid JSON: ${error.message}`);
|
|
226
|
+
}
|
|
227
|
+
scenarios.push(readScenario(id, raw, stat.mtimeMs));
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
problems.push({ id, file, error: error.message });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { scenarios, problems };
|
|
234
|
+
}
|
|
235
|
+
/** The scenarios that belong to one view: those naming it (or its app), or
|
|
236
|
+
* naming none, that fit its kind of preview. */
|
|
237
|
+
export function scenariosFor(list, app, view, kind) {
|
|
238
|
+
const names = [app.toLowerCase(), view.toLowerCase()];
|
|
239
|
+
return list.filter((scenario) => (!scenario.view || names.includes(scenario.view.toLowerCase())) && (!scenario.kind || scenario.kind === kind));
|
|
240
|
+
}
|
|
241
|
+
/** In words, for the pill: "POST /api/payments* → 503". */
|
|
242
|
+
export function describeRule(rule) {
|
|
243
|
+
const outcome = rule.offline ? "offline" : rule.status ?? (rule.json !== undefined || rule.body !== undefined ? 200 : `+${rule.delayMs}ms`);
|
|
244
|
+
return `${rule.method ? `${rule.method} ` : ""}${rule.path} → ${outcome}`;
|
|
245
|
+
}
|
|
246
|
+
function simulated(rules) {
|
|
247
|
+
if (!rules.length)
|
|
248
|
+
return undefined;
|
|
249
|
+
return describeRule(rules[0]) + (rules.length > 1 ? ` (+${rules.length - 1} more)` : "");
|
|
250
|
+
}
|
|
251
|
+
/** Resolves a scenario through its `from` chain: the base's page and steps
|
|
252
|
+
* first, then each one built on it; network rules nearest first, so a scenario
|
|
253
|
+
* overrides what it builds on. */
|
|
254
|
+
export function planScenario(list, id, kind = "web") {
|
|
255
|
+
const chain = [];
|
|
256
|
+
for (let at = id; at;) {
|
|
257
|
+
const scenario = list.find((item) => item.id === at);
|
|
258
|
+
if (!scenario)
|
|
259
|
+
throw new Error(at === id ? `No scenario called "${id}".` : `"${chain.at(-1).id}" starts from "${at}", which doesn't exist or can't be read.`);
|
|
260
|
+
if (chain.some((item) => item.id === at))
|
|
261
|
+
throw new Error(`"${id}" starts from itself through ${chain.map((item) => item.id).join(" → ")}.`);
|
|
262
|
+
chain.push(scenario);
|
|
263
|
+
if (chain.length > MAX_CHAIN)
|
|
264
|
+
throw new Error(`"${id}" builds on more than ${MAX_CHAIN} scenarios.`);
|
|
265
|
+
at = scenario.from;
|
|
266
|
+
}
|
|
267
|
+
const mismatch = chain.find((scenario) => scenario.kind && scenario.kind !== kind);
|
|
268
|
+
if (mismatch)
|
|
269
|
+
throw new Error(`"${mismatch.id}" is for ${mismatch.kind === "web" ? "web pages" : "desktop apps"}, and this is ${kind === "web" ? "a web page" : "a desktop app"}.`);
|
|
270
|
+
const order = [...chain].reverse();
|
|
271
|
+
const network = chain.flatMap((scenario) => scenario.network);
|
|
272
|
+
const api = chain.find((scenario) => scenario.api)?.api;
|
|
273
|
+
if (kind === "desktop" && network.length && !api)
|
|
274
|
+
throw new Error(`"${id}" simulates responses, but a desktop app calls its API directly: add "api": {"env": "API_URL", "target": "http://127.0.0.1:4000"} so Bivy can put a proxy in between.`);
|
|
275
|
+
return {
|
|
276
|
+
id, name: chain[0].name, kind, fresh: order.some((scenario) => scenario.fresh === true),
|
|
277
|
+
stages: order.map((scenario, index) => ({ ...(scenario.open ? { open: scenario.open } : index === 0 && kind === "web" ? { open: "/" } : {}), steps: scenario.steps })),
|
|
278
|
+
network, ...(simulated(network) ? { simulated: simulated(network) } : {}),
|
|
279
|
+
args: order.flatMap((scenario) => scenario.args ?? []), env: Object.assign({}, ...order.map((scenario) => scenario.env ?? {})), ...(api ? { api } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/** What a list or tile shows. `since`: the app's creation; files changed after it are new. */
|
|
283
|
+
export function summarize(list, problems, since, kind = "web") {
|
|
284
|
+
const rows = list.map((scenario) => {
|
|
285
|
+
try {
|
|
286
|
+
const plan = planScenario(list, scenario.id, kind);
|
|
287
|
+
const open = [...plan.stages].reverse().find((stage) => stage.open)?.open;
|
|
288
|
+
return {
|
|
289
|
+
id: scenario.id, name: scenario.name, ...(scenario.description ? { description: scenario.description } : {}),
|
|
290
|
+
...(open ? { open } : {}),
|
|
291
|
+
steps: plan.stages.reduce((sum, stage) => sum + stage.steps.length, 0),
|
|
292
|
+
...(plan.simulated ? { simulated: plan.simulated } : {}), ...(plan.fresh ? { fresh: true } : {}),
|
|
293
|
+
...(scenario.changedAt > since ? { isNew: true } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
return { id: scenario.id, name: scenario.name, steps: 0, error: error.message };
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
return [...rows, ...problems.map((problem) => ({ id: problem.id, name: problem.id, steps: 0, error: problem.error }))];
|
|
301
|
+
}
|
|
302
|
+
/** The rule a request meets, if any: the first whose method and path match. */
|
|
303
|
+
export function matchRule(rules, method, url) {
|
|
304
|
+
const [pathname] = url.split("?");
|
|
305
|
+
return rules.find((rule) => (!rule.method || rule.method === (method ?? "GET").toUpperCase())
|
|
306
|
+
&& new RegExp(`^${rule.path.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(rule.path.includes("?") ? url : pathname));
|
|
307
|
+
}
|
package/dist/apps/service.js
CHANGED
|
@@ -13,6 +13,11 @@ 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 { SCENARIO_DIR, loadScenarios, planScenario, scenariosFor, summarize } from "./scenarios.js";
|
|
17
|
+
import { startScenarioProxy } from "./scenario-proxy.js";
|
|
18
|
+
const scenarioKind = (entry) => entry.target.kind === "display" ? "desktop" : "web";
|
|
19
|
+
/** A desktop step in words, for the message when one fails. */
|
|
20
|
+
const stepWords = (step) => "click" in step ? `click ${Array.isArray(step.click) ? step.click.join(",") : step.click}` : "type" in step ? `type "${step.type.slice(0, 40)}"` : "press" in step ? `press ${step.press}` : "menu" in step ? `menu ${step.menu}` : "wait" in step ? `wait ${step.wait}` : "step";
|
|
16
21
|
const NO_REVIEWS = { publish: (review) => review, expire: () => { } };
|
|
17
22
|
const NO_PINS = { publish: (pin) => pin };
|
|
18
23
|
/** A pin's region is in the page CSS pixels of the viewport it was marked in.
|
|
@@ -62,6 +67,8 @@ export class AppService {
|
|
|
62
67
|
latest = new Map();
|
|
63
68
|
/** Per view: the newest reviewer note a run-end card has already counted. */
|
|
64
69
|
handedOver = new Map();
|
|
70
|
+
/** Per desktop app in a scenario with an API: the proxy in front of it and the variable pointing there. */
|
|
71
|
+
proxies = new Map();
|
|
65
72
|
/** Screenshots in progress, so a card, Compare and a baseline share one browser run. */
|
|
66
73
|
inflight = new Map();
|
|
67
74
|
constructor(registry, gateway, terminals, options = {}) {
|
|
@@ -76,6 +83,7 @@ export class AppService {
|
|
|
76
83
|
this.reviews = options.reviews ?? NO_REVIEWS;
|
|
77
84
|
this.pinSink = options.pins ?? NO_PINS;
|
|
78
85
|
this.settleMs = options.settleMs ?? COMPARE_SETTLE_MS;
|
|
86
|
+
registry.desktopScenario = (viewId, scenario) => this.desktopScenario(viewId, scenario);
|
|
79
87
|
}
|
|
80
88
|
/** A session's apps, or every app on this machine when no session is given. */
|
|
81
89
|
list(sessionId) {
|
|
@@ -117,6 +125,11 @@ export class AppService {
|
|
|
117
125
|
const server = this.servers.get(id);
|
|
118
126
|
if (entry?.target.kind !== "display" || !server?.termId)
|
|
119
127
|
continue;
|
|
128
|
+
// In a scenario, the new code starts in it again, steps and all.
|
|
129
|
+
if (entry.scenario) {
|
|
130
|
+
void this.desktopScenario(id, entry.scenario.id).catch(() => { });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
120
133
|
this.terminals.close(server.termId);
|
|
121
134
|
server.termId = undefined;
|
|
122
135
|
void this.ensureServer(entry).catch(() => { });
|
|
@@ -225,7 +238,7 @@ export class AppService {
|
|
|
225
238
|
const presented = current && run.reviews.get(entry.app.id) === current.id;
|
|
226
239
|
if (!shouldReview({ trigger: "run", mode, muted: run.muted, revisionChanged: true, change }) && !(presented && after))
|
|
227
240
|
continue;
|
|
228
|
-
latest = this.review(entry, run, { trigger: presented ? current.trigger : "run", note: presented ? current.note : undefined, path: entry.lastPath ?? "/", shot: after, before });
|
|
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 });
|
|
229
242
|
}
|
|
230
243
|
// Reviewer notes that arrived since the last hand-over ride on the view's
|
|
231
244
|
// card for this run, or get a card of their own. A count only: the notes
|
|
@@ -264,6 +277,7 @@ export class AppService {
|
|
|
264
277
|
throw new Error("Path must start with /.");
|
|
265
278
|
const note = typeof input.note === "string" ? input.note.trim().slice(0, 500) : undefined;
|
|
266
279
|
const entry = this.pickView(sessionId, input.target);
|
|
280
|
+
const tries = input.try?.length ? this.tryScenarios(entry, input.try) : undefined;
|
|
267
281
|
const mode = this.registry.reviewMode(entry.app.id);
|
|
268
282
|
const active = this.runs.get(sessionId);
|
|
269
283
|
const run = active && !active.ended ? active : undefined;
|
|
@@ -279,9 +293,90 @@ export class AppService {
|
|
|
279
293
|
const shot = enabled ? await this.shotOf(entry, path) : undefined;
|
|
280
294
|
const baseline = run?.baseline.get(entry.view.id);
|
|
281
295
|
const before = baseline && shot && !baseline.equals(shot) && path === (entry.lastPath ?? "/") ? baseline : undefined;
|
|
282
|
-
const review = this.review(entry, run, { trigger, note, path, shot, before, screenshotsOff: !enabled });
|
|
296
|
+
const review = this.review(entry, run, { trigger, note, path, shot, before, screenshotsOff: !enabled, ...(tries ? { try: tries } : {}) });
|
|
283
297
|
return { review, message: enabled ? (shot ? "The user sees it in the chat." : "The user sees a card in the chat, but the screenshot failed.") : "The user sees a card in the chat. Screenshots are off on this machine, so it has no picture." };
|
|
284
298
|
}
|
|
299
|
+
/** `bivy app scenarios`: a view's scenarios as the preview shows them, and
|
|
300
|
+
* files that can't be opened, with what's wrong (picked like `present`). */
|
|
301
|
+
scenarios(sessionId, target) {
|
|
302
|
+
const entry = this.pickView(sessionId, target);
|
|
303
|
+
const { scenarios, problems } = loadScenarios(entry.workspace);
|
|
304
|
+
const kind = scenarioKind(entry);
|
|
305
|
+
return { app: entry.app.name, view: entry.view.name, dir: SCENARIO_DIR, scenarios: summarize(scenariosFor(scenarios, entry.app.name, entry.view.name, kind), problems, entry.app.createdAt, kind) };
|
|
306
|
+
}
|
|
307
|
+
/** Scenarios a review card offers to try, by ID, each one that can be opened. */
|
|
308
|
+
tryScenarios(entry, ids) {
|
|
309
|
+
const { scenarios, problems } = loadScenarios(entry.workspace);
|
|
310
|
+
const kind = scenarioKind(entry);
|
|
311
|
+
const known = summarize(scenariosFor(scenarios, entry.app.name, entry.view.name, kind), problems, entry.app.createdAt, kind);
|
|
312
|
+
const wanted = [...new Set(ids.map((id) => id.trim().toLowerCase().replace(/\.json$/, "")).filter(Boolean))].slice(0, 6);
|
|
313
|
+
return wanted.map((id) => {
|
|
314
|
+
const found = known.find((item) => item.id === id);
|
|
315
|
+
if (!found)
|
|
316
|
+
throw new Error(`No scenario "${id}" for ${entry.view.name}. ${known.length ? `Its scenarios: ${known.map((item) => item.id).join(", ")}.` : `Write one in ${SCENARIO_DIR}/${id}.json.`}`);
|
|
317
|
+
if (found.error)
|
|
318
|
+
throw new Error(`Scenario "${id}" can't be opened: ${found.error}`);
|
|
319
|
+
return { id: found.id, name: found.name };
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/** Puts a desktop app in a scenario ("" for none). It is one program for
|
|
323
|
+
* everyone watching, so the scenario is the app's: it restarts with the
|
|
324
|
+
* scenario's arguments and environment, its API (if the scenario has one)
|
|
325
|
+
* behind a proxy that applies the network rules, and then the steps run in
|
|
326
|
+
* its window the way an agent's input does. A step that fails leaves the app
|
|
327
|
+
* where it got to and says which step, rather than throwing. */
|
|
328
|
+
async desktopScenario(viewId, id) {
|
|
329
|
+
const entry = this.registry.getView(viewId);
|
|
330
|
+
if (entry?.target.kind !== "display")
|
|
331
|
+
throw new Error("Desktop scenarios are for desktop apps.");
|
|
332
|
+
const plan = id ? planScenario(scenariosFor(loadScenarios(entry.workspace).scenarios, entry.app.name, entry.view.name, "desktop"), id, "desktop") : null;
|
|
333
|
+
this.proxies.get(viewId)?.close();
|
|
334
|
+
this.proxies.delete(viewId);
|
|
335
|
+
if (plan?.api) {
|
|
336
|
+
const proxy = await startScenarioProxy(plan.api.target, plan.network);
|
|
337
|
+
this.proxies.set(viewId, { close: proxy.close, env: { [plan.api.env]: proxy.url } });
|
|
338
|
+
}
|
|
339
|
+
entry.scenario = plan ?? undefined;
|
|
340
|
+
const server = this.servers.get(viewId);
|
|
341
|
+
if (server?.termId) {
|
|
342
|
+
this.terminals.close(server.termId);
|
|
343
|
+
server.termId = undefined;
|
|
344
|
+
}
|
|
345
|
+
await this.windowShown(entry);
|
|
346
|
+
if (!plan)
|
|
347
|
+
return { plan: null };
|
|
348
|
+
const steps = plan.stages.flatMap((stage) => stage.steps);
|
|
349
|
+
for (const [index, step] of steps.entries()) {
|
|
350
|
+
try {
|
|
351
|
+
await this.desktopStep(entry, step);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
return { plan, error: `Step ${index + 1} (${stepWords(step)}): ${error.message}` };
|
|
355
|
+
}
|
|
356
|
+
await new Promise((r) => setTimeout(r, AFTER_INPUT_MS));
|
|
357
|
+
}
|
|
358
|
+
return { plan };
|
|
359
|
+
}
|
|
360
|
+
async desktopStep(entry, step) {
|
|
361
|
+
if ("wait" in step) {
|
|
362
|
+
if (typeof step.wait === "number")
|
|
363
|
+
await new Promise((r) => setTimeout(r, step.wait));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if ("menu" in step) {
|
|
367
|
+
if (!entry.displayControl)
|
|
368
|
+
throw new Error("This machine's desktop apps don't expose their menus (macOS only). Use a key combo instead.");
|
|
369
|
+
await pressMenu(entry.displayControl, readMenuPath(step.menu));
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (!entry.display)
|
|
373
|
+
throw new Error("The app isn't running. Check its Logs in the Apps sheet.");
|
|
374
|
+
const action = "click" in step && Array.isArray(step.click) ? { kind: "click", x: step.click[0], y: step.click[1] }
|
|
375
|
+
: "type" in step ? { kind: "type", text: step.type } : "press" in step ? { kind: "key", keys: step.press } : undefined;
|
|
376
|
+
if (!action)
|
|
377
|
+
throw new Error("That step is for web pages.");
|
|
378
|
+
await sendInput(entry.display, inputEvents(readAction(action)));
|
|
379
|
+
}
|
|
285
380
|
/** No more cards for the rest of the session's current run. */
|
|
286
381
|
mute(sessionId) {
|
|
287
382
|
const run = this.runs.get(sessionId);
|
|
@@ -348,7 +443,8 @@ export class AppService {
|
|
|
348
443
|
* returning to a view's stable address. */
|
|
349
444
|
/** `scale`: the opening device's pixel density, used if this starts a display. */
|
|
350
445
|
/** `page`: start on this page instead of the app's root (a review card's page). */
|
|
351
|
-
|
|
446
|
+
/** `scenario`: open it in that scenario (the shell enters it once the app has loaded). */
|
|
447
|
+
async open(sessionId, appId, viewId, returnTo, direct = false, scale, page, scenario) {
|
|
352
448
|
const entry = this.registry.requireView(sessionId, appId, viewId);
|
|
353
449
|
if (entry.target.kind === "display" && !entry.display)
|
|
354
450
|
entry.displayScale = scale === 2 ? 2 : 1;
|
|
@@ -358,8 +454,12 @@ export class AppService {
|
|
|
358
454
|
if (direct && !this.gateway.openDirect)
|
|
359
455
|
throw new Error("This machine can't open previews directly.");
|
|
360
456
|
let url = direct ? this.gateway.openDirect(viewId) : this.gateway.open(viewId, returnTo);
|
|
361
|
-
|
|
362
|
-
|
|
457
|
+
const start = page && page.startsWith("/") && !page.startsWith("//") && page.length <= 2048 ? page : undefined;
|
|
458
|
+
const inScenario = !direct && scenario && /^[a-z0-9][a-z0-9-]{0,63}$/.test(scenario) ? scenario : undefined;
|
|
459
|
+
if (start || inScenario)
|
|
460
|
+
url += `~${start ? encodeURIComponent(start).replace(/~/g, "%7E") : ""}`;
|
|
461
|
+
if (inScenario)
|
|
462
|
+
url += `~${inScenario}`;
|
|
363
463
|
// Don't wait for a managed server to boot: the preview shows it starting
|
|
364
464
|
// and reloads itself once it answers.
|
|
365
465
|
if (this.servers.get(viewId))
|
|
@@ -646,8 +746,10 @@ export class AppService {
|
|
|
646
746
|
return this.displays.ensure(entry.view.id, entry.app.name, entry.displayScale).then((display) => {
|
|
647
747
|
entry.display = display.socket;
|
|
648
748
|
entry.displayControl = display.control;
|
|
649
|
-
|
|
650
|
-
|
|
749
|
+
// A scenario adds its arguments and environment; the display's own variables always win.
|
|
750
|
+
const [command, ...args] = [...display.launch ?? [], target.command, ...target.args, ...entry.scenario?.args ?? []];
|
|
751
|
+
const env = { ...entry.scenario?.env, ...this.proxies.get(entry.view.id)?.env, ...display.env };
|
|
752
|
+
return { command: command, args, workspace: target.workspace, env, name: `${entry.app.name} · ${entry.view.name}` };
|
|
651
753
|
});
|
|
652
754
|
}
|
|
653
755
|
/** Services start synchronously; a desktop app waits for its display. */
|
|
@@ -699,6 +801,8 @@ export class AppService {
|
|
|
699
801
|
}
|
|
700
802
|
stopServer(id) {
|
|
701
803
|
this.displays.stop(id);
|
|
804
|
+
this.proxies.get(id)?.close();
|
|
805
|
+
this.proxies.delete(id);
|
|
702
806
|
const entry = this.registry.getView(id);
|
|
703
807
|
if (entry) {
|
|
704
808
|
entry.display = undefined;
|
package/dist/apps/styles.css
CHANGED
|
@@ -3243,6 +3243,10 @@ 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
|
+
/* "Try it": scenarios the agent offered, each opening the live preview in that state. */
|
|
3247
|
+
.review-try { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); margin: var(--space-3) var(--space-3) 0; }
|
|
3248
|
+
.review-try-label { margin-inline-end: var(--space-1); color: var(--muted); font-size: var(--text-sm); }
|
|
3249
|
+
.review-try .btn { min-block-size: var(--space-7); }
|
|
3246
3250
|
.review-status { margin: var(--space-2) var(--space-3) 0; color: var(--muted); font-size: var(--text-sm); }
|
|
3247
3251
|
.review-actions { display: flex; flex-wrap: wrap; gap: var(--space-1); padding: var(--space-3); }
|
|
3248
3252
|
.review-actions .btn { min-block-size: var(--space-7); }
|
package/dist/apps/types.js
CHANGED
|
@@ -15,7 +15,8 @@ export function isAppReview(value) {
|
|
|
15
15
|
&& (review.trigger === "present" || review.trigger === "run" || review.trigger === "asked" || review.trigger === "notes") && typeof review.at === "number"
|
|
16
16
|
&& (review.shot === undefined || isShot(review.shot)) && (review.before === undefined || isShot(review.before))
|
|
17
17
|
&& (review.note === undefined || typeof review.note === "string")
|
|
18
|
-
&& (review.notes === undefined || (Number.isInteger(review.notes) && review.notes >= 0))
|
|
18
|
+
&& (review.notes === undefined || (Number.isInteger(review.notes) && review.notes >= 0))
|
|
19
|
+
&& (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)));
|
|
19
20
|
}
|
|
20
21
|
export const APP_PIN_STATES = ["open", "changed", "gone", "done"];
|
|
21
22
|
export const APP_PIN_BLOCK = "bivy_app_pin";
|
|
@@ -25,11 +25,12 @@ export function createAppCommands(service, workspaceFor, published = () => { },
|
|
|
25
25
|
published(app);
|
|
26
26
|
return { app };
|
|
27
27
|
},
|
|
28
|
-
"apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined, typeof msg.path === "string" ? msg.path : undefined),
|
|
28
|
+
"apps.open": (msg) => service.open(String(msg.sessionId), String(msg.appId), String(msg.viewId), typeof msg.returnTo === "string" ? msg.returnTo : undefined, msg.direct === true, typeof msg.scale === "number" ? msg.scale : undefined, typeof msg.path === "string" ? msg.path : undefined, typeof msg.scenario === "string" ? msg.scenario : undefined),
|
|
29
29
|
"apps.shot": (msg) => service.shot(String(msg.sessionId), typeof msg.appId === "string" ? msg.appId : undefined, { widths: msg.widths, themes: msg.themes, path: typeof msg.path === "string" ? msg.path : undefined }),
|
|
30
30
|
"apps.input": (msg) => service.act(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined, msg.action),
|
|
31
31
|
"apps.menu": (msg) => service.menu(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined, msg.path),
|
|
32
|
-
"apps.present": (msg) => service.present(String(msg.sessionId), { target: typeof msg.target === "string" ? msg.target : undefined, path: typeof msg.path === "string" ? msg.path : undefined, note: typeof msg.note === "string" ? msg.note : undefined }),
|
|
32
|
+
"apps.present": (msg) => service.present(String(msg.sessionId), { target: typeof msg.target === "string" ? msg.target : undefined, path: typeof msg.path === "string" ? msg.path : undefined, note: typeof msg.note === "string" ? msg.note : undefined, try: Array.isArray(msg.try) ? msg.try.filter((id) => typeof id === "string") : undefined }),
|
|
33
|
+
"apps.scenarios": (msg) => service.scenarios(String(msg.sessionId), typeof msg.target === "string" ? msg.target : undefined),
|
|
33
34
|
"apps.showMe": (msg) => service.present(String(msg.sessionId), { target: typeof msg.appId === "string" ? msg.appId : undefined, trigger: "asked" }),
|
|
34
35
|
"apps.mute": (msg) => service.mute(String(msg.sessionId)),
|
|
35
36
|
"apps.reviewMode": (msg) => service.setReviewMode(String(msg.sessionId), String(msg.appId), msg.mode),
|
package/dist/exec.js
CHANGED
|
@@ -174,7 +174,10 @@ async function main() {
|
|
|
174
174
|
const model = created.model;
|
|
175
175
|
if (args.model && model?.id) {
|
|
176
176
|
const qualified = !model.provider || model.id.startsWith(`${model.provider}/`) ? model.id : `${model.provider}/${model.id}`;
|
|
177
|
-
|
|
177
|
+
// Compare model names: the same model can sit under another provider
|
|
178
|
+
// spelling in this agent (openai-codex/gpt-5.6-sol is OpenCode's openai/gpt-5.6-sol).
|
|
179
|
+
const name = (id) => id.slice(id.lastIndexOf("/") + 1);
|
|
180
|
+
if (![model.id, qualified].some((id) => id === args.model || name(id) === name(args.model))) {
|
|
178
181
|
err(`Model "${args.model}" is not available for this agent; using ${qualified}.\n`);
|
|
179
182
|
}
|
|
180
183
|
}
|
package/dist/node-env.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Imported first by server.ts, so the node's environment is clean before any
|
|
4
|
+
// module reads it or launches an agent (see runtime/inherited-session-env.ts).
|
|
5
|
+
import { scrubInheritedSessionEnv } from "./runtime/inherited-session-env.js";
|
|
6
|
+
scrubInheritedSessionEnv();
|
|
@@ -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", "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", "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" },
|
|
@@ -17,11 +17,12 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
17
17
|
"apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
|
|
18
18
|
"apps.offers": Type.Object({ ...request, ...optionalSession }),
|
|
19
19
|
"apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
|
|
20
|
-
"apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
|
20
|
+
"apps.open": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), returnTo: Type.Optional(Type.String({ maxLength: 2048 })), direct: Type.Optional(Type.Boolean()), scale: Type.Optional(Type.Integer({ minimum: 1, maximum: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })), scenario: Type.Optional(Type.String({ maxLength: 64 })) }),
|
|
21
21
|
"apps.shot": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()), widths: Type.Optional(Type.Array(Type.Integer(), { maxItems: 4 })), themes: Type.Optional(Type.Array(Type.Union([Type.Literal("light"), Type.Literal("dark")]), { maxItems: 2 })), path: Type.Optional(Type.String({ maxLength: 2048 })) }),
|
|
22
22
|
"apps.input": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), action: Type.Object({ kind: Type.String({ maxLength: 20 }) }) }),
|
|
23
23
|
"apps.menu": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { minItems: 1, maxItems: 8 })) }),
|
|
24
|
-
"apps.present": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.String({ maxLength: 2048 })), note: Type.Optional(Type.String({ maxLength: 2000 })) }),
|
|
24
|
+
"apps.present": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })), path: Type.Optional(Type.String({ maxLength: 2048 })), note: Type.Optional(Type.String({ maxLength: 2000 })), try: Type.Optional(Type.Array(Type.String({ maxLength: 64 }), { maxItems: 6 })) }),
|
|
25
|
+
"apps.scenarios": Type.Object({ ...request, ...session, target: Type.Optional(Type.String({ maxLength: 200 })) }),
|
|
25
26
|
"apps.showMe": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()) }),
|
|
26
27
|
"apps.mute": Type.Object({ ...request, ...session }),
|
|
27
28
|
"apps.reviewMode": Type.Object({ ...request, ...session, appId: Type.String(), mode: Type.Union([Type.Literal("ready"), Type.Literal("every"), Type.Literal("off")]) }),
|