@bivy/bivy 0.19.4-staging.1 → 0.19.4-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.
@@ -0,0 +1,214 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // The `bivy` command table: one row per top-level command. bin/bivy.mjs
4
+ // dispatches only commands found here, and `bivy help`, `bivy help --json`,
5
+ // shell completions, "did you mean" and `pnpm run check:cli` (which keeps
6
+ // docs/cli-reference.md in step) all read these rows. Adding a command means
7
+ // adding a row; a command without one cannot be dispatched.
8
+ //
9
+ // Fields:
10
+ // name canonical name (what the dispatcher switches on)
11
+ // aliases other spellings that dispatch to it
12
+ // group help section (GROUPS below, in display order)
13
+ // usage one-line synopsis after `bivy`
14
+ // summary what it does, one line
15
+ // scope "session" (acts on $BIVY_SESSION_ID), "node" (this machine) or "account"
16
+ // json true when it accepts --json (or honors BIVY_OUTPUT=json)
17
+ // subcommands first-argument verbs, for completion and discovery
18
+ // hidden plumbing launched by Bivy itself; left out of help and completions
19
+
20
+ /** Exit codes shared by the commands agents call. 0 and 1 hold everywhere. */
21
+ export const EXIT = Object.freeze({ ok: 0, failed: 1, usage: 2, notFound: 3, denied: 4, timeout: 5, conflict: 6, unavailable: 75 });
22
+
23
+ export const GROUPS = [
24
+ ["session", "Inside an agent session (defaults to $BIVY_SESSION_ID)"],
25
+ ["sessions", "Agents and sessions"],
26
+ ["runs", "Runs and automations"],
27
+ ["node", "This node"],
28
+ ["accounts", "Accounts, providers and credentials"],
29
+ ["config", "Configuration and extensions"],
30
+ ["help", "Help and discovery"],
31
+ ];
32
+
33
+ export const COMMANDS = [
34
+ // Inside an agent session
35
+ { name: "context", group: "session", scope: "session", json: true, usage: "context [--json]", summary: "Where this agent is running: session, workspace, machine, apps, and what to run next" },
36
+ { name: "attach", group: "session", scope: "session", json: true, usage: 'attach <file> [--caption "…"] [--artifact]', summary: "Show a local file or image to the user in the chat" },
37
+ { name: "suggest", group: "session", scope: "session", json: true, usage: 'suggest "<task>" [--title "label"]', summary: "Propose a task the user can start in one tap" },
38
+ { name: "app", group: "session", scope: "session", json: true, usage: "app <publish|shot|present|share|run|…>", summary: "Live previews: publish a web/terminal/desktop app, screenshot it, present it", subcommands: ["publish", "list", "remove", "shot", "present", "share", "notes", "run", "click", "type", "key", "scroll", "drag", "move", "menu"] },
39
+ { name: "delegate", group: "session", scope: "session", json: true, usage: 'delegate "<task>" [--agent <id>] [--machine <name>] [--wait]', summary: "Hand a task to another agent or machine and get its answer back", subcommands: ["machines", "status", "wait"] },
40
+
41
+ // Agents and sessions
42
+ { name: "run", group: "sessions", scope: "node", usage: "run <agent> [--chat] [--name …] [--model …] [--node …] [--workspace …]", summary: "Run an agent (native CLI/TUI) as a session the app can see" },
43
+ { name: "exec", group: "sessions", scope: "node", json: true, usage: 'exec "<prompt>" [--agent …] [--session …]', summary: "One-shot headless session: prints the answer to stdout" },
44
+ { name: "send", group: "sessions", scope: "node", json: true, usage: 'send <id> "<message>"', summary: "Send a prompt to an existing session and stream the reply" },
45
+ { name: "sessions", aliases: ["ls"], group: "sessions", scope: "node", json: true, usage: "sessions [--json]", summary: "List recent sessions (live and saved) and resume one" },
46
+ { name: "resume", group: "sessions", scope: "node", usage: "resume [n|id]", summary: "Resume a session directly (default: most recent)" },
47
+ { name: "kill", group: "sessions", scope: "node", usage: "kill <id> [--delete]", summary: "Stop a session or terminal" },
48
+ { name: "takeover", group: "sessions", scope: "node", usage: "takeover <id>", summary: "Stop a run-terminal's native TUI and continue it as a governed chat" },
49
+ { name: "promote", group: "sessions", scope: "node", usage: "promote <session-id>", summary: "Continue a warm-replicated session on this node" },
50
+ { name: "prune", aliases: ["clean"], group: "sessions", scope: "node", json: true, usage: "prune [--keep N] [--older-than 7d] [--dry-run]", summary: "Delete old sessions, workspaces and worktrees" },
51
+ { name: "agents", group: "sessions", scope: "node", json: true, usage: "agents [--json]", summary: "List supported agents and which are installed" },
52
+ { name: "agent", group: "sessions", scope: "node", json: true, usage: "agent <add|list|remove>", summary: "Connect, list or remove a user-owned agent", subcommands: ["add", "list", "remove"] },
53
+ { name: "agents:install", aliases: ["runtimes:install"], group: "sessions", scope: "node", usage: "agents:install [--bridges]", summary: "Install known upstream agents" },
54
+ { name: "shim", aliases: ["listen"], group: "sessions", scope: "node", usage: "shim [install|uninstall|status] <agent>", summary: "Make typing '<agent>' open its TUI in a session the app can see", subcommands: ["install", "uninstall", "status"] },
55
+
56
+ // Runs and automations
57
+ { name: "runs", group: "runs", scope: "account", json: true, usage: 'runs <start|list|status|wait>', summary: "Unattended Runs with checks, evidence and a Receipt", subcommands: ["start", "list", "status", "wait"] },
58
+ { name: "automation", aliases: ["automations"], group: "runs", scope: "account", json: true, usage: "automation <list|trigger|init|validate|plan|test|apply>", summary: "Automations as code: validate, plan, apply, trigger", subcommands: ["list", "trigger", "init", "validate", "plan", "test", "test-filter", "apply"] },
59
+
60
+ // This node
61
+ { name: "setup", aliases: ["init"], group: "node", scope: "node", usage: "setup", summary: "First-run wizard: agent, model login, remote sign-in, background service" },
62
+ { name: "start", aliases: ["dev"], group: "node", scope: "node", usage: "start", summary: "Run the daemon in the foreground" },
63
+ { name: "stop", group: "node", scope: "node", usage: "stop", summary: "Stop the background service" },
64
+ { name: "restart", group: "node", scope: "node", usage: "restart [--force]", summary: "Restart the background service after active turns finish" },
65
+ { name: "status", group: "node", scope: "node", json: true, usage: "status [--json]", summary: "Show config and whether the node is reachable" },
66
+ { name: "doctor", group: "node", scope: "node", usage: "doctor", summary: "Health check: deps, node, model, remote, agents" },
67
+ { name: "diagnostics", group: "node", scope: "node", usage: "diagnostics [--out <file>]", summary: "Print a redacted, shareable diagnostics bundle" },
68
+ { name: "capabilities", group: "node", scope: "node", json: true, usage: "capabilities [--json]", summary: "What this machine unlocks: OS, agents, providers, Docker/GPU, plugins" },
69
+ { name: "logs", group: "node", scope: "node", usage: "logs [-f]", summary: "Tail the node logs" },
70
+ { name: "audit", group: "node", scope: "node", json: true, usage: "audit [--session <id>] [--verify] [--json]", summary: "Show and verify the node's governance audit trail" },
71
+ { name: "update", group: "node", scope: "node", usage: "update [--force]", summary: "Update Bivy, install deps and restart the service" },
72
+ { name: "update:log", group: "node", scope: "node", usage: "update:log", summary: "Show output of the last (or in-progress) update" },
73
+ { name: "service", group: "node", scope: "node", usage: "service <install|uninstall|status>", summary: "Manage the background service", subcommands: ["install", "uninstall", "status"] },
74
+ { name: "rename", aliases: ["node:rename"], group: "node", scope: "node", usage: "rename <name>", summary: "Rename this node (no restart)" },
75
+ { name: "nodes", group: "node", scope: "node", usage: "nodes [list|add|remove]", summary: "List, add or remove other nodes", subcommands: ["list", "add", "remove"] },
76
+ { name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
77
+ { name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
78
+ { name: "link", group: "node", scope: "node", usage: "link", summary: "Show a web app link as a QR code in the terminal" },
79
+ { name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
80
+ { name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
81
+
82
+ // Accounts, providers and credentials
83
+ { name: "login", group: "accounts", scope: "account", usage: "login", summary: "Sign this machine into a Bivy account" },
84
+ { name: "logout", aliases: ["signout"], group: "accounts", scope: "account", usage: "logout", summary: "Sign this machine out" },
85
+ { name: "provider", aliases: ["model"], group: "accounts", scope: "node", usage: "provider login [provider]", summary: "Sign into a model provider", subcommands: ["login"] },
86
+ { name: "auth", aliases: ["credentials", "creds"], group: "accounts", scope: "node", usage: "auth <list|add|remove|import|sync|…>", summary: "Model credentials: import local logins, add, sync", subcommands: ["list", "add", "remove", "sync", "preset", "ingest", "config", "import"] },
87
+ { name: "secrets", aliases: ["secret"], group: "accounts", scope: "node", usage: "secrets <list|set|ref|delete|doctor|resolve>", summary: "Store tokens in the node vault", subcommands: ["list", "set", "ref", "delete", "doctor", "resolve"] },
88
+ { name: "github:connect", aliases: ["connect-repo"], group: "accounts", scope: "node", usage: "github:connect [owner/repo]", summary: "Connect GitHub" },
89
+ { name: "github:app-create", group: "accounts", scope: "node", usage: "github:app-create [--org <org>]", summary: "One-click: create and connect a GitHub App" },
90
+ { name: "github:app-connect", group: "accounts", scope: "node", usage: "github:app-connect --app-id <id> --key <path.pem>", summary: "Connect an existing GitHub App" },
91
+ { name: "github:app-sync", group: "accounts", scope: "account", usage: "github:app-sync [on|off]", summary: "Sync GitHub App keys to this account's other nodes" },
92
+
93
+ // Configuration and extensions
94
+ { name: "config", group: "config", scope: "node", json: true, usage: "config <show|get|set|unset|explain|validate|init|path>", summary: "Typed node configuration", subcommands: ["init", "validate", "show", "get", "set", "unset", "explain", "path"] },
95
+ { name: "plugin", aliases: ["plugins"], group: "config", scope: "node", json: true, usage: "plugin <init|validate|doctor|test|install|list|remove>", summary: "Build and install plugins", subcommands: ["init", "validate", "doctor", "test", "install", "list", "remove"] },
96
+ { name: "voice", aliases: ["stt"], group: "config", scope: "node", usage: "voice <provider|key|remove|status>", summary: "Configure speech-to-text", subcommands: ["provider", "key", "remove", "status"] },
97
+ { name: "completions", aliases: ["completion"], group: "config", scope: "node", usage: "completions <bash|zsh|fish>", summary: "Print a shell completion script", subcommands: ["bash", "zsh", "fish"] },
98
+
99
+ // Help and discovery
100
+ { name: "help", aliases: ["-h", "--help"], group: "help", scope: "node", json: true, usage: "help [command] [--json]", summary: "Show this help, one command's row, or every command as JSON" },
101
+ { name: "version", aliases: ["--version", "-v"], group: "help", scope: "node", usage: "version", summary: "Print the installed Bivy version" },
102
+
103
+ // Plumbing
104
+ { name: "mcp-serve", group: "help", scope: "session", hidden: true, usage: "mcp-serve", summary: "Bivy's MCP server for agents (stdio; injected into agent MCP config)" },
105
+ { name: "mcp-proxy", group: "help", scope: "session", hidden: true, usage: "mcp-proxy -- <server command>", summary: "MCP proxy in front of an agent's own MCP servers (stdio)" },
106
+ ];
107
+
108
+ const byWord = new Map();
109
+ for (const command of COMMANDS) for (const word of [command.name, ...(command.aliases ?? [])]) byWord.set(word, command);
110
+
111
+ /** The row for a typed command word (name or alias), or undefined. */
112
+ export function resolveCommand(word) {
113
+ return typeof word === "string" ? byWord.get(word) : undefined;
114
+ }
115
+
116
+ // Edit distance where swapping two neighbors counts once ("hepl" → "help").
117
+ function distance(a, b) {
118
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
119
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
120
+ for (let i = 1; i <= a.length; i++) {
121
+ for (let j = 1; j <= b.length; j++) {
122
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
123
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
124
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
125
+ }
126
+ }
127
+ return d[a.length][b.length];
128
+ }
129
+
130
+ /** Closest visible command names to a mistyped word, best first (at most 3). */
131
+ export function suggestCommands(word) {
132
+ const typed = String(word ?? "").toLowerCase();
133
+ if (!typed) return [];
134
+ const scored = [];
135
+ for (const command of COMMANDS) {
136
+ if (command.hidden) continue;
137
+ const words = [command.name, ...(command.aliases ?? [])].filter((w) => !w.startsWith("-"));
138
+ const best = Math.min(...words.map((w) => (w.startsWith(typed) || typed.startsWith(w) ? 0 : distance(typed, w))));
139
+ if (best <= Math.max(1, Math.floor(typed.length / 3))) scored.push([best, command.name]);
140
+ }
141
+ return scored.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1])).slice(0, 3).map(([, name]) => name);
142
+ }
143
+
144
+ /** The words shells should complete after `bivy`. */
145
+ export function completionWords() {
146
+ return COMMANDS.filter((command) => !command.hidden).flatMap((command) => [command.name, ...(command.aliases ?? []).filter((a) => !a.startsWith("-"))]);
147
+ }
148
+
149
+ /** Commands with first-argument verbs, for completing `bivy <cmd> <verb>`. */
150
+ export function subcommandTable() {
151
+ return COMMANDS.filter((command) => !command.hidden && command.subcommands?.length).map((command) => ({
152
+ words: [command.name, ...(command.aliases ?? [])],
153
+ subcommands: command.subcommands,
154
+ }));
155
+ }
156
+
157
+ /** One command as plain data: what `bivy help --json` and `bivy help <cmd> --json` print. */
158
+ export function describeCommand(command) {
159
+ const { name, aliases = [], group, usage, summary, scope, json = false, subcommands = [] } = command;
160
+ return { name, aliases, group, usage: `bivy ${usage}`, summary, scope, json, subcommands, help: `bivy ${name} --help` };
161
+ }
162
+
163
+ export function describeCli() {
164
+ return {
165
+ exitCodes: EXIT,
166
+ output: "Commands marked json accept --json; BIVY_OUTPUT=json turns it on for every such command.",
167
+ errors: "With --json, failures print {\"error\":{\"code\",\"message\",\"hint\"?,\"next\"?}} to stderr.",
168
+ commands: COMMANDS.filter((command) => !command.hidden).map(describeCommand),
169
+ };
170
+ }
171
+
172
+ /** `bivy help` text. `paint` colors a command synopsis (the CLI passes its cyan);
173
+ * `details` adds a line under a command, for lists only known at runtime. */
174
+ export function renderHelp({ paint = (s) => s, bold = (s) => s, details = {} } = {}) {
175
+ const visible = COMMANDS.filter((command) => !command.hidden);
176
+ const width = Math.min(44, Math.max(...visible.map((command) => command.usage.length)) + 5);
177
+ const lines = [`${bold("bivy")} — Bivy node CLI`, ""];
178
+ for (const [group, title] of GROUPS) {
179
+ const rows = visible.filter((command) => command.group === group);
180
+ if (!rows.length) continue;
181
+ lines.push(bold(title));
182
+ for (const command of rows) {
183
+ const synopsis = `bivy ${command.usage}`;
184
+ const pad = synopsis.length < width ? " ".repeat(width - synopsis.length) : "\n" + " ".repeat(width + 2);
185
+ lines.push(` ${paint(synopsis)}${pad}${command.summary}`);
186
+ if (details[command.name]) lines.push(`${" ".repeat(width + 2)}${details[command.name]}`);
187
+ }
188
+ lines.push("");
189
+ }
190
+ lines.push("Every command takes --help. 'bivy help --json' lists all commands, flags that take --json, and exit codes.");
191
+ lines.push("Agents: start with 'bivy context'.");
192
+ return lines.join("\n");
193
+ }
194
+
195
+ /** `--json` was passed, or BIVY_OUTPUT=json asks for JSON everywhere. */
196
+ export function wantsJson(args = [], env = process.env) {
197
+ return args.includes("--json") || String(env?.BIVY_OUTPUT ?? "").toLowerCase() === "json";
198
+ }
199
+
200
+ /**
201
+ * The one error shape for commands agents call: JSON on stderr under --json, a
202
+ * readable line otherwise, then exit with a code from EXIT. `next` names the
203
+ * command that gets the caller unstuck.
204
+ */
205
+ export function cliError({ code, message, hint, next, exit = EXIT.failed }, { json = false, paint = (s) => s } = {}) {
206
+ if (json) {
207
+ process.stderr.write(`${JSON.stringify({ error: { code, message, ...(hint ? { hint } : {}), ...(next ? { next } : {}) } })}\n`);
208
+ } else {
209
+ process.stderr.write(`${paint(message)}\n`);
210
+ if (hint) process.stderr.write(`${hint}\n`);
211
+ if (next) process.stderr.write(`Try: ${next}\n`);
212
+ }
213
+ process.exit(exit);
214
+ }
@@ -46,6 +46,7 @@ export const BIVY_AGENT_NOTE = [
46
46
  '`bivy delegate "<self-contained task>" --agent <id> [--machine <name>] --wait` runs it there and prints ' +
47
47
  "its answer and any branch/PR; `--to codex,grok@<machine>` asks several to compare, `bivy delegate machines` " +
48
48
  "lists machines and their agents. Use your own sub-agents for everything else.",
49
+ "- `bivy context` shows your session, workspace and published apps; `bivy help` lists every command (`--json` for all).",
49
50
  ].join("\n");
50
51
  const PREAMBLE = "The user's global Bivy instructions follow. They apply to every workspace; where they conflict with " +
51
52
  "instructions from the current repository (AGENTS.md, CLAUDE.md, …), the repository's instructions win.";
@@ -16,6 +16,29 @@ export function readElementScrolls(input) {
16
16
  return { selector: raw.selector, x: raw.x, y: raw.y };
17
17
  });
