@bivy/bivy 0.20.0 → 0.20.1-staging.10

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 (162) hide show
  1. package/README.md +5 -0
  2. package/bin/access-view.mjs +43 -0
  3. package/bin/bivy.mjs +300 -18
  4. package/bin/cli-commands.mjs +7 -2
  5. package/bin/guides/talk-to-the-user.md +19 -3
  6. package/bin/install-kind.mjs +20 -0
  7. package/bin/tailscale.mjs +70 -0
  8. package/bin/tui/app.mjs +189 -0
  9. package/bin/tui/model.mjs +112 -0
  10. package/bin/tui/render.mjs +164 -0
  11. package/bin/tui/text.mjs +72 -0
  12. package/bin/tui/transcript.mjs +54 -0
  13. package/dist/access.js +72 -0
  14. package/dist/agent-instructions.js +8 -3
  15. package/dist/apps/listeners.js +31 -3
  16. package/dist/apps/service.js +8 -1
  17. package/dist/apps/styles.css +16 -2
  18. package/dist/apps/tokens.css +40 -0
  19. package/dist/auth.js +31 -4
  20. package/dist/controllers/app-commands.js +7 -2
  21. package/dist/machine-theme.js +118 -0
  22. package/dist/node-update.js +16 -0
  23. package/dist/pair-codes.js +41 -0
  24. package/dist/protocol/client-command-routes.js +2 -0
  25. package/dist/protocol/client-command-schemas.js +3 -1
  26. package/dist/remote/direct.js +24 -0
  27. package/dist/remote/index.js +2 -0
  28. package/dist/remote/tailnet.js +44 -0
  29. package/dist/server.js +165 -18
  30. package/dist/session/run-terminal.js +6 -2
  31. package/dist/session/session-namer.js +8 -2
  32. package/dist/session/session-tokens.js +1 -1
  33. package/dist/session/suggestions.js +5 -1
  34. package/dist/web-app.js +55 -0
  35. package/package.json +2 -2
  36. package/web/apple-touch-icon.png +0 -0
  37. package/web/assets/AutomationsView-CjTCAznJ.js +51 -0
  38. package/web/assets/ChangesCard-CBV_oCpv.js +4 -0
  39. package/web/assets/LibraryView-tZ-SLR74.js +2 -0
  40. package/web/assets/ProviderConnect-di0nwwfS.js +1 -0
  41. package/web/assets/ReadinessChecklist-Bbgq2GQ8.js +1 -0
  42. package/web/assets/Settings-wr27-Utd.js +10 -0
  43. package/web/assets/Sheet-DyGDs8Ap.js +2 -0
  44. package/web/assets/StatusDot-dpbztK53.js +1 -0
  45. package/web/assets/Terminal-B1njb2Cr.css +1 -0
  46. package/web/assets/Terminal-BnewuT2I.js +96 -0
  47. package/web/assets/VoiceSettings-gkjVXlwh.js +1 -0
  48. package/web/assets/abnfDiagram-VCTEODGH-xPExYTbq.js +1 -0
  49. package/web/assets/arc-Pry-7G1_.js +1 -0
  50. package/web/assets/architecture-7GRP2DOG-C2VA8oJp.js +1 -0
  51. package/web/assets/architectureDiagram-5GKGNRK7-DY9aKTXO.js +36 -0
  52. package/web/assets/array-BifhSqXX.js +1 -0
  53. package/web/assets/blockDiagram-I7D4REHJ-oAtHEG_S.js +129 -0
  54. package/web/assets/c4Diagram-7LVT6UL2-DAW5aIn-.js +38 -0
  55. package/web/assets/channel-DfQdWzMf.js +1 -0
  56. package/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  57. package/web/assets/chunk-4HAMMTFA-k55Imkrb.js +62 -0
  58. package/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  59. package/web/assets/chunk-75Z2AOVW-9BoEka2V.js +2 -0
  60. package/web/assets/chunk-DU6HZSFF-v3xjhwis.js +127 -0
  61. package/web/assets/chunk-F27PBJKO-ukCww9iX.js +1 -0
  62. package/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
  63. package/web/assets/chunk-GMAD6QVW-eEDdT117.js +72 -0
  64. package/web/assets/chunk-GVQU2GXP-BJ7btc9s.js +1 -0
  65. package/web/assets/chunk-IMKFNOWR-Cmcy1775.js +231 -0
  66. package/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  67. package/web/assets/chunk-L3NEJ4N5-BjMoq7Ow.js +1 -0
  68. package/web/assets/chunk-OSK3NFVY-03uwrVwP.js +10 -0
  69. package/web/assets/chunk-P2QGCYS3-C3poXv15.js +1 -0
  70. package/web/assets/chunk-POPQ4Y6H-DmAcZLrU.js +1 -0
  71. package/web/assets/chunk-PWAF6VOD-Bi69-MYG.js +1 -0
  72. package/web/assets/chunk-SHT3W25Y-DBVBaWqA.js +168 -0
  73. package/web/assets/chunk-SVP7TREG-DA4UOsHJ.js +88 -0
  74. package/web/assets/chunk-TICWLB2K-CcUmJxSS.js +206 -0
  75. package/web/assets/chunk-XXDRQBXY-DqAYeljN.js +1 -0
  76. package/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  77. package/web/assets/classDiagram-ZZMXUADV-DDfEWWro.js +1 -0
  78. package/web/assets/classDiagram-v2-VYDZK3BY-DDfEWWro.js +1 -0
  79. package/web/assets/common-DvIV9UK2.js +5 -0
  80. package/web/assets/cose-bilkent-JH36ORCC-BIqBF2fY.js +1 -0
  81. package/web/assets/cynefin-OW5HDTMX-B-p1iHVa.js +1 -0
  82. package/web/assets/cynefinDiagram-5FMLGOSQ-D3t5rjlb.js +62 -0
  83. package/web/assets/cytoscape.esm-NgwPT4ez.js +321 -0
  84. package/web/assets/dagre-C-rnJpZF.js +1 -0
  85. package/web/assets/dagre-GXQ25YYZ-CAVvMffj.js +4 -0
  86. package/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  87. package/web/assets/diagram-S7CK7UJ4-5avxKhVt.js +30 -0
  88. package/web/assets/diagram-UQ7AKVKN-Dre36bGM.js +41 -0
  89. package/web/assets/diagram-VSXAHHWV-DiIGVyBf.js +3 -0
  90. package/web/assets/diagram-VX7I27RA-J24keGuv.js +24 -0
  91. package/web/assets/diagram-Z3DM3KII-CO6mpfCn.js +24 -0
  92. package/web/assets/dist-v5Q1xZ2K.js +1 -0
  93. package/web/assets/ebnfDiagram-PWID7BFC-Cj8nHw-p.js +1 -0
  94. package/web/assets/erDiagram-RLTQ6QDP-DCnatJWn.js +99 -0
  95. package/web/assets/eventmodeling-NTZA5JFV-CUmkoHPv.js +1 -0
  96. package/web/assets/flowDiagram-HODETNUW-CAMIZvHT.js +1 -0
  97. package/web/assets/ganttDiagram-EL5Y4UJY-CnoyY5yJ.js +292 -0
  98. package/web/assets/gitGraph-4MIJSDKK-5QxKBdhV.js +1 -0
  99. package/web/assets/gitGraphDiagram-WWUBYQGX-m72whYP5.js +106 -0
  100. package/web/assets/graphlib-DS17s2tU.js +1 -0
  101. package/web/assets/index-D36pVYmX.css +1 -0
  102. package/web/assets/index-DiYWjsXW.js +3 -0
  103. package/web/assets/info-A6RAGUB7-D0yTLuOD.js +1 -0
  104. package/web/assets/infoDiagram-27XIBGKW-DhMqaJK2.js +2 -0
  105. package/web/assets/init-C-OQMol4.js +1 -0
  106. package/web/assets/ishikawaDiagram-5VMMS53U-D43D69Lw.js +70 -0
  107. package/web/assets/journeyDiagram-3NMN7TZE-BGvdefN8.js +139 -0
  108. package/web/assets/jsx-runtime-NZYk81nU.js +1 -0
  109. package/web/assets/kanban-definition-UXKFOSKX-DTLCjXTM.js +89 -0
  110. package/web/assets/katex-ZlcWpGUi.js +257 -0
  111. package/web/assets/line-D3e4bvOi.js +1 -0
  112. package/web/assets/linear-SzVmGSw4.js +1 -0
  113. package/web/assets/mermaid-parser.core-hF3j4BfN.js +7 -0
  114. package/web/assets/mermaid.core-Dt4GvTcF.js +44 -0
  115. package/web/assets/mindmap-definition-YA3MSWOX-kGjt1kFy.js +96 -0
  116. package/web/assets/mount-BjlUftih.css +1 -0
  117. package/web/assets/mount-DFFc-s3e.js +27 -0
  118. package/web/assets/ordinal-BDEzSJ7C.js +1 -0
  119. package/web/assets/packet-AYTQ26CC-uWunEOCP.js +1 -0
  120. package/web/assets/path-fybaL0A-.js +1 -0
  121. package/web/assets/pegDiagram-XKGWAZYB-DHUvSUTF.js +1 -0
  122. package/web/assets/pie-WAS4IAKB-CDjHTH5e.js +1 -0
  123. package/web/assets/pieDiagram-E7YTZNPT-BXmHg_oo.js +39 -0
  124. package/web/assets/quadrantDiagram-AXDQQJYC-wrGDXaUj.js +7 -0
  125. package/web/assets/radar-RG4KPBEZ-iriLOEHZ.js +1 -0
  126. package/web/assets/railroad-74A4TZTK-Bji_D229.js +1 -0
  127. package/web/assets/railroad-abnf-HS5TGJTU-C2rjIz3_.js +1 -0
  128. package/web/assets/railroad-ebnf-LZEXJU2U-D1IwbAAk.js +1 -0
  129. package/web/assets/railroad-peg-WCYAUIDC-D6AkTvV0.js +1 -0
  130. package/web/assets/railroadDiagram-O6MQD6OU-DaMvYvYd.js +1 -0
  131. package/web/assets/requirementDiagram-BXWQKSXE-CSk6pyHt.js +84 -0
  132. package/web/assets/rolldown-runtime-hePW80VL.js +1 -0
  133. package/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  134. package/web/assets/sankeyDiagram-P5KCCOFB-CDYh3o0x.js +40 -0
  135. package/web/assets/sequenceDiagram-WJ2MYXX4-Cebdnnr3.js +162 -0
  136. package/web/assets/settingsRoute-D0Hlh8NA.js +1 -0
  137. package/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  138. package/web/assets/src-B6xuSHsQ.js +1 -0
  139. package/web/assets/stateDiagram-D77RDMKH-B9UCBm56.js +1 -0
  140. package/web/assets/stateDiagram-v2-MP3YSRHH-Btx-OjtR.js +1 -0
  141. package/web/assets/swimlanes-42K2YHIH-zH5kmbnV.js +1 -0
  142. package/web/assets/swimlanesDiagram-VR7AAH4N-CZInbEeM.js +8 -0
  143. package/web/assets/timeline-definition-24CTP7MA-D2tCs5Ws.js +120 -0
  144. package/web/assets/treeView-Q6P3EWNA-BCo4q2Xs.js +1 -0
  145. package/web/assets/treemap-WGGIJYW6-C2Xuxw-p.js +1 -0
  146. package/web/assets/useStore-DECYZPZP.js +27 -0
  147. package/web/assets/vennDiagram-4TSXK5OY-CMD5TuHM.js +34 -0
  148. package/web/assets/wardley-WFR3VGLG-0MDv_diU.js +1 -0
  149. package/web/assets/wardleyDiagram-VM6X3IG4-BOQSNZzn.js +78 -0
  150. package/web/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  151. package/web/assets/xychartDiagram-S5SC5T6Z-B79lzllh.js +7 -0
  152. package/web/fonts/OFL.txt +90 -0
  153. package/web/fonts/newsreader-italic.woff2 +0 -0
  154. package/web/fonts/newsreader-roman.woff2 +0 -0
  155. package/web/icon-192.png +0 -0
  156. package/web/icon-512.png +0 -0
  157. package/web/icon.svg +16 -0
  158. package/web/index.html +36 -0
  159. package/web/manifest.webmanifest +1 -0
  160. package/web/runtime-config.js +3 -0
  161. package/web/sw.js +1 -0
  162. package/web/tent-logo.png +0 -0
