@bivy/bivy 0.20.1-staging.8 → 0.20.1-staging.9

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 (116) hide show
  1. package/bin/access-view.mjs +43 -0
  2. package/bin/bivy.mjs +98 -14
  3. package/bin/cli-commands.mjs +1 -0
  4. package/bin/tailscale.mjs +10 -3
  5. package/dist/access.js +72 -0
  6. package/dist/apps/styles.css +1 -0
  7. package/dist/auth.js +18 -4
  8. package/dist/protocol/client-command-routes.js +2 -0
  9. package/dist/protocol/client-command-schemas.js +2 -0
  10. package/dist/remote/direct.js +2 -1
  11. package/dist/remote/index.js +1 -0
  12. package/dist/remote/tailnet.js +44 -0
  13. package/dist/server.js +40 -2
  14. package/package.json +1 -1
  15. package/web/assets/AutomationsView-DjOv4IMn.js +51 -0
  16. package/web/assets/{ChangesCard-sGQlkM2d.js → ChangesCard-Bcd6mp08.js} +1 -1
  17. package/web/assets/{LibraryView-DYUo2624.js → LibraryView-CiRErGG3.js} +1 -1
  18. package/web/assets/{ProviderConnect-DB-gwOwe.js → ProviderConnect-htTjSJzb.js} +1 -1
  19. package/web/assets/Settings-oBsZwFcG.js +10 -0
  20. package/web/assets/{Sheet-BmAB1Fu_.js → Sheet-lYov8PLo.js} +1 -1
  21. package/web/assets/{Terminal-Dkr504wc.js → Terminal-B9d4Ijmc.js} +1 -1
  22. package/web/assets/{VoiceSettings-DaR6ba3A.js → VoiceSettings-BIADw4Ll.js} +1 -1
  23. package/web/assets/{abnfDiagram-VCTEODGH-CZTMfHPQ.js → abnfDiagram-VCTEODGH-gRoFYqU5.js} +1 -1
  24. package/web/assets/architecture-7GRP2DOG-BEJ5aLoK.js +1 -0
  25. package/web/assets/{architectureDiagram-5GKGNRK7-BBR7u-Nc.js → architectureDiagram-5GKGNRK7-C4cNJboP.js} +1 -1
  26. package/web/assets/{blockDiagram-I7D4REHJ-DU1iTe41.js → blockDiagram-I7D4REHJ-DAICqtG4.js} +1 -1
  27. package/web/assets/{c4Diagram-7LVT6UL2-DoY25VIT.js → c4Diagram-7LVT6UL2-DRswRDNz.js} +1 -1
  28. package/web/assets/channel-BPXvQ5Jt.js +1 -0
  29. package/web/assets/{chunk-4HAMMTFA-CwLS5ngk.js → chunk-4HAMMTFA-2joFftNJ.js} +1 -1
  30. package/web/assets/{chunk-75Z2AOVW-FHeNbdLy.js → chunk-75Z2AOVW-XWOFp1H_.js} +1 -1
  31. package/web/assets/{chunk-DU6HZSFF-CUNfwm6b.js → chunk-DU6HZSFF-DS4Nz4nA.js} +1 -1
  32. package/web/assets/{chunk-F27PBJKO-BT9LzzRz.js → chunk-F27PBJKO-Dx_X9qdN.js} +1 -1
  33. package/web/assets/{chunk-GMAD6QVW-BUr3PoNi.js → chunk-GMAD6QVW-DxV6twrr.js} +1 -1
  34. package/web/assets/{chunk-GVQU2GXP-Dwa-Ue8e.js → chunk-GVQU2GXP-MSIDLTHB.js} +1 -1
  35. package/web/assets/{chunk-IMKFNOWR-CBemkwVh.js → chunk-IMKFNOWR-CELsloet.js} +1 -1
  36. package/web/assets/{chunk-L3NEJ4N5-AGhZr4q5.js → chunk-L3NEJ4N5-BZ3ozIZf.js} +1 -1
  37. package/web/assets/{chunk-OSK3NFVY-CxTtDDij.js → chunk-OSK3NFVY-BXjogSMs.js} +1 -1
  38. package/web/assets/{chunk-P2QGCYS3-DGmss7hg.js → chunk-P2QGCYS3-DFcISUhA.js} +1 -1
  39. package/web/assets/{chunk-POPQ4Y6H-nsxQTUJd.js → chunk-POPQ4Y6H-CMv8VcTR.js} +1 -1
  40. package/web/assets/{chunk-PWAF6VOD-CFEkzIZ2.js → chunk-PWAF6VOD-Ck43wPZJ.js} +1 -1
  41. package/web/assets/{chunk-SHT3W25Y-BArb8KoG.js → chunk-SHT3W25Y-Dd3YYPpo.js} +1 -1
  42. package/web/assets/{chunk-SVP7TREG-CEFJfpYl.js → chunk-SVP7TREG-1wHIX2u8.js} +1 -1
  43. package/web/assets/{chunk-TICWLB2K-ByqSjpyA.js → chunk-TICWLB2K-C2RL-jix.js} +1 -1
  44. package/web/assets/classDiagram-ZZMXUADV-DicIinQk.js +1 -0
  45. package/web/assets/classDiagram-v2-VYDZK3BY-DicIinQk.js +1 -0
  46. package/web/assets/{cynefin-OW5HDTMX-BPd4eJmp.js → cynefin-OW5HDTMX-L3cCx0-a.js} +1 -1
  47. package/web/assets/{cynefinDiagram-5FMLGOSQ-rm271vYO.js → cynefinDiagram-5FMLGOSQ-B-p9_QH8.js} +1 -1
  48. package/web/assets/{dagre-GXQ25YYZ-a4DVMh7o.js → dagre-GXQ25YYZ-CNcD81ab.js} +1 -1
  49. package/web/assets/{diagram-S7CK7UJ4-CR4kj8JQ.js → diagram-S7CK7UJ4-CLXtZ4aE.js} +1 -1
  50. package/web/assets/{diagram-UQ7AKVKN-BVAidz_h.js → diagram-UQ7AKVKN-Bj1-misl.js} +1 -1
  51. package/web/assets/{diagram-VSXAHHWV-P_KshIhr.js → diagram-VSXAHHWV-BsncRf1X.js} +1 -1
  52. package/web/assets/{diagram-VX7I27RA-CfR4HucM.js → diagram-VX7I27RA-DgnUNiqR.js} +1 -1
  53. package/web/assets/{diagram-Z3DM3KII-CnxFHDS4.js → diagram-Z3DM3KII-CYA1FOfJ.js} +1 -1
  54. package/web/assets/{ebnfDiagram-PWID7BFC-0lvDIevQ.js → ebnfDiagram-PWID7BFC-EN37F-ZX.js} +1 -1
  55. package/web/assets/{erDiagram-RLTQ6QDP-B5onTaVV.js → erDiagram-RLTQ6QDP-7khCs6R0.js} +1 -1
  56. package/web/assets/eventmodeling-NTZA5JFV-DPlKwCgg.js +1 -0
  57. package/web/assets/flowDiagram-HODETNUW-CuyexHJ1.js +1 -0
  58. package/web/assets/{ganttDiagram-EL5Y4UJY-DcP58ot2.js → ganttDiagram-EL5Y4UJY-X2fCrUiA.js} +1 -1
  59. package/web/assets/{gitGraph-4MIJSDKK-DMgkg1Fa.js → gitGraph-4MIJSDKK-C1jg4oXv.js} +1 -1
  60. package/web/assets/{gitGraphDiagram-WWUBYQGX-ChZQlU2S.js → gitGraphDiagram-WWUBYQGX-aRlKIG1q.js} +1 -1
  61. package/web/assets/{index-emCm7_bF.js → index-BYhnjcv5.js} +2 -2
  62. package/web/assets/index-Cik3irwJ.css +1 -0
  63. package/web/assets/{info-A6RAGUB7-Bwm7-KTz.js → info-A6RAGUB7-knTxGQzP.js} +1 -1
  64. package/web/assets/{infoDiagram-27XIBGKW-PpNSZSrV.js → infoDiagram-27XIBGKW-CZ5eWH_5.js} +1 -1
  65. package/web/assets/{ishikawaDiagram-5VMMS53U-DXh6yXna.js → ishikawaDiagram-5VMMS53U-BaN7oiXe.js} +1 -1
  66. package/web/assets/{journeyDiagram-3NMN7TZE-DFEj5jkB.js → journeyDiagram-3NMN7TZE-DSKzoitB.js} +1 -1
  67. package/web/assets/{kanban-definition-UXKFOSKX-C5Mvo17h.js → kanban-definition-UXKFOSKX-Do-Ybw2Z.js} +1 -1
  68. package/web/assets/{line-CqUbcJrv.js → line-CwAjwUaG.js} +1 -1
  69. package/web/assets/{mermaid-parser.core-BzUSMP_7.js → mermaid-parser.core-DgfSoRZF.js} +3 -3
  70. package/web/assets/{mermaid.core-CooqV3Py.js → mermaid.core-BmmLukUA.js} +4 -4
  71. package/web/assets/{mindmap-definition-YA3MSWOX-CvsfS7bG.js → mindmap-definition-YA3MSWOX-BvYA7HOJ.js} +1 -1
  72. package/web/assets/mount-YpsogRG1.js +26 -0
  73. package/web/assets/{packet-AYTQ26CC-BzXGhTo4.js → packet-AYTQ26CC-z5xJrrsj.js} +1 -1
  74. package/web/assets/{pegDiagram-XKGWAZYB-BvdN2RIR.js → pegDiagram-XKGWAZYB-Dlnf2qqP.js} +1 -1
  75. package/web/assets/{pie-WAS4IAKB-CNH8JQby.js → pie-WAS4IAKB-Bcc2ZLgF.js} +1 -1
  76. package/web/assets/{pieDiagram-E7YTZNPT-C6LABPqT.js → pieDiagram-E7YTZNPT-DK6Bm2ct.js} +1 -1
  77. package/web/assets/{quadrantDiagram-AXDQQJYC-D2ebcGIP.js → quadrantDiagram-AXDQQJYC-CGdNrgH7.js} +1 -1
  78. package/web/assets/{radar-RG4KPBEZ-B7uFPzC9.js → radar-RG4KPBEZ-DvDzdzlV.js} +1 -1
  79. package/web/assets/{railroad-74A4TZTK-CE9pdZ9o.js → railroad-74A4TZTK-DAk4jBzt.js} +1 -1
  80. package/web/assets/railroad-abnf-HS5TGJTU-BboXzbI7.js +1 -0
  81. package/web/assets/railroad-ebnf-LZEXJU2U-2aWLf6tt.js +1 -0
  82. package/web/assets/railroad-peg-WCYAUIDC-DkEFbMwn.js +1 -0
  83. package/web/assets/{railroadDiagram-O6MQD6OU-vJc9tRJP.js → railroadDiagram-O6MQD6OU-qghivQgJ.js} +1 -1
  84. package/web/assets/{requirementDiagram-BXWQKSXE-CVLUnEfL.js → requirementDiagram-BXWQKSXE-BB2VHwAe.js} +1 -1
  85. package/web/assets/{sankeyDiagram-P5KCCOFB-DkHI3ZIh.js → sankeyDiagram-P5KCCOFB-DYsK6FvP.js} +1 -1
  86. package/web/assets/{sequenceDiagram-WJ2MYXX4-BgosmpaZ.js → sequenceDiagram-WJ2MYXX4-DN7_ECVu.js} +1 -1
  87. package/web/assets/settingsRoute-BryKVhsu.js +1 -0
  88. package/web/assets/{stateDiagram-D77RDMKH-BZ5Zqme7.js → stateDiagram-D77RDMKH-CMoUI-nz.js} +1 -1
  89. package/web/assets/stateDiagram-v2-MP3YSRHH-DVVMJFff.js +1 -0
  90. package/web/assets/{swimlanes-42K2YHIH-rkMg8KEw.js → swimlanes-42K2YHIH-Dg9Ds-zo.js} +1 -1
  91. package/web/assets/swimlanesDiagram-VR7AAH4N-CMp2P5bx.js +8 -0
  92. package/web/assets/{timeline-definition-24CTP7MA-_z_HJLWC.js → timeline-definition-24CTP7MA-B71AxnxY.js} +1 -1
  93. package/web/assets/{treeView-Q6P3EWNA-jR5Z1Fmf.js → treeView-Q6P3EWNA-CcYDvc71.js} +1 -1
  94. package/web/assets/{treemap-WGGIJYW6-OtJhfdUS.js → treemap-WGGIJYW6-COuLLLjA.js} +1 -1
  95. package/web/assets/{useStore-CaPvUEwc.js → useStore-CVAcdLfm.js} +3 -3
  96. package/web/assets/{vennDiagram-4TSXK5OY-BFTbjE-l.js → vennDiagram-4TSXK5OY-Dyg63v_c.js} +1 -1
  97. package/web/assets/{wardley-WFR3VGLG-BV0ly0l7.js → wardley-WFR3VGLG-FAnOXgo0.js} +1 -1
  98. package/web/assets/{wardleyDiagram-VM6X3IG4-DYvCqNRg.js → wardleyDiagram-VM6X3IG4-fhlKAloZ.js} +1 -1
  99. package/web/assets/{xychartDiagram-S5SC5T6Z-CaYy8yTC.js → xychartDiagram-S5SC5T6Z-DbM80CJG.js} +1 -1
  100. package/web/index.html +2 -2
  101. package/web/sw.js +1 -1
  102. package/web/assets/AutomationsView-BnHdmp7P.js +0 -51
  103. package/web/assets/Settings-VYk0Et0A.js +0 -10
  104. package/web/assets/architecture-7GRP2DOG-MiVMMOHz.js +0 -1
  105. package/web/assets/channel-m_2Ky5Co.js +0 -1
  106. package/web/assets/classDiagram-ZZMXUADV-BM6g8SUg.js +0 -1
  107. package/web/assets/classDiagram-v2-VYDZK3BY-BM6g8SUg.js +0 -1
  108. package/web/assets/eventmodeling-NTZA5JFV-DviokIMu.js +0 -1
  109. package/web/assets/flowDiagram-HODETNUW-BtFCDXEP.js +0 -1
  110. package/web/assets/index-CsC63U16.css +0 -1
  111. package/web/assets/mount-BD-g3eYn.js +0 -26
  112. package/web/assets/railroad-abnf-HS5TGJTU-C_bKzcjL.js +0 -1
  113. package/web/assets/railroad-ebnf-LZEXJU2U-BzPEt-Wn.js +0 -1
  114. package/web/assets/railroad-peg-WCYAUIDC-CeL6qyBT.js +0 -1
  115. package/web/assets/stateDiagram-v2-MP3YSRHH-DtyGmcgM.js +0 -1
  116. package/web/assets/swimlanesDiagram-VR7AAH4N-CbnaKO-d.js +0 -8
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Terminal views of the node's access report (src/access.ts): the full
4
+ // `bivy access` screen and the one-line summary `bivy status` shows.
5
+
6
+ const REACH_WORD = ["", "on your tailnet", "from anywhere"];
7
+
8
+ /** "Tailscale + Bivy hosted", or "This machine only". */
9
+ export function activeLabel(report) {
10
+ const on = report.setups.filter((s) => s.active && s.id !== "local");
11
+ return on.length ? on.map((s) => s.label).join(" + ") : "This machine only";
12
+ }
13
+
14
+ function labelOf(report, id) {
15
+ return report.setups.find((s) => s.id === id)?.label ?? id;
16
+ }
17
+
18
+ /** `access: Tailscale — next: Bivy hosted` for `bivy status`. */
19
+ export function accessLine(report) {
20
+ const next = report.next.filter((n) => n.id !== "server").map((n) => labelOf(report, n.id));
21
+ return `${activeLabel(report)}${next.length ? ` — next: ${next.join(" or ")} ('bivy access')` : ""}`;
22
+ }
23
+
24
+ /** The `bivy access` screen as lines; `paint` supplies colors. */
25
+ export function accessScreen(report, paint) {
26
+ const lines = [`${paint.bold("Access:")} ${activeLabel(report)}`];
27
+ if (report.tailscaleUrl) lines.push(` ${paint.dim("tailnet")} ${report.tailscaleUrl}`);
28
+ if (report.controlPlaneUrl) lines.push(` ${paint.dim("app")} ${report.controlPlaneUrl}`);
29
+ lines.push("");
30
+ for (const feature of report.features) {
31
+ const reach = report.reach[feature.id];
32
+ lines.push(` ${reach ? paint.green("✓") : paint.dim("✗")} ${feature.label}${reach ? paint.dim(` ${REACH_WORD[reach]}`.trimEnd()) : ""}`);
33
+ }
34
+ if (report.next.length) {
35
+ lines.push("", paint.bold("Next:"));
36
+ for (const step of report.next) {
37
+ const setup = report.setups.find((s) => s.id === step.id);
38
+ lines.push(` ${paint.cyan((setup?.command ?? "").padEnd(26))} ${setup?.label}`, ` ${" ".repeat(26)} ${paint.dim(`adds ${step.addsText}`)}`);
39
+ }
40
+ }
41
+ lines.push("", paint.dim("Setups stack: adding one never takes away another. 'bivy access local' turns remote access off."));
42
+ return lines;
43
+ }
package/bin/bivy.mjs CHANGED
@@ -40,6 +40,7 @@ import { nativeResumeRef, resolveSessionsLimit, truncateSavedSessions } from "./
40
40
  import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