18
18
  }
19
+ const MAX_NOTES = 20;
20
+ /** Notes from the client (untrusted shape): numbered, bounded, with strokes. */
21
+ export function readNotes(input) {
22
+ if (input === undefined)
23
+ return [];
24
+ if (!Array.isArray(input) || !input.length || input.length > MAX_NOTES)
25
+ throw new Error(`A message carries 1 to ${MAX_NOTES} notes.`);
26
+ return input.map((raw, index) => {
27
+ const note = raw;
28
+ if (!Number.isInteger(note?.n) || note.n < 1 || note.n > MAX_NOTES)
29
+ throw new Error("Invalid note number.");
30
+ if (note.words !== undefined && typeof note.words !== "string")
31
+ throw new Error("Invalid note text.");
32
+ const selectors = Array.isArray(note.selectors) ? note.selectors.filter((s) => typeof s === "string" && !!s && s.length <= 300).slice(0, 8) : [];
33
+ return { n: index + 1, words: typeof note.words === "string" ? note.words.trim().slice(0, 2000) : "", selectors, strokes: readStrokes(raw.strokes) };
34
+ });
35
+ }
36
+ /** Where a note's number sits: the top-left of everything it covers. */
37
+ export function noteAnchor(note) {
38
+ const xs = note.strokes.flatMap((stroke) => stroke.points.map(([x]) => x));
39
+ const ys = note.strokes.flatMap((stroke) => stroke.points.map(([, y]) => y));
40
+ return { x: Math.min(...xs), y: Math.min(...ys) };
41
+ }
19
42
  const MAX_STROKES = 50;