package/dist/access.js ADDED
@@ -0,0 +1,72 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // How this machine can be reached, as one table: the setups (this machine only,
5
+ // Tailscale, Bivy hosted, your own server), what each gives you, and which are
6
+ // active here. `bivy access`, `bivy status`, setup and the app all render this
7
+ // report, so they always agree. Setups stack: each only adds to the ones before.
8
+ export const ACCESS_FEATURES = [
9
+ { id: "devices", label: "Your phone and other devices" },
10
+ { id: "machines", label: "All your machines in one app" },
11
+ { id: "push", label: "Push notifications" },
12
+ { id: "sharing", label: "Shareable app previews" },
13
+ ];
14
+ export const ACCESS_SETUPS = [
15
+ { id: "local", label: "This machine only", summary: "The terminal and a browser on this machine.", gives: { devices: 0, machines: 0, push: 0, sharing: 0 } },
16
+ { id: "tailscale", label: "Tailscale", summary: "Your devices reach your machines over your tailnet. No account, nothing in between.", command: "bivy access tailscale", gives: { devices: 1, machines: 1, push: 0, sharing: 0 } },
17
+ { id: "hosted", label: "Bivy hosted", summary: "Sign in once. Sessions stay end-to-end encrypted.", command: "bivy access hosted", gives: { devices: 2, machines: 2, push: 2, sharing: 2 } },
18
+ { id: "server", label: "Your own server", summary: "Like hosted, on a server you control.", command: "bivy access server <url>", gives: { devices: 2, machines: 2, push: 2, sharing: 2 } },
19
+ ];
20
+ /** "a, b and c" (or "a and b"). */
21
+ function and(items) {
22
+ return items.length < 2 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
23
+ }
24
+ const origin = (url) => {
25
+ try {
26
+ return url ? new URL(url).origin : "";
27
+ }
28
+ catch {
29
+ return "";
30
+ }
31
+ };
32
+ /** Which setups are active, from what the node has on disk. */
33
+ export function activeSetups(inputs) {
34
+ const active = ["local"];
35
+ if (inputs.tailscaleHostname)
36
+ active.push("tailscale");
37
+ const relay = inputs.relay;
38
+ if (relay?.room && relay.roomToken)
39
+ active.push("server");
40
+ else if (relay?.controlPlaneUrl)
41
+ active.push(origin(relay.controlPlaneUrl) === origin(inputs.hostedControlPlane) ? "hosted" : "server");
42
+ return active;
43
+ }
44
+ export function accessReport(inputs) {
45
+ const active = activeSetups(inputs);
46
+ const reach = {};
47
+ for (const { id } of ACCESS_FEATURES) {
48
+ reach[id] = Math.max(...ACCESS_SETUPS.filter((s) => active.includes(s.id)).map((s) => s.gives[id]));
49
+ }
50
+ // Hosted and your own server are alternatives: once one is on, neither is a next step.
51
+ const linked = active.includes("hosted") || active.includes("server");
52
+ const next = ACCESS_SETUPS
53
+ .filter((s) => !active.includes(s.id) && !(linked && (s.id === "hosted" || s.id === "server")))
54
+ .map((s) => {
55
+ const adds = ACCESS_FEATURES.filter((f) => s.gives[f.id] > reach[f.id]);
56
+ // What you already have on your tailnet only widens: say "from anywhere" once for those.
57
+ const widened = adds.filter((f) => reach[f.id] === 1).map((f) => f.label.toLowerCase());
58
+ const fresh = adds.filter((f) => reach[f.id] === 0).map((f) => f.label.toLowerCase());
59
+ const phrases = [...(widened.length ? [`${and(widened)} from anywhere`] : []), ...fresh];
60
+ return { id: s.id, adds: adds.map((f) => f.id), addsText: and(phrases) };
61
+ })
62
+ .filter((s) => s.adds.length > 0);
63
+ return {
64
+ features: ACCESS_FEATURES,
65
+ setups: ACCESS_SETUPS.map((s) => ({ ...s, active: active.includes(s.id) })),
66
+ active,
67
+ reach,
68
+ next,
69
+ ...(inputs.tailscaleHostname ? { tailscaleUrl: `https://${inputs.tailscaleHostname}` } : {}),
70
+ ...(inputs.relay?.controlPlaneUrl ? { controlPlaneUrl: inputs.relay.controlPlaneUrl } : {}),
71
+ };
72
+ }
@@ -39,9 +39,14 @@ export const BIVY_AGENT_NOTE = [
39
39
  "can check your work; `bivy app present` tells the user a visible change is ready to look at. " +
40
40
  "`bivy app --help` has the details.",
41
41
  "- Proposing tasks the user could hand you (next steps, ideas, options): post each with " +
42
- '`bivy suggest "<complete instruction>" [--title "short label"]` instead of only listing them. The user can ' +
43
- "start each in one tap, in this session or in a parallel one that works in its own copy of the project, so " +
44
- "write it to stand on its own, with paths relative to the project root.",
42
+ '`bivy suggest "<complete instruction>" [--title "short label"] [--run here|subagents|new]` instead of only ' +
43
+ "listing them. The user can start each in one tap, in this session, through your sub-agents, or in a parallel " +
44
+ "one that works in its own copy of the project, so write it to stand on its own, with paths relative to the " +
45
+ "project root. `--run` is the one you recommend: here when it builds on this conversation, subagents for " +
46
+ "independent tasks you can split and supervise (only if you have sub-agents), new for bigger work worth its " +
47
+ "own session.",
48
+ '- If this session\'s title (taken from the first message) doesn\'t say what the work is, or the work changes ' +
49
+ 'direction: `bivy title "<short title>"`.',
45
50
  "- When you finish long work, get blocked, or need the user to look at something: " +
46
51
  '`bivy notify "<message>"` (a chat card, plus a push to their phone when they are away). To ask and wait for ' +
47
52
  'an answer: `bivy ask "<question>" [--option A --option B]` prints their answer (or use your own ask-the-user tool).',
@@ -19,11 +19,39 @@ export async function scanListeners(workspace, platform = process.platform) {
19
19
  }
20
20
  const found = platform === "linux" ? await scanProc(root) : platform === "darwin" ? await scanLsof(root) : [];
21
21
  const byPort = new Map();
22
- for (const offer of found)
22
+ for (const { cwd: _cwd, ...offer } of found)
23
23
  if (offer.pid !== process.pid && offer.port >= 1024 && !byPort.has(offer.port))
24
24
  byPort.set(offer.port, offer);
25
25
  return [...byPort.values()].sort((a, b) => a.port - b.port);
26
26
  }