41
41
  import { removeInstallAndState } from "./uninstall-paths.mjs";
42
42
  import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
43
+ import { accessLine, accessScreen } from "./access-view.mjs";
43
44
  import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
44
45
  import { resolveAttachSessionId } from "./attach-session-id.mjs";
45
46
  import { detectInstallKind as classifyInstallKind, managedInstall, npmGlobalPrefix } from "./install-kind.mjs";
@@ -4527,6 +4528,8 @@ async function cmdTokenSetup() {
4527
4528
  }
4528
4529
 
4529
4530
  async function cmdSetup(args = []) {
4531
+ // Set by the access question; `bivy tailscale` runs once the node is up.
4532
+ let wantTailscale = false;
4530
4533
  if (args.includes("-h") || args.includes("--help")) {
4531
4534
  console.log("Usage: bivy setup\n\nFirst-run wizard: agent choice, model login, remote access + sign-in, and background service. Workspace and port get safe defaults. Re-run later to change the default agent or remote access.");
4532
4535
  return;
@@ -4634,18 +4637,22 @@ async function cmdSetup(args = []) {
4634
4637
  // (BIVY_CONTROL_PLANE_URL / BIVY_RELAY_URL then pre-fill the URL prompts below).
4635
4638
  const nodeClaimCode = process.env.BIVY_NODE_CLAIM_CODE?.trim();
4636
4639
  const selfHostEnv = !nodeClaimCode && Boolean((process.env.BIVY_CONTROL_PLANE_URL || "").trim() || (process.env.BIVY_RELAY_URL || "").trim());
4640
+ // Tailscale is offered only where it's installed; it needs no account.
4641
+ const tailscaleInstalled = runTailscale(["version"]) !== null;
4637
4642
  const syncChoice = nodeClaimCode ? "h" : await askChoice(
4638
- "Remote access",
4643
+ "How will you reach this machine?",
4639
4644
  [
4640
- { key: "h", label: "hosted (recommended — sign in with GitHub or email; nothing caps your local usage)" },
4641
- { key: "s", label: "self-hosted (your own control plane + relay)" },
4642
- { key: "l", label: "local only for now (terminal CLI only; run 'bivy relay:setup' later)" },
4645
+ { key: "h", label: "Bivy hosted (recommended — sign in with GitHub or email; every machine, from anywhere)" },
4646
+ ...(tailscaleInstalled ? [{ key: "t", label: "Tailscale (your devices over your tailnet; no account, nothing in between)" }] : []),
4647
+ { key: "s", label: "your own server (a Bivy server you run)" },
4648
+ { key: "l", label: "this machine only for now (add more any time with 'bivy access')" },
4643
4649
  ],
4644
4650
  selfHostEnv ? "s" : "h",
4645
4651
  );
4652
+ wantTailscale = syncChoice === "t";
4646
4653
  const relayArgs = [];
4647
- if (syncChoice === "l") {
4648
- console.log(c.dim("\nSkipping remote access. Enable it any time with 'bivy relay:setup'."));
4654
+ if (syncChoice === "l" || syncChoice === "t") {
4655
+ console.log(c.dim(`\n${syncChoice === "t" ? "Tailscale is turned on once the node is running." : "Skipping remote access."} 'bivy access' shows every option later.`));
4649
4656
  }
4650
4657
  if (syncChoice === "s") {
4651
4658
  const endpoints = await getHostedEndpoints();
@@ -4655,7 +4662,7 @@ async function cmdSetup(args = []) {
4655
4662
  if (relayWs.trim()) relayArgs.push("--relay", relayWs.trim());
4656
4663
  }
4657
4664
 
4658
- if (syncChoice !== "l") {
4665
+ if (syncChoice !== "l" && syncChoice !== "t") {
4659
4666
  if (!nodeClaimCode) {
4660
4667
  const loginChoice = await askChoice(
4661
4668
  "Remote login",
@@ -4704,7 +4711,7 @@ async function cmdSetup(args = []) {
4704
4711
  setupSession = consumeSetupSession();
4705
4712
  }
4706
4713
  } else {
4707
- console.log(c.dim("\nRemote access already configured. Re-run 'bivy relay:setup' to change sync or sign-in."));
4714
+ console.log(c.dim("\nRemote access already configured. 'bivy access' shows it and the other options."));
4708
4715
  }
4709
4716
 
4710
4717
  // GitHub App — connect it from the web app (Settings → GitHub App) or with
@@ -4784,6 +4791,7 @@ async function cmdSetup(args = []) {
4784
4791
  ? Boolean(liveReadiness?.credential?.ok ?? hasModelConfig(finalConfig))
4785
4792
  : agentAuthReady;
4786
4793
  const repoReady = Boolean(liveReadiness?.repository?.ok);
4794
+ if (wantTailscale) await cmdTailscale(["on"]).catch((error) => console.log(c.yellow(`Tailscale setup didn't finish: ${error?.message || error}. Run 'bivy access tailscale' to retry.`)));
4787
4795
  console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
4788
4796
  console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
4789
4797
  console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
@@ -4792,7 +4800,7 @@ async function cmdSetup(args = []) {
4792
4800
  const ghReady = githubConnected(finalConfig);
4793
4801
  console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("optional — connect later in the app under Settings → GitHub App")}`);
4794
4802
  console.log(` ${agentReady && modelReady && repoReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady && repoReady ? "ready to try" : "blocked by the stage above"}`);
4795
- console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
4803
+ console.log(` ${fs.existsSync(relayConfigPath) || fs.existsSync(tailscaleConfigPath) ? c.green("✓") : c.dim("○")} access ${fs.existsSync(relayConfigPath) || fs.existsSync(tailscaleConfigPath) ? "set up — 'bivy access' shows what it gives you" : "this machine only — 'bivy access' shows the options"}\n`);
4796
4804
  // Get the user into the product immediately; terminal commands are the
4797
4805
  // fallback/next-step checklist after the remote app has been opened or linked.
4798
4806
  await finishSetupRemote(finalConfig, setupSession);
@@ -4979,8 +4987,10 @@ async function cmdStatus(args = []) {
4979
4987
  const relay = loadRelayConfig();
4980
4988
  const relaySt = status?.relay;
4981
4989
  const relayConfigured = Boolean(relaySt?.configured || relay);
4990
+ const access = reachable ? await fetchAccess(config).catch(() => null) : null;
4991
+ if (access) console.log(` access: ${accessLine(access)}`);
4982
4992
  if (!relayConfigured) {
4983
- console.log(` remote: ${c.dim("local only")} ${c.dim("('bivy relay:setup' to enable remote access)")}`);
4993
+ if (!access) console.log(` remote: ${c.dim("this machine only")} ${c.dim("('bivy access' to see the options)")}`);
4984
4994
  } else {
4985
4995
  // The live link state is only knowable when the node is running; if it's
4986
4996
  // down we can say it's configured but not whether it's currently connected.
@@ -5175,7 +5185,7 @@ async function cmdDoctor(args = []) {
5175
5185
  const relayApp = status?.relay?.controlPlaneUrl;
5176
5186
  const relayErr = status?.relay?.lastError;
5177
5187
  const relayLine = !relayConfigured
5178
- ? c.dim("local only — 'bivy relay:setup' to enable")
5188
+ ? c.dim("this machine only — 'bivy access' to see the options")
5179
5189
  : relayConnected
5180
5190
  ? c.green("relay connected") + (relayApp ? c.dim(` ${relayApp}`) : "")
5181
5191
  : c.yellow("configured, not connected") + (relayErr ? c.dim(` (${relayErr})`) : "");
@@ -5815,7 +5825,7 @@ async function cmdTailscale(args = []) {
5815
5825
  process.exitCode = EXIT.unavailable;
5816
5826
  return;
5817
5827
  }
5818
- const { running, hostname } = parseTailscaleStatus(status.stdout);
5828
+ const { running, hostname, owner } = parseTailscaleStatus(status.stdout);
5819
5829
  if (!running || !hostname) {
5820
5830
  console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
5821
5831
  process.exitCode = EXIT.unavailable;
@@ -5828,7 +5838,7 @@ async function cmdTailscale(args = []) {
5828
5838
  process.exitCode = EXIT.conflict;
5829
5839
  return;
5830
5840
  }
5831
- fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
5841
+ fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname, ...(owner ? { owner } : {}) }, null, 2)}\n`, { mode: 0o600 });
5832
5842
  const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5833
5843
  const served = runTailscale(serveOnArgs(port));
5834
5844
  if (!served?.ok) {
@@ -5841,12 +5851,83 @@ async function cmdTailscale(args = []) {
5841
5851
  return;
5842
5852
  }
5843
5853
  console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
5844
- console.log(c.dim(" Only devices on your tailnet can reach it. Pair each one once with a link from 'bivy tailscale pair'."));
5854
+ console.log(c.dim(owner
5855
+ ? ` Only devices on your tailnet can reach it. Yours (signed in as ${owner}) are let in without pairing;\n anyone else's pairs once with a link from 'bivy tailscale pair'.`
5856
+ : " Only devices on your tailnet can reach it. This machine is tagged, so each device pairs once with a link from 'bivy tailscale pair'."));
5845
5857
  if (!reload?.webApp) console.log(c.yellow(" This install has no built web app, so the address serves the API only. Run 'pnpm run build:web' in a source checkout."));
5846
5858
  console.log("");
5859
+ if (owner) {
5860
+ console.log(`Open ${c.cyan(`https://${hostname}`)} on any of your devices.\n`);
5861
+ return;
5862
+ }
5847
5863
  await printPairLink(config);
5848
5864
  }
5849
5865
 
5866
+ async function fetchAccess(config) {
5867
+ return localApi(config, "/api/access/get", { method: "POST", body: "{}" });
5868
+ }
5869
+
5870
+ // `bivy access` — how this machine can be reached (this machine only,
5871
+ // Tailscale, Bivy hosted, your own server), what that gives you, and the next
5872
+ // step. The node owns the table (src/access.ts); this only renders and routes.
5873
+ async function cmdAccess(args = []) {
5874
+ const [verb, target] = args.filter((a) => !a.startsWith("-"));
5875
+ if (args.includes("-h") || args.includes("--help") || (verb && !["local", "tailscale", "hosted", "server"].includes(verb))) {
5876
+ console.log(`Usage: bivy access [--json]
5877
+ bivy access tailscale reach this machine from your devices over Tailscale
5878
+ bivy access hosted sign in to Bivy hosted: every machine, from anywhere
5879
+ bivy access server <url> [--relay <wss-url>] the same, through your own server
5880
+ bivy access local turn remote access off
5881
+
5882
+ Setups stack: Tailscale can stay on alongside hosted or your own server.`);
5883
+ if (verb && !["local", "tailscale", "hosted", "server"].includes(verb)) process.exitCode = EXIT.usage;
5884
+ return;
5885
+ }
5886
+ const config = loadConfig();
5887
+ if (!(await ensureNodeRunning(config))) {
5888
+ console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
5889
+ process.exitCode = EXIT.unavailable;
5890
+ return;
5891
+ }
5892
+ const report = await fetchAccess(config);
5893
+
5894
+ if (!verb) {
5895
+ if (wantsJson(args)) { console.log(JSON.stringify(report, null, 2)); return; }
5896
+ console.log(`\n${accessScreen(report, c).join("\n")}\n`);
5897
+ return;
5898
+ }
5899
+ if (verb === "tailscale") return cmdTailscale(["on"]);
5900
+ if (verb === "hosted" || verb === "server") {
5901
+ const linked = report.setups.find((s) => s.active && (s.id === "hosted" || s.id === "server"));
5902
+ if (linked && linked.id === verb && verb === "hosted") {
5903
+ console.log(`Already on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}.`);
5904
+ return;
5905
+ }
5906
+ if (linked) console.log(c.yellow(`This machine is on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}. Switching keeps everything on this machine; your devices sign in again at the new address.`));
5907
+ if (verb === "hosted") return cmdRelaySetup([]);
5908
+ if (!target) {
5909
+ console.log(`Usage: bivy access server <url> [--relay <wss-url>]\n\nThe address of your Bivy server, e.g. https://bivy.example.com. Set one up with docs/self-host-quickstart.md.`);
5910
+ process.exitCode = EXIT.usage;
5911
+ return;
5912
+ }
5913
+ let appUrl;
5914
+ try { appUrl = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`); } catch { appUrl = null; }
5915
+ if (!appUrl) { console.error(c.red(`Not a URL: ${target}`)); process.exitCode = EXIT.usage; return; }
5916
+ // Bivy's server installs serve the relay at /relay on the app's own domain.
5917
+ const relayUrl = argValue(args, "relay") || `${appUrl.protocol === "http:" ? "ws" : "wss"}://${appUrl.host}/relay`;
5918
+ return cmdRelaySetup(["--control-plane", appUrl.origin, "--relay", relayUrl]);
5919
+ }
5920
+ // local: turn off every remote setup that is on.
5921
+ const on = report.setups.filter((s) => s.active && s.id !== "local");
5922
+ if (!on.length) { console.log("Already this machine only."); return; }
5923
+ const rl = createPrompter();
5924
+ const yes = await rl.askYesNo(`Turn off ${on.map((s) => s.label).join(" and ")}? Sessions and credentials on this machine stay.`, false);
5925
+ rl.close();
5926
+ if (!yes) return;
5927
+ if (on.some((s) => s.id === "tailscale")) await cmdTailscale(["off"]);
5928
+ if (on.some((s) => s.id === "hosted" || s.id === "server")) await cmdAccountLogout([]);
5929
+ }
5930
+
5850
5931
  function printUninstallHelp() {
5851
5932
  console.log(`
5852
5933
  ${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
@@ -6453,6 +6534,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
6453
6534
  case "tailscale":
6454
6535
  await cmdTailscale(args);
6455
6536
  break;
6537
+ case "access":
6538
+ await cmdAccess(args);
6539
+ break;
6456
6540
  case "relay:setup":
6457
6541
  await cmdRelaySetup(args);
6458
6542
  break;
@@ -106,6 +106,7 @@ export const COMMANDS = [
106
106
  { name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
107
107
  { name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
108
108
  { name: "link", group: "node", scope: "node", usage: "link", summary: "Show a web app link as a QR code in the terminal" },
109
+ { name: "access", group: "node", scope: "node", json: true, usage: "access [local|tailscale|hosted|server <url>]", summary: "How this machine can be reached, what that gives you, and the next step", subcommands: ["local", "tailscale", "hosted", "server"] },
109
110
  { name: "tailscale", group: "node", scope: "node", usage: "tailscale [on|pair|status|devices|revoke|off]", summary: "Reach this machine over Tailscale, with no server in between", subcommands: ["on", "pair", "status", "devices", "revoke", "off"] },
110
111
  { name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
111
112
  { name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
package/bin/tailscale.mjs CHANGED
@@ -7,12 +7,19 @@
7
7
  /** The one HTTPS port Bivy claims on the machine's tailnet name. */
8
8
  export const SERVE_HTTPS_PORT = 443;
9
9
 
10
- /** `{ running, hostname }` from `tailscale status --json` output. */
10
+ /**
11
+ * `{ running, hostname, owner }` from `tailscale status --json` output. `owner`
12
+ * is the login of the user this machine belongs to, or null for a tagged
13
+ * machine (owned by tags, not a person).
14
+ */
11
15
  export function parseTailscaleStatus(text) {
12
16
  let status;
13
- try { status = JSON.parse(text); } catch { return { running: false, hostname: null }; }
17
+ try { status = JSON.parse(text); } catch { return { running: false, hostname: null, owner: null }; }
14
18
  const hostname = String(status?.Self?.DNSName ?? "").replace(/\.$/, "") || null;
15
- return { running: status?.BackendState === "Running", hostname };
19
+ const tagged = Array.isArray(status?.Self?.Tags) && status.Self.Tags.length > 0;
20
+ const login = status?.User?.[String(status?.Self?.UserID)]?.LoginName;
21
+ const owner = !tagged && typeof login === "string" && login.includes("@") ? login : null;
22
+ return { running: status?.BackendState === "Running", hostname, owner };
16
23
  }
17
24
 
18
25
  /** Where `tailscale serve` should send the machine's HTTPS traffic. */
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
+ }
@@ -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); }
package/dist/auth.js CHANGED
@@ -115,10 +115,24 @@ export function isLoopbackAddress(address) {
115
115
  // Sockets accepted by a listener that fronts remote callers through a local
116
116
  // proxy (`tailscale serve` forwards tailnet traffic from 127.0.0.1). Their peer
117
117
  // address is loopback, but the caller is not, so they never get loopback trust.
118
- const remoteSockets = new WeakSet();
118
+ const remoteSockets = new WeakMap();
119
119
  /** Treat every connection `server` accepts as remote, whatever its peer address. */
120
- export function markRemoteListener(server) {
121
- server.on("connection", (socket) => remoteSockets.add(socket));
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;
122
136
  }
123
137
  /** True when a request physically arrived over loopback from a local caller. */
124
138
  export function isLoopbackRequest(req) {
@@ -154,7 +168,7 @@ export function tokenFromRequest(req) {
154
168
  * `isAuthorized` is off. */
155
169
  export function resolveAuth(identity, req) {
156
170
  const loopback = isLoopbackRequest(req);
157
- const deviceId = identity.verifyToken(tokenFromRequest(req));
171
+ const deviceId = identity.verifyToken(tokenFromRequest(req)) ?? tailnetDevice(req);
158
172
  return { deviceId, loopback };
159
173
  }
160
174
  export function isAuthorized(ctx) {
@@ -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,6 +11,8 @@ 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
18
  "apps.offers": Type.Object({ ...request, ...session }),
@@ -15,7 +15,8 @@ export function loadDirectListenerConfig(appDir) {
15
15
  const port = Number(raw.port);
16
16
  if (!Number.isInteger(port) || port <= 0 || port > 65535)
17
17
  return null;
18
- return { port, hostname: typeof raw.hostname === "string" && raw.hostname ? raw.hostname : undefined };
18
+ const text = (value) => (typeof value === "string" && value ? value : undefined);
19
+ return { port, hostname: text(raw.hostname), owner: text(raw.owner) };
19
20
  }
20
21
  catch {
21
22
  return null;
@@ -13,3 +13,4 @@
13
13
  export { RelayConnector, loadRelayConfig } from "./relay-client.js";
14
14
  export { soloCredentials, buildDialUrl } from "./solo.js";
15
15
  export { loadDirectListenerConfig, DIRECT_CONFIG_FILE } from "./direct.js";
16
+ export { discoverTailnetMachines, parseTailnetPeers } from "./tailnet.js";
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // The other machines on your tailnet that run Bivy, so a machine reached over
5
+ // Tailscale can offer the rest. Tailscale lists the peers; each is asked for
6
+ // Bivy's hello at its tailnet address, which only answers where `bivy
7
+ // tailscale` is on.
8
+ import { execFile } from "node:child_process";
9
+ /** Peers from `tailscale status --json`, without the machine itself. */
10
+ export function parseTailnetPeers(text) {
11
+ let status;
12
+ try {
13
+ status = JSON.parse(text);
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ return Object.values(status?.Peer ?? {})
19
+ .map((p) => ({ dnsName: String(p.DNSName ?? "").replace(/\.$/, ""), hostName: String(p.HostName ?? ""), online: p.Online === true, os: p.OS }))
20
+ .filter((p) => p.dnsName.endsWith(".ts.net"));
21
+ }
22
+ function tailscaleStatus() {
23
+ return new Promise((resolve) => {
24
+ execFile("tailscale", ["status", "--json"], { timeout: 3000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => resolve(error ? "" : stdout));
25
+ });
26
+ }
27
+ async function hello(peer, fetchImpl) {
28
+ try {
29
+ const res = await fetchImpl(`https://${peer.dnsName}/api/direct/hello`, { signal: AbortSignal.timeout(2500) });
30
+ const body = await res.json();
31
+ if (!res.ok || body?.bivy !== true)
32
+ return null;
33
+ return { name: typeof body.name === "string" && body.name ? body.name : peer.hostName, url: `https://${peer.dnsName}`, nodeId: typeof body.nodeId === "string" ? body.nodeId : undefined, os: peer.os, online: true, self: false };
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ /** Online tailnet peers running Bivy over Tailscale. Offline peers aren't asked. */
40
+ export async function discoverTailnetMachines(deps = {}) {
41
+ const peers = parseTailnetPeers(await (deps.status ?? tailscaleStatus)()).filter((p) => p.online);
42
+ const found = await Promise.all(peers.map((p) => hello(p, deps.fetchImpl ?? fetch)));
43
+ return found.filter((m) => m !== null).sort((a, b) => a.name.localeCompare(b.name));
44
+ }
package/dist/server.js CHANGED
@@ -87,9 +87,11 @@ import { SessionEventCoalescer } from "./session-event-coalescer.js";
87
87
  import { readMachineTheme, watchMachineTheme } from "./machine-theme.js";
88
88
  import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed, markRemoteListener } from "./auth.js";
89
89
  import { PairCodes } from "./pair-codes.js";
90
+ import { accessReport } from "./access.js";
91
+ import { hostedEndpoints } from "./hosted-endpoints.mjs";
90
92
  import http from "node:http";
91
93
  import { webAppDirs, webAppRouter } from "./web-app.js";
92
- import { RelayConnector, loadRelayConfig, loadDirectListenerConfig, soloCredentials } from "./remote/index.js";
94
+ import { RelayConnector, loadRelayConfig, loadDirectListenerConfig, discoverTailnetMachines, soloCredentials } from "./remote/index.js";
93
95
  import { readEphemeralTeardownConfig, shouldSelfTeardown, snapshotsDurableForTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
94
96
  import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
95
97
  import { clearTurnActivity } from "./session/turn-activity.js";
@@ -2297,6 +2299,25 @@ appRegistry.on("notes", (viewId) => {
2297
2299
  });
2298
2300
  }, NOTES_NOTIFY_MS).unref?.();
2299
2301
  });
2302
+ // Asking every peer takes a moment; a short cache keeps switcher opens instant.
2303
+ let tailnetCache = null;
2304
+ async function tailnetMachines() {
2305
+ const direct = loadDirectListenerConfig(appDir);
2306
+ if (!direct?.hostname)
2307
+ return [];
2308
+ if (!tailnetCache || Date.now() - tailnetCache.at > 15_000)
2309
+ tailnetCache = { at: Date.now(), machines: discoverTailnetMachines() };
2310
+ const self = { name: identity.name, url: `https://${direct.hostname}`, nodeId: identity.nodeId, os: process.platform, online: true, self: true };
2311
+ return [self, ...(await tailnetCache.machines).filter((m) => m.url !== self.url)];
2312
+ }
2313
+ function currentAccess() {
2314
+ const relay = loadRelayConfig(appDir);
2315
+ return accessReport({
2316
+ tailscaleHostname: loadDirectListenerConfig(appDir)?.hostname,
2317
+ relay: relay ? { controlPlaneUrl: relay.controlPlaneUrl, room: relay.room, roomToken: relay.roomToken } : null,
2318
+ hostedControlPlane: hostedEndpoints(process.env).controlPlane,
2319
+ });
2320
+ }
2300
2321
  const RELAY_COMMANDS = {
2301
2322
  ...createAppCommands(appService, (id) => { const record = resolveSession(id); return record ? harnessDirFor(record) : undefined; }, (app) => {
2302
2323
  const record = resolveSession(app.sessionId);
@@ -2314,6 +2335,14 @@ const RELAY_COMMANDS = {
2314
2335
  // (see runBivyUpdate). The node restarts itself when the update lands, so the
2315
2336
  // client just sees the socket reconnect on the new build; a failure to even
2316
2337
  // start reports back so the banner can show the manual command.
2338
+ // The machines on this machine's tailnet that run Bivy, this one first.
2339
+ async "tailnet.machines"(msg, ctx) {
2340
+ ctx.reply({ type: "tailnet.machines.ok", requestId: msg.requestId, machines: await tailnetMachines() });
2341
+ },
2342
+ // How this machine can be reached and what the next setup would add (src/access.ts).
2343
+ "access.get"(msg, ctx) {
2344
+ ctx.reply({ type: "access.get.ok", requestId: msg.requestId, ...currentAccess() });
2345
+ },
2317
2346
  "node.update"(_msg, ctx) {
2318
2347
  const result = runBivyUpdate();
2319
2348
  ctx.reply({ type: "node.update.result", ok: result.ok, error: result.error });
@@ -9478,6 +9507,12 @@ app.post("/api/auth/pair", sensitiveRateLimiter, (req, res) => {
9478
9507
  const { device, token } = accessDevices.create(name);
9479
9508
  res.json({ ok: true, device, token });
9480
9509
  });
9510
+ // Identifies a Bivy node to the other machines on its tailnet, so each can list
9511
+ // the rest (tailnet.machines). Public, like /healthz: it says only that Bivy
9512
+ // runs here and under which name; the direct listener is tailnet-only.
9513
+ app.get("/api/direct/hello", (_req, res) => {
9514
+ res.json({ bivy: true, name: identity.name, nodeId: identity.nodeId });
9515
+ });
9481
9516
  // All other /api routes require auth (loopback may bypass, per config and host
9482
9517
  // — see loopbackAllowed()/isMultiUserHost() in src/auth.ts).
9483
9518
  app.use("/api", authMiddleware(identity, {
@@ -12142,8 +12177,10 @@ const wss = new WebSocketServer({ server, path: "/ws" });
12142
12177
  // getting loopback trust; every caller needs a device token.
12143
12178
  const directWebApp = webAppRouter(webAppDirs(repoRoot));
12144
12179
  let directListener = null;
12180
+ let directOwner;
12145
12181
  async function reloadDirectListener() {
12146
12182
  const config = loadDirectListenerConfig(appDir);
12183
+ directOwner = config?.owner;
12147
12184
  if (directListener && directListener.port !== config?.port) {
12148
12185
  const previous = directListener;
12149
12186
  directListener = null;
@@ -12160,7 +12197,8 @@ async function reloadDirectListener() {
12160
12197
  directApp.use(directWebApp);
12161
12198
  directApp.use(app);
12162
12199
  const listener = http.createServer(directApp);
12163
- markRemoteListener(listener);
12200
+ // The owner's own tailnet devices come in on their Tailscale identity.
12201
+ markRemoteListener(listener, { trustedLogin: () => directOwner });
12164
12202
  const directWss = new WebSocketServer({ server: listener, path: "/ws" });
12165
12203
  directWss.on("connection", (socket, req) => wss.emit("connection", socket, req));
12166
12204
  await new Promise((resolve, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.20.1-staging.8",
3
+ "version": "0.20.1-staging.9",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",