20
43
  const MAX_POINTS = 10_000;
21
44
  /** Validates strokes from the client (untrusted shape): bounded, finite. */
@@ -62,10 +85,57 @@ const HALO = tokenColor("annotate-halo", "#ffffff");
62
85
  /** Mark width in CSS pixels, and its halo on each side. */
63
86
  const WIDTH = 4;
64
87
  const HALO_WIDTH = 1.5;
88
+ /** A note's number, in CSS pixels: the pill's half-height, and the digits. */
89
+ const PIP = { radius: 11, height: 12, width: 6, gap: 3, stroke: 1.6 };
90
+ /** Seven-segment digits, drawn rather than typeset: the picture has to carry
91
+ * the numbers, and a PNG writer has no font. Adding a glyph is adding a row.
92
+ *
93
+ * aaa
94
+ * f b
95
+ * ggg
96
+ * e c
97
+ * ddd
98
+ */
99
+ const SEGMENTS = {
100
+ "0": "abcdef", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc",
101
+ "5": "afgcd", "6": "afgecd", "7": "abc", "8": "abcdefg", "9": "abcdfg",
102
+ };
103
+ function digit(glyph, x, y, w, h) {
104
+ const middle = y + h / 2;
105
+ const lines = {
106
+ a: [[x, y], [x + w, y]], b: [[x + w, y], [x + w, middle]], c: [[x + w, middle], [x + w, y + h]],
107
+ d: [[x, y + h], [x + w, y + h]], e: [[x, middle], [x, y + h]], f: [[x, y], [x, middle]], g: [[x, middle], [x + w, middle]],
108
+ };
109
+ return [...(SEGMENTS[glyph] ?? "")].map((name) => lines[name]);
110
+ }
111
+ /** Cuts out the part of a picture a pin is about, with room around it so the
112
+ * mark is read in its surroundings rather than in isolation. `scale` maps CSS
113
+ * pixels to image pixels. Returns the whole picture when the cut would be
114
+ * empty or nearly all of it. */
115
+ export function crop(png, region, scale, padding = 24) {
116
+ const image = decodePng(png);
117
+ const { width, height, channels } = image;
118
+ const pad = padding * scale;
119
+ const left = Math.max(0, Math.floor((region.x - padding) * scale));
120
+ const top = Math.max(0, Math.floor((region.y - padding) * scale));
121
+ const right = Math.min(width, Math.ceil((region.x + region.width) * scale + pad));
122
+ const bottom = Math.min(height, Math.ceil((region.y + region.height) * scale + pad));
123
+ const w = right - left, h = bottom - top;
124
+ if (w < 8 || h < 8 || (w >= width * 0.9 && h >= height * 0.9))
125
+ return png;
126
+ const rgb = Buffer.alloc(w * h * 3);
127
+ for (let y = 0; y < h; y++) {
128
+ for (let x = 0; x < w; x++) {
129
+ const from = ((y + top) * width + (x + left)) * channels;
130
+ image.pixels.copy(rgb, (y * w + x) * 3, from, from + 3);
131
+ }
132
+ }
133
+ return encodePng(w, h, rgb);
134
+ }
65
135
  /** Draws marks onto a PNG. `scale` maps CSS pixels to image pixels;
66
136
  * `offset` (CSS px) is subtracted first — the scroll position the picture
67
137
  * was taken at. Halos go under every mark first, so crossings stay clean. */