27
+ /** Servers running anywhere under `root` (the owner's home), each with the
28
+ * project it runs in: what a first session can open before it has a folder.
29
+ * Processes running in `root` itself or in Bivy's own data folders aren't apps. */
30
+ export async function scanMachineListeners(root, bivyDirs = [path.join(root, ".bivy")], platform = process.platform) {
31
+ let top;
32
+ try {
33
+ top = await fs.realpath(root);
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ const found = platform === "linux" ? await scanProc(top) : platform === "darwin" ? await scanLsof(top) : [];
39
+ const byPort = new Map();
40
+ for (const { cwd, ...offer } of found) {
41
+ if (offer.pid === process.pid || offer.port < 1024 || byPort.has(offer.port) || cwd === top || bivyDirs.includes(cwd))
42
+ continue;
43
+ byPort.set(offer.port, { ...offer, project: await projectOf(cwd, top) });
44
+ }
45
+ return [...byPort.values()].sort((a, b) => a.port - b.port);
46
+ }
47
+ /** The nearest folder at or above `dir` (below `top`) with a .git, else `dir`. */
48
+ async function projectOf(dir, top) {
49
+ for (let at = dir; inside(top, at) && at !== top; at = path.dirname(at)) {
50
+ if (await fs.stat(path.join(at, ".git")).then(() => true, () => false))
51
+ return at;
52
+ }
53
+ return dir;
54
+ }
27
55
  const inside = (root, dir) => dir === root || dir.startsWith(root + path.sep);
28
56
  /** `local_address` in /proc/net/tcp{,6}: hex address in little-endian 32-bit words. */
29
57
  export function listenPort(line) {
@@ -73,7 +101,7 @@ async function scanProc(root) {
73
101
  return;
74
102
  const argv = (await fs.readFile(`/proc/${pid}/cmdline`, "utf8").catch(() => "")).split("\0").filter(Boolean);
75
103
  for (const port of ports)
76
- offers.push({ port, pid: Number(pid), command: commandLabel(argv) });
104
+ offers.push({ port, pid: Number(pid), command: commandLabel(argv), cwd });
77
105
  }));