68
- export function composite(png, strokes, map) {
138
+ export function composite(png, strokes, map, badges = []) {
69
139
  const image = decodePng(png);
70
140
  const { width, height, channels } = image;
71
141
  const rgb = Buffer.alloc(width * height * 3);
@@ -103,5 +173,19 @@ export function composite(png, strokes, map) {
103
173
  trace(path, r + HALO_WIDTH * map.scale, HALO);
104
174
  for (const path of paths)
105
175
  trace(path, r, INK);
176
+ // The numbers last, over the marks, so a note and the thing it is about stay
177
+ // paired in the picture as well as in the words.
178
+ for (const badge of badges) {
179
+ const glyphs = [...String(badge.n)];
180
+ const [pw, ph, gap, pr] = [PIP.width, PIP.height, PIP.gap, PIP.radius].map((v) => v * map.scale);
181
+ const span = glyphs.length * pw + (glyphs.length - 1) * gap;
182
+ const [cx, cy] = toImage([badge.x, badge.y]);
183
+ // A traced line of radius `pr` is a pill: a disc at each end, filled between.
184
+ trace([[cx - span / 2, cy], [cx + span / 2, cy]], pr, INK);
185
+ glyphs.forEach((glyph, index) => {
186
+ for (const segment of digit(glyph, cx - span / 2 + index * (pw + gap), cy - ph / 2, pw, ph))
187
+ trace(segment, PIP.stroke * map.scale, HALO);
188
+ });
189
+ }
106
190
  return encodePng(width, height, rgb);
107
191
  }
@@ -83,7 +83,10 @@ function startPointing(){
83
83
  document.documentElement.append(layer,box);
84
84
  addEventListener('keydown',key,true);
85
85
  }
86
- for(const type of ['pointerdown','keydown'])addEventListener(type,e=>{if(!layer&&!(host&&e.composedPath?.().includes(host)))interacted=true;},true);
86
+ // "before" keeps the value this event changed, so a press that turns out to be
87
+ // a mark (not use of the app) can put it back.
88
+ let interactedBefore=false;
89
+ for(const type of ['pointerdown','keydown'])addEventListener(type,e=>{if(!layer&&!(host&&e.composedPath?.().includes(host))){interactedBefore=interacted;interacted=true;}},true);
87
90
  // Draw (in the shell, over this page): where the page is, what state it holds
88
91
  // that a fresh browser on the machine wouldn't, and what's under each mark.
89
92
  const signals=()=>{
@@ -135,13 +138,86 @@ addEventListener('message',e=>{
135
138
  }
136
139
  else if(d.type==='bivy:draw')post(drawState());
137
140
  else if(d.type==='bivy:scroll'){scrollBy({left:n(d.dx),top:n(d.dy),behavior:'instant'});post({type:'scrolled',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)}});}
141
+ // Where the reader was before they took a new version. The page may still be
142
+ // laying out (or fetching what makes it long enough), so try again briefly.
143
+ else if(d.type==='bivy:restore'){
144
+ const panels=Array.isArray(d.elementScrolls)?d.elementScrolls.slice(0,50):[];
145
+ const put=()=>{
146
+ scrollTo({left:n(d.scroll?.x),top:n(d.scroll?.y),behavior:'instant'});
147
+ for(const p of panels){try{document.querySelector(p.selector)?.scrollTo({left:n(p.x),top:n(p.y),behavior:'instant'});}catch{}}
148
+ };
149
+ for(const ms of [0,150,500])setTimeout(put,ms);
150
+ }
138
151
  else if(d.type==='bivy:marks'&&d.rect)post({type:'marked',id:n(d.id),elements:marked({x:n(d.rect.x),y:n(d.rect.y),width:n(d.rect.width),height:n(d.rect.height)})});
139
152
  });