78
106
  return offers;
79
107
  }
@@ -103,7 +131,7 @@ async function scanLsof(root) {
103
131
  for (const name of values) {
104
132
  const match = /^(\*|127\.[\d.]+|\[::1\]|\[::\]|localhost):(\d+)$/.exec(name);
105
133
  if (match)
106
- offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)) });
134
+ offers.push({ port: Number(match[2]), pid, command: commandLabel(args.split(/\s+/)), cwd });
107
135
  }
108
136
  }
109
137
  return offers;
@@ -6,7 +6,7 @@ import { PIN_CHANGE, REVIEW_MODES, regionChange, shouldReview, visualChange } fr
6
6
  import fs from "node:fs";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
- import { scanListeners } from "./listeners.js";
9
+ import { scanListeners, scanMachineListeners } from "./listeners.js";
10
10
  import { takeShots } from "./screenshot.js";
11
11
  import { approximate, composite, crop, noteAnchor, readNotes, readStrokes, readElementScrolls } from "./annotate.js";
12
12
  import { captureFrame, encodePng, sendInput } from "./rfb.js";
@@ -44,6 +44,7 @@ export class AppService {
44
44
  terminalStarts = new Map();
45
45
  servers = new Map();
46
46
  scan;
47
+ scanMachine;
47
48
  serverWatchMs;
48
49
  screenshots;
49
50
  displays;
@@ -68,6 +69,7 @@ export class AppService {
68
69
  this.gateway = gateway;
69
70
  this.terminals = terminals;
70
71
  this.scan = options.scan ?? scanListeners;
72
+ this.scanMachine = options.scanMachine ?? scanMachineListeners;
71
73
  this.serverWatchMs = options.serverWatchMs ?? 5_000;
72
74
  this.screenshots = { enabled: options.screenshots?.enabled ?? (() => false), take: options.screenshots?.take ?? takeShots };
73
75
  this.displays = options.displays ?? NO_DISPLAYS;
@@ -330,6 +332,11 @@ export class AppService {
330
332
  const claimed = this.registry.claimedPorts(sessionId);
331
333
  return { offers: (await this.scan(workspace)).filter((offer) => !claimed.has(offer.port)) };
332
334
  }
335
+ /** Servers running anywhere under `root`, with their projects: what a first
336
+ * session can start in and preview before it has a workspace of its own. */
337
+ async machineOffers(root) {
338
+ return { offers: await this.scanMachine(root) };
339
+ }
333
340
  /** Publish a detected server. Re-scanned so a client can't adopt an arbitrary port. */
334
341
  async adopt(sessionId, workspace, port) {
335
342
  const offer = (await this.offers(sessionId, workspace)).offers.find((item) => item.port === port);
@@ -116,6 +116,7 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
116
116
  padding: 9px var(--space-3); border: 0; border-radius: var(--radius-sm);
117
117
  background: transparent; color: var(--ink); font: inherit;
118
118
  font-size: var(--text-sm); text-align: left; cursor: pointer;
119
+ text-decoration: none; /* a menu item can be a link to another machine */
119
120
  }
120
121
  .menu-item:hover, .menu-item[aria-checked="true"], .menu-item.active { background: var(--surface-2); }
121
122
  .menu-item.danger { color: var(--danger); }
@@ -182,6 +183,7 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
182
183
  .badge[data-variant="solid"][data-tone="accent"] { background: var(--accent); }
183
184
  .badge[data-variant="solid"][data-tone="ok"] { background: var(--ok); }
184
185
  .badge[data-variant="solid"][data-tone="danger"] { background: var(--danger); color: var(--danger-contrast); }
186
+ .badge[data-variant="solid"][data-tone="unseen"] { background: var(--unseen); color: var(--unseen-contrast); }
185
187
 
186
188
  /* ---- Status ---- */
187
189
  .status-dot {
@@ -898,6 +900,9 @@ button.sheet-action:last-child { border-bottom: 0; }
898
900
  and let them wrap on a narrow phone instead of squashing. */
899
901
  .composer-lead { gap: var(--space-2); flex-wrap: wrap; }
900
902
  .composer-gh:empty { display: none; }
903
+ /* The Apps pill keeps to the band's right edge, apart from the run pill. */
904
+ .apps-pill { flex: none; margin-left: auto; }
905
+ .apps-pill > svg { flex: none; color: var(--muted); }
901
906
  .pill-gh { flex: none; }
902
907
 
903
908
  /* TUI single-writer lock: full session-window interstitial (see
@@ -2310,7 +2315,7 @@ a.queue-item:hover { border-color: var(--line-strong); background: var(--surface
2310
2315
  cards, icon over label. Rendered by Segmented.tsx. */
2311
2316
  .segmented-cards { display: flex; gap: var(--space-2); flex-wrap: wrap; margin: 4px 0 10px; }
2312
2317
  .segmented-cards .selectable {
2313
- flex: 1; min-width: 92px; display: flex; flex-direction: column; align-items: center;
2318
+ flex: 1; min-width: 72px; display: flex; flex-direction: column; align-items: center;
2314
2319
  gap: var(--space-2); padding: 16px 10px; font-size: var(--text-sm); font-weight: var(--weight-semibold);
2315
2320
  }
2316
2321
  .segmented-icon { display: inline-flex; }
@@ -3123,8 +3128,14 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3123
3128
  .suggestion-eyebrow, .delegation-eyebrow, .notice-eyebrow { margin: 0; color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
3124
3129
  .suggestion-title { margin: 0; font-size: var(--text-base); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
3125
3130
  .suggestion-text { margin: 0; color: var(--muted); font-size: var(--text-sm); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
3126
- .suggestion-actions, .suggestion-all { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
3131
+ .suggestion-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
3132
+ .suggestion-bar, .suggestion-all { display: flex; flex-direction: column; gap: var(--space-1); margin-top: var(--space-2); }
3127
3133
  .suggestion-all { padding-top: var(--space-2); border-top: 1px solid var(--line); }
3134
+ .suggestion-bar .suggestion-actions, .suggestion-all .suggestion-actions { margin-top: 0; }
3135
+ .suggestion-hint { margin: 0; color: var(--muted); font-size: var(--text-xs); }
3136
+ /* In a set, the title is the checkbox's label, so the whole line is the tap target. */
3137
+ .suggestion-pick { display: flex; align-items: flex-start; gap: var(--space-2); cursor: pointer; }
3138
+ .suggestion-pick input { flex: none; inline-size: 18px; block-size: 18px; margin: 2px 0 0; accent-color: var(--accent); }
3128
3139
  .suggestion-status { margin: var(--space-2) 0 0; display: flex; align-items: center; gap: var(--space-2); color: var(--ok); font-size: var(--text-sm); font-weight: var(--weight-medium); }
3129
3140
  /* Notice (NoticeCard.tsx): a message the agent sent with `bivy notify`.
3130
3141
  Shell and tone from .card, the urgent marker from .badge. */
@@ -3278,6 +3289,9 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
3278
3289
  .readiness .card-title { margin: 0 0 var(--space-1); }
3279
3290
  .readiness .card-sub { margin: 0; }
3280
3291
  .get-started-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-3); margin-top: var(--space-3); }
3292
+ .get-started-scan { display: flex; align-items: center; gap: var(--space-2); }
3293
+ .get-started-app { margin: var(--space-1) 0 0; overflow-wrap: anywhere; }
3294
+ .get-started-more { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-1) var(--space-2); margin-top: var(--space-2); }
3281
3295
  .readiness-checks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
3282
3296
  /* Summarize completed checks so the next setup action stays visible. */
3283
3297
  .readiness-check.state-passed { display: none; }
@@ -215,6 +215,46 @@
215
215
  }
216
216
  }
217
217
 
218
+ /* ---- Machine theme: the palette of the desktop the node runs on (Omarchy's
219
+ colors.toml), when Appearance is set to Machine. The app sets only the raw
220
+ --machine-* inputs (packages/web/src/theme.ts) and data-theme to the
221
+ palette's mode; every token below is derived here, so the mapping stays in
222
+ this file. Placed after the light/dark blocks so it wins at equal specificity. ---- */
223
+ :root[data-machine-theme] {
224
+ --bg: var(--machine-background);
225
+ --surface: var(--machine-background);
226
+ --surface-2: color-mix(in srgb, var(--machine-foreground) 6%, var(--machine-background));
227
+ --surface-3: color-mix(in srgb, var(--machine-foreground) 11%, var(--machine-background));
228
+ --ink: var(--machine-foreground);
229
+ --muted: color-mix(in srgb, var(--machine-foreground) 68%, var(--machine-background));
230
+ --line: color-mix(in srgb, var(--machine-foreground) 14%, var(--machine-background));
231
+ --line-strong: color-mix(in srgb, var(--machine-foreground) 28%, var(--machine-background));
232
+ --accent: var(--machine-accent);
233
+ --accent-contrast: var(--machine-background);
234
+ --accent-hover: color-mix(in srgb, var(--machine-accent) 80%, var(--machine-foreground));
235
+ --accent-soft: color-mix(in srgb, var(--machine-accent) 20%, var(--machine-background));
236
+ --danger: var(--machine-red);
237
+ --danger-soft: color-mix(in srgb, var(--machine-red) 20%, var(--machine-background));
238
+ --danger-contrast: var(--machine-background);
239
+ --ok: var(--machine-green);
240
+ --merged: var(--machine-magenta);
241
+ --unseen: var(--machine-yellow);
242
+ --unseen-contrast: var(--machine-background);
243
+ --warn: var(--machine-yellow);
244
+ --warn-soft: color-mix(in srgb, var(--machine-yellow) 20%, var(--machine-background));
245
+ --term-selection: color-mix(in srgb, var(--machine-selection) 70%, transparent);
246
+ --term-selection-inactive: color-mix(in srgb, var(--machine-selection) 40%, transparent);
247
+ --syntax-comment: var(--machine-muted);
248
+ --syntax-keyword: var(--machine-magenta);
249
+ --syntax-string: var(--machine-green);
250
+ --syntax-number: var(--machine-orange);
251
+ --syntax-title: var(--machine-blue);
252
+ --syntax-type: var(--machine-yellow);
253
+ --syntax-name: var(--machine-cyan);
254
+ --syntax-deletion: var(--machine-red);
255
+ --syntax-addition: var(--machine-green);
256
+ }
257
+
218
258
  @media (prefers-reduced-motion: reduce) {
219
259
  :root { --dur-fast: 0ms; --dur: 0ms; --dur-slow: 0ms; }
220
260
  }
package/dist/auth.js CHANGED
@@ -112,6 +112,32 @@ export function isLoopbackAddress(address) {
112
112
  const addr = address.replace(/^::ffff:/, "");
113
113
  return addr === "127.0.0.1" || addr === "::1" || addr === "localhost";
114
114
  }
115
+ // Sockets accepted by a listener that fronts remote callers through a local
116
+ // proxy (`tailscale serve` forwards tailnet traffic from 127.0.0.1). Their peer
117
+ // address is loopback, but the caller is not, so they never get loopback trust.
118
+ const remoteSockets = new WeakMap();
119
+ /** Treat every connection `server` accepts as remote, whatever its peer address. */
120
+ export function markRemoteListener(server, options = {}) {
121
+ server.on("connection", (socket) => remoteSockets.set(socket, options));
122
+ }
123
+ /**
124
+ * `tailnet:<login>` when a request reached a remote listener from a device
125
+ * signed in to the tailnet as the machine's owner. The listener binds loopback,
126
+ * so another local account could forge the header; honor it only where local
127
+ * callers are trusted anyway (the same rule as the loopback bypass).
128
+ */
129
+ function tailnetDevice(req) {
130
+ const options = req.socket ? remoteSockets.get(req.socket) : undefined;
131
+ const owner = options?.trustedLogin?.()?.toLowerCase();
132
+ const login = req.headers["tailscale-user-login"];
133
+ if (!owner || typeof login !== "string" || login.toLowerCase() !== owner)
134
+ return null;
135
+ return loopbackAllowed() ? `tailnet:${owner}` : null;
136
+ }
137
+ /** True when a request physically arrived over loopback from a local caller. */
138
+ export function isLoopbackRequest(req) {
139
+ return !!req.socket && !remoteSockets.has(req.socket) && isLoopbackAddress(req.socket.remoteAddress);
140
+ }
115
141
  export function extractToken(headerValue) {
116
142
  if (!headerValue)
117
143
  return null;
@@ -141,8 +167,8 @@ export function tokenFromRequest(req) {
141
167
  * `/api/auth/bootstrap`) can rely on it even when the general bypass in
142
168
  * `isAuthorized` is off. */
143
169
  export function resolveAuth(identity, req) {
144
- const loopback = isLoopbackAddress(req.socket?.remoteAddress);
145
- const deviceId = identity.verifyToken(tokenFromRequest(req));
170
+ const loopback = isLoopbackRequest(req);
171
+ const deviceId = identity.verifyToken(tokenFromRequest(req)) ?? tailnetDevice(req);
146
172
  return { deviceId, loopback };
147
173
  }
148
174
  export function isAuthorized(ctx) {
@@ -160,7 +186,8 @@ export function isAuthorized(ctx) {
160
186
  * web client (?local=1), a LAN device browsing the node directly, or CLI/native
161
187
  * clients (no Origin).
162
188
  * Remote phones reach the node through the relay, which the node dials
163
- * *outbound* — those never arrive here as inbound upgrades. So we allow only
189
+ * *outbound* — those never arrive here as inbound upgrades — or over Tailscale
190
+ * on the `*.ts.net` name, which counts as local here. So we allow only
164
191
  * local/private hostnames and reject a public Host (rebinding) or public Origin
165
192
  * (cross-site). Escape hatches: BIVY_ALLOWED_HOSTS (comma-separated extra
166
193
  * hostnames, e.g. a reverse-proxy domain) and BIVY_ALLOW_ANY_ORIGIN=1.
@@ -272,7 +299,7 @@ export function authMiddleware(identity, sessionTokens) {
272
299
  return;
273
300
  }
274
301
  sessionTokens.used?.(sessionId, call);
275
- req.auth = { deviceId: null, loopback: isLoopbackAddress(req.socket?.remoteAddress), sessionId };
302
+ req.auth = { deviceId: null, loopback: isLoopbackRequest(req), sessionId };
276
303
  next();
277
304
  return;
278
305
  }
@@ -1,4 +1,8 @@
1
- export function createAppCommands(service, workspaceFor, published = () => { }) {
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import os from "node:os";
4
+ /** `machineRoot`: where servers are looked for before a session exists (the owner's home). */
5
+ export function createAppCommands(service, workspaceFor, published = () => { }, machineRoot = os.homedir) {
2
6
  const workspaceOf = (sessionId) => {
3
7
  const workspace = workspaceFor(sessionId);
4
8
  if (!workspace)
@@ -13,7 +17,8 @@ export function createAppCommands(service, workspaceFor, published = () => { })
13
17
  published(app);
14
18
  return { app };
15
19
  },
16
- "apps.offers": (msg) => service.offers(String(msg.sessionId), workspaceOf(String(msg.sessionId))),
20
+ // Without a session: servers anywhere in the owner's home, for a first session to open.
21
+ "apps.offers": (msg) => typeof msg.sessionId === "string" ? service.offers(msg.sessionId, workspaceOf(msg.sessionId)) : service.machineOffers(machineRoot()),
17
22
  "apps.adopt": async (msg) => {
18
23
  const sessionId = String(msg.sessionId);
19
24
  const app = await service.adopt(sessionId, workspaceOf(sessionId), Number(msg.port));
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // The desktop theme of the machine a node runs on, so the app can match it.
5
+ // Today that is Omarchy: `omarchy-theme-set` stages the active theme's
6
+ // `colors.toml`, which this reads, resolves the way Omarchy does (legacy names
7
+ // and ANSI fallbacks) and sends to clients as `node.theme`. Another desktop is
8
+ // another row in THEME_SOURCES.
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ export const MACHINE_COLORS = ["background", "foreground", "accent", "selection", "muted", "red", "green", "yellow", "blue", "magenta", "cyan", "orange"];
13
+ /** Where a desktop keeps its active palette. Checked in order; the first that exists wins. */
14
+ export const THEME_SOURCES = [
15
+ // Omarchy 3.2+: the staged, generated copy of the active theme.
16
+ { colors: ".local/state/omarchy/current/theme/colors.toml", name: ".local/state/omarchy/current/theme.name" },
17
+ // Earlier Omarchy: a symlink to the active theme's directory.
18
+ { colors: ".config/omarchy/current/theme/colors.toml", name: null },
19
+ ];
20
+ // Omarchy's own resolution order (bin/omarchy-theme-color): each key's
21
+ // candidates, first defined wins. Legacy short names and ANSI colorN come last.
22
+ const FALLBACKS = {
23
+ background: ["background", "bg", "color0"],
24
+ foreground: ["foreground", "fg", "color7"],
25
+ accent: ["accent", "blue", "color4"],
26
+ selection: ["selection", "selection_background", "color8", "color0", "background", "bg"],
27
+ muted: ["muted", "color8", "dark_foreground", "dark_fg", "foreground", "fg", "color7"],
28
+ red: ["red", "color1"],
29
+ green: ["green", "color2"],
30
+ yellow: ["yellow", "color3"],
31
+ blue: ["blue", "color4"],
32
+ magenta: ["magenta", "purple", "color5"],
33
+ cyan: ["cyan", "color6"],
34
+ orange: ["orange", "yellow", "color3"],
35
+ };
36
+ const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
37
+ /** `key = "value"` pairs from a flat TOML palette; comments and tables ignored. */
38
+ export function parseFlatToml(text) {
39
+ const out = {};
40
+ for (const line of text.split("\n")) {
41
+ const match = /^\s*([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^#\s]+))/.exec(line);
42
+ if (match)
43
+ out[match[1]] = (match[2] ?? match[3] ?? match[4] ?? "").trim();
44
+ }
45
+ return out;
46
+ }
47
+ function expand(hex) {
48
+ const h = hex.toLowerCase();
49
+ return h.length === 4 ? `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}` : h;
50
+ }
51
+ function isLight(hex) {
52
+ const n = Number.parseInt(hex.slice(1), 16);
53
+ return ((n >> 16) & 255) + ((n >> 8) & 255) + (n & 255) > 382;
54
+ }
55
+ /** A palette the app can use, or null when the file lacks a usable background and foreground. */
56
+ export function resolveMachineTheme(raw, name, lightModeFile = false) {
57
+ const colors = {};
58
+ for (const key of MACHINE_COLORS) {
59
+ const value = FALLBACKS[key].map((candidate) => raw[candidate]).find((v) => v && HEX.test(v));
60
+ if (value)
61
+ colors[key] = expand(value);
62
+ }
63
+ if (!colors.background || !colors.foreground)
64
+ return null;
65
+ // Anything still missing falls back to the ink, so the app never mixes in a stray default.
66
+ for (const key of MACHINE_COLORS)
67
+ colors[key] ??= colors.foreground;
68
+ const declared = raw.mode ?? raw.theme_type;
69
+ const mode = declared === "light" || declared === "dark" ? declared : lightModeFile || isLight(colors.background) ? "light" : "dark";
70
+ return { name, mode, colors };
71
+ }
72
+ function titleCase(slug) {
73
+ return slug.replace(/(^|-)([a-z])/g, (_m, sep, c) => `${sep ? " " : ""}${c.toUpperCase()}`);
74
+ }
75
+ /** The machine's active theme, or null when no known desktop theme is present. */
76
+ export function readMachineTheme(home = os.homedir()) {
77
+ for (const source of THEME_SOURCES) {
78
+ const colorsPath = path.join(home, source.colors);
79
+ let text;
80
+ try {
81
+ text = fs.readFileSync(colorsPath, "utf8");
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ const dir = path.dirname(colorsPath);
87
+ let slug = "";
88
+ try {
89
+ slug = source.name ? fs.readFileSync(path.join(home, source.name), "utf8").trim() : path.basename(fs.realpathSync(dir));
90
+ }
91
+ catch {
92
+ /* unnamed theme */
93
+ }
94
+ return resolveMachineTheme(parseFlatToml(text), titleCase(slug || "machine"), fs.existsSync(path.join(dir, "light.mode")));
95
+ }
96
+ return null;
97
+ }
98
+ /**
99
+ * Call `onChange` whenever the machine's theme changes (a theme switch
100
+ * replaces the staged directory, so this polls the file's stat rather than
101
+ * holding an inotify watch on a directory that goes away). Returns a stop function.
102
+ */
103
+ export function watchMachineTheme(onChange, home = os.homedir(), intervalMs = 1500) {
104
+ let last = JSON.stringify(readMachineTheme(home));
105
+ const check = () => {
106
+ const theme = readMachineTheme(home);
107
+ const next = JSON.stringify(theme);
108
+ if (next === last)
109
+ return;
110
+ last = next;
111
+ onChange(theme);
112
+ };
113
+ const paths = THEME_SOURCES.flatMap((source) => [source.colors, source.name]).filter((p) => !!p).map((p) => path.join(home, p));
114
+ for (const p of paths)
115
+ fs.watchFile(p, { interval: intervalMs, persistent: false }, check);
116
+ return () => { for (const p of paths)
117
+ fs.unwatchFile(p, check); };
118
+ }
@@ -2,6 +2,22 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { gt, valid } from "semver";
4
4
  /** Match the channel recorded by install.sh and `bivy update`. */
5
+ /**
6
+ * `{ manager, update }` when a system package manager owns this install (the
7
+ * AUR's `bivy` writes `.bivy-install.json`, as bin/install-kind.mjs reads it for
8
+ * the CLI), so the node must not replace its files; null otherwise.
9
+ */
10
+ export function managedInstall(packageRoot) {
11
+ try {
12
+ const data = JSON.parse(fs.readFileSync(path.join(packageRoot, ".bivy-install.json"), "utf8"));
13
+ if (typeof data.manager === "string" && data.manager)
14
+ return { manager: data.manager, update: typeof data.update === "string" ? data.update : "" };
15
+ }
16
+ catch {
17
+ /* not managed */
18
+ }
19
+ return null;
20
+ }
5
21
  export function updateRegistryUrl(appDir, override) {
6
22
  if (override)
7
23
  return override;
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // One-time pairing codes for devices that reach the node directly (over
5
+ // Tailscale). The owner mints a code on the machine (`bivy tailscale pair`),
6
+ // the phone opens `https://<machine>.ts.net/#pair=<code>` and trades the code
7
+ // for a device token. Codes are single-use, short-lived and held only in
8
+ // memory, so a restart invalidates every outstanding one.
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ export const PAIR_CODE_TTL_MS = 10 * 60 * 1000;
11
+ const digest = (code) => createHash("sha256").update(code).digest("hex");
12
+ export class PairCodes {
13
+ now;
14
+ // Keyed by the code's hash, so a lookup never compares secrets directly.
15
+ pending = new Map();
16
+ constructor(now = Date.now) {
17
+ this.now = now;
18
+ }
19
+ issue(ttlMs = PAIR_CODE_TTL_MS) {
20
+ this.sweep();
21
+ const code = randomBytes(24).toString("base64url");
22
+ const expiresAt = this.now() + ttlMs;
23
+ this.pending.set(digest(code), expiresAt);
24
+ return { code, expiresAt };
25
+ }
26
+ /** True once per valid, unexpired code; the code is spent either way. */
27
+ redeem(code) {
28
+ if (!code)
29
+ return false;
30
+ const key = digest(code);
31
+ const expiresAt = this.pending.get(key);
32
+ this.pending.delete(key);
33
+ return expiresAt !== undefined && expiresAt > this.now();
34
+ }
35
+ sweep() {
36
+ const now = this.now();
37
+ for (const [key, expiresAt] of this.pending)
38
+ if (expiresAt <= now)
39
+ this.pending.delete(key);
40
+ }
41
+ }
@@ -5,6 +5,8 @@
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
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}` })),
7
7
  { method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
8
+ { method: "post", path: "/api/access/get", kind: "access.get" },
9
+ { method: "post", path: "/api/tailnet/machines", kind: "tailnet.machines" },
8
10
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
9
11
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
10
12
  { method: "post", path: "/api/session/pause", kind: "session.pause" },
@@ -11,9 +11,11 @@ export const CLIENT_COMMAND_SCHEMAS = {
11
11
  ping: Type.Object(request),
12
12
  "apps.list": Type.Object({ ...request, ...optionalSession }),
13
13
  "artifacts.list": Type.Object(request),
14
+ "access.get": Type.Object(request),
15
+ "tailnet.machines": Type.Object(request),
14
16
  "session.fork.local": Type.Object({ ...request, ...session, model: Type.Optional(Type.Object({ provider: Type.Optional(Type.String({ maxLength: 200 })), id: Type.String({ maxLength: 200 }) })) }),
15
17
  "apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
16
- "apps.offers": Type.Object({ ...request, ...session }),
18
+ "apps.offers": Type.Object({ ...request, ...optionalSession }),
17
19
  "apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
18
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 })) }),
19
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 })) }),
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Direct access over Tailscale: no control plane, no relay. `bivy tailscale`
5
+ // writes `<data-dir>/tailscale.json` and points `tailscale serve` (HTTPS on the
6
+ // machine's ts.net name, tailnet only) at a loopback port; the node opens that
7
+ // port as its direct listener. Every connection on it is treated as remote, so
8
+ // callers need a device token, which a phone gets by redeeming a pairing code.
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ export const DIRECT_CONFIG_FILE = "tailscale.json";
12
+ export function loadDirectListenerConfig(appDir) {
13
+ try {
14
+ const raw = JSON.parse(fs.readFileSync(path.join(appDir, DIRECT_CONFIG_FILE), "utf8"));
15
+ const port = Number(raw.port);
16
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
17
+ return null;
18
+ const text = (value) => (typeof value === "string" && value ? value : undefined);
19
+ return { port, hostname: text(raw.hostname), owner: text(raw.owner) };
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
@@ -12,3 +12,5 @@
12
12
  // promote to a standalone `@bivy/remote` npm package.
13
13
  export { RelayConnector, loadRelayConfig } from "./relay-client.js";
14
14
  export { soloCredentials, buildDialUrl } from "./solo.js";
15
+ export { loadDirectListenerConfig, DIRECT_CONFIG_FILE } from "./direct.js";
16
+ export { discoverTailnetMachines, parseTailnetPeers } from "./tailnet.js";