140
- addEventListener('message',e=>{
141
- if(!framed||e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:point')return;
142
- // Held: the layer goes when the finger lifts (see up).
143
- if(e.data.on)startPointing();else if(!held)stop();
144
- });
153
+ // Marking, started from inside the app: a long press on what's wrong. A tap is
154
+ // left alone, so the app stays usable; so are presses on a field or on text the
155
+ // reader is selecting, where a long press already means something to them.
156
+ // Once it fires, the rest of the gesture is swallowed here — the shell's marking
157
+ // layer cannot receive a gesture that began in this document — and its points
158
+ // are streamed to the shell.
159
+ if(framed){
160
+ let hold=null,armed=true;
161
+ const page=e=>[Math.round(e.clientX+scrollX),Math.round(e.clientY+scrollY)];
162
+ // A long press already means something where the reader is typing or selecting.
163
+ const theirs=el=>{
164
+ for(let n=el;n&&n.nodeType===1;n=n.parentElement)if(/^(input|textarea|select)$/.test(n.localName)||n.isContentEditable)return true;
165
+ return getSelection?.()?.isCollapsed===false;
166
+ };
167
+ const clear=()=>{clearTimeout(hold?.timer);hold=null;};
168
+ /** A clear layer so nothing new reaches the app while a mark is being made.
169
+ * It cannot take this gesture's events (the app's element already has them,
170
+ * which is what the swallowing below is for), but it stops everything else. */
171
+ const cover=()=>{
172
+ stop();
173
+ layer=document.createElement('div');
174
+ layer.setAttribute('aria-hidden','true');
175
+ Object.assign(layer.style,{position:'fixed',inset:0,zIndex:2147483646,cursor:'crosshair',background:'transparent',touchAction:'none',userSelect:'none',webkitUserSelect:'none',webkitTouchCallout:'none'});
176
+ layer.addEventListener('contextmenu',e=>e.preventDefault());
177
+ document.documentElement.append(layer);
178
+ };
179
+ const begin=()=>{
180
+ const start=hold;if(!start)return;
181
+ // The press was marking, not using the app: the picture is still faithful.
182
+ interacted=start.before;
183
+ hold={marking:true,id:start.id};
184
+ cover();
185
+ const el=under({clientX:start.client[0],clientY:start.client[1]});
186
+ const r=el&&el.getBoundingClientRect();
187
+ post(Object.assign(drawState(),{type:'mark-start',point:start.point,
188
+ element:el?{selector:selector(el),tag:el.localName,text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,120),
189
+ rect:{x:Math.round(r.left+scrollX),y:Math.round(r.top+scrollY),width:Math.round(r.width),height:Math.round(r.height)}}:undefined}));
190
+ };
191
+ addEventListener('pointerdown',e=>{
192
+ if(!armed||hold?.marking||layer||(host&&e.composedPath?.().includes(host))||!e.isPrimary||theirs(e.target))return;
193
+ clear();
194
+ hold={id:e.pointerId,client:[e.clientX,e.clientY],point:page(e),before:interactedBefore,timer:setTimeout(begin,450)};
195
+ },true);
196
+ addEventListener('pointermove',e=>{
197
+ if(!hold||e.pointerId!==hold.id)return;
198
+ if(hold.marking){post({type:'mark-move',point:page(e)});return;}
199
+ if(Math.hypot(e.clientX-hold.client[0],e.clientY-hold.client[1])>10)clear();
200
+ },true);
201
+ for(const type of ['pointerup','pointercancel'])addEventListener(type,e=>{
202
+ if(!hold||e.pointerId!==hold.id)return;
203
+ const marking=hold.marking;clear();
204
+ if(!marking)return;
205
+ stop();post({type:'mark-end'});
206
+ },true);
207
+ // Scrolling is never a mark, however still the finger was.
208
+ addEventListener('scroll',()=>{if(hold&&!hold.marking)clear();},true);
209
+ // Hiding Bivy's controls means "let me use the app": an app with its own
210
+ // long press (a canvas, a map) gets it back, and showing them arms it again.
211
+ addEventListener('message',e=>{
212
+ if(e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:arm')return;
213
+ armed=e.data.on!==false;
214
+ if(!armed&&hold&&!hold.marking)clear();
215
+ });
216
+ // The app must not act on the gesture it already owns: no click on release,
217
+ // no scroll, no selection, no callout.
218
+ for(const type of ['click','auxclick','dblclick','contextmenu','mousedown','mouseup','touchstart','touchmove','touchend','selectstart'])
219
+ addEventListener(type,e=>{if(hold?.marking){e.preventDefault();e.stopPropagation();}},{capture:true,passive:false});
220
+ }
145
221
  if(REVIEWER&&!framed){
146
222
  // A shadow root keeps the app's CSS off these controls (and ours off the app).
147
223
  host=document.createElement('bivy-review');