@bivy/bivy 0.20.1-staging.5 → 0.20.1-staging.7

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 (140) hide show
  1. package/bin/bivy.mjs +147 -0
  2. package/bin/cli-commands.mjs +3 -0
  3. package/bin/guides/talk-to-the-user.md +7 -0
  4. package/bin/tailscale.mjs +63 -0
  5. package/dist/agent-instructions.js +2 -0
  6. package/dist/auth.js +16 -3
  7. package/dist/pair-codes.js +41 -0
  8. package/dist/remote/direct.js +23 -0
  9. package/dist/remote/index.js +1 -0
  10. package/dist/server.js +106 -13
  11. package/dist/session/session-namer.js +8 -2
  12. package/dist/session/session-tokens.js +1 -1
  13. package/dist/web-app.js +55 -0
  14. package/package.json +1 -1
  15. package/web/apple-touch-icon.png +0 -0
  16. package/web/assets/AutomationsView-BJ3gBIGf.js +51 -0
  17. package/web/assets/ChangesCard-BzRQTCVs.js +4 -0
  18. package/web/assets/LibraryView-C6mluzPl.js +2 -0
  19. package/web/assets/ProviderConnect-BK6oEwlu.js +1 -0
  20. package/web/assets/ReadinessChecklist-Bbgq2GQ8.js +1 -0
  21. package/web/assets/Settings-CG236bpZ.js +10 -0
  22. package/web/assets/Sheet-YqJCwCtR.js +2 -0
  23. package/web/assets/StatusDot-dpbztK53.js +1 -0
  24. package/web/assets/Terminal-B1njb2Cr.css +1 -0
  25. package/web/assets/Terminal-CWjLg3i2.js +96 -0
  26. package/web/assets/VoiceSettings-DVlH29qG.js +1 -0
  27. package/web/assets/abnfDiagram-VCTEODGH-DR5nREn3.js +1 -0
  28. package/web/assets/arc-Pry-7G1_.js +1 -0
  29. package/web/assets/architecture-7GRP2DOG-Mvf9rpFA.js +1 -0
  30. package/web/assets/architectureDiagram-5GKGNRK7-BTtzbbP7.js +36 -0
  31. package/web/assets/array-BifhSqXX.js +1 -0
  32. package/web/assets/blockDiagram-I7D4REHJ-B02cXkpO.js +129 -0
  33. package/web/assets/c4Diagram-7LVT6UL2-DuMEAvRa.js +38 -0
  34. package/web/assets/channel-BETA4C1-.js +1 -0
  35. package/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  36. package/web/assets/chunk-4HAMMTFA-C3z4iXsh.js +62 -0
  37. package/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  38. package/web/assets/chunk-75Z2AOVW-BaELqtNa.js +2 -0
  39. package/web/assets/chunk-DU6HZSFF-BBrnNwn_.js +127 -0
  40. package/web/assets/chunk-F27PBJKO-Bfuzd8Q8.js +1 -0
  41. package/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
  42. package/web/assets/chunk-GMAD6QVW-B0Np7gv0.js +72 -0
  43. package/web/assets/chunk-GVQU2GXP-CiZeRFSW.js +1 -0
  44. package/web/assets/chunk-IMKFNOWR-DJGDP5h1.js +231 -0
  45. package/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  46. package/web/assets/chunk-L3NEJ4N5-u67SBNSZ.js +1 -0
  47. package/web/assets/chunk-OSK3NFVY-DJUfOWEW.js +10 -0
  48. package/web/assets/chunk-P2QGCYS3-CYmch4EI.js +1 -0
  49. package/web/assets/chunk-POPQ4Y6H-BOjVOrGx.js +1 -0
  50. package/web/assets/chunk-PWAF6VOD-ChmjsLTV.js +1 -0
  51. package/web/assets/chunk-SHT3W25Y-BU57QSGG.js +168 -0
  52. package/web/assets/chunk-SVP7TREG-3okF7bFJ.js +88 -0
  53. package/web/assets/chunk-TICWLB2K-DervhtUR.js +206 -0
  54. package/web/assets/chunk-XXDRQBXY-DqAYeljN.js +1 -0
  55. package/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  56. package/web/assets/classDiagram-ZZMXUADV-DG1LzruI.js +1 -0
  57. package/web/assets/classDiagram-v2-VYDZK3BY-DG1LzruI.js +1 -0
  58. package/web/assets/common-DvIV9UK2.js +5 -0
  59. package/web/assets/cose-bilkent-JH36ORCC-BIqBF2fY.js +1 -0
  60. package/web/assets/cynefin-OW5HDTMX-BpkHeQ4U.js +1 -0
  61. package/web/assets/cynefinDiagram-5FMLGOSQ-BY9V3rN7.js +62 -0
  62. package/web/assets/cytoscape.esm-NgwPT4ez.js +321 -0
  63. package/web/assets/dagre-C-rnJpZF.js +1 -0
  64. package/web/assets/dagre-GXQ25YYZ-DAmjU9c2.js +4 -0
  65. package/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  66. package/web/assets/diagram-S7CK7UJ4-BE7RKay-.js +30 -0
  67. package/web/assets/diagram-UQ7AKVKN-CkizMHlG.js +41 -0
  68. package/web/assets/diagram-VSXAHHWV-BELxVnhK.js +3 -0
  69. package/web/assets/diagram-VX7I27RA-C3wu2cVu.js +24 -0
  70. package/web/assets/diagram-Z3DM3KII-CYX5IZ30.js +24 -0
  71. package/web/assets/dist-v5Q1xZ2K.js +1 -0
  72. package/web/assets/ebnfDiagram-PWID7BFC-CgyMDCw8.js +1 -0
  73. package/web/assets/erDiagram-RLTQ6QDP-CC1ZIoBl.js +99 -0
  74. package/web/assets/eventmodeling-NTZA5JFV-YiXMtkrP.js +1 -0
  75. package/web/assets/flowDiagram-HODETNUW-UVK9K8aR.js +1 -0
  76. package/web/assets/ganttDiagram-EL5Y4UJY-CLUlox-U.js +292 -0
  77. package/web/assets/gitGraph-4MIJSDKK-B_yZBnXo.js +1 -0
  78. package/web/assets/gitGraphDiagram-WWUBYQGX-DNGTGoq6.js +106 -0
  79. package/web/assets/graphlib-DS17s2tU.js +1 -0
  80. package/web/assets/index-D-0NDZis.js +3 -0
  81. package/web/assets/index-Dk7VU5lG.css +1 -0
  82. package/web/assets/info-A6RAGUB7-O_QhqiG0.js +1 -0
  83. package/web/assets/infoDiagram-27XIBGKW-CfBbmLPf.js +2 -0
  84. package/web/assets/init-C-OQMol4.js +1 -0
  85. package/web/assets/ishikawaDiagram-5VMMS53U-BJj25HdR.js +70 -0
  86. package/web/assets/journeyDiagram-3NMN7TZE-D547gNvv.js +139 -0
  87. package/web/assets/jsx-runtime-NZYk81nU.js +1 -0
  88. package/web/assets/kanban-definition-UXKFOSKX-BziklmYw.js +89 -0
  89. package/web/assets/katex-ZlcWpGUi.js +257 -0
  90. package/web/assets/line-CjB_oA8i.js +1 -0
  91. package/web/assets/linear-SzVmGSw4.js +1 -0
  92. package/web/assets/mermaid-parser.core-Bj5-yX0d.js +7 -0
  93. package/web/assets/mermaid.core-CWsczu0T.js +44 -0
  94. package/web/assets/mindmap-definition-YA3MSWOX-BqsBs-D4.js +96 -0
  95. package/web/assets/mount-BjlUftih.css +1 -0
  96. package/web/assets/mount-BlTXC3tw.js +26 -0
  97. package/web/assets/ordinal-BDEzSJ7C.js +1 -0
  98. package/web/assets/packet-AYTQ26CC-B8U6sMom.js +1 -0
  99. package/web/assets/path-fybaL0A-.js +1 -0
  100. package/web/assets/pegDiagram-XKGWAZYB-BXV85Hj-.js +1 -0
  101. package/web/assets/pie-WAS4IAKB-CD71AvCo.js +1 -0
  102. package/web/assets/pieDiagram-E7YTZNPT-DNq8_A1r.js +39 -0
  103. package/web/assets/quadrantDiagram-AXDQQJYC-ZNiXqFdJ.js +7 -0
  104. package/web/assets/radar-RG4KPBEZ-CpvF1rs-.js +1 -0
  105. package/web/assets/railroad-74A4TZTK-BIfz4cd-.js +1 -0
  106. package/web/assets/railroad-abnf-HS5TGJTU-oAj9W4Xw.js +1 -0
  107. package/web/assets/railroad-ebnf-LZEXJU2U-B5J5VpFh.js +1 -0
  108. package/web/assets/railroad-peg-WCYAUIDC-DpqDcjfx.js +1 -0
  109. package/web/assets/railroadDiagram-O6MQD6OU-Fao_FZJL.js +1 -0
  110. package/web/assets/requirementDiagram-BXWQKSXE-QnxN_viD.js +84 -0
  111. package/web/assets/rolldown-runtime-hePW80VL.js +1 -0
  112. package/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  113. package/web/assets/sankeyDiagram-P5KCCOFB-DDhcKN50.js +40 -0
  114. package/web/assets/sequenceDiagram-WJ2MYXX4-bVK0aV05.js +162 -0
  115. package/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  116. package/web/assets/src-B6xuSHsQ.js +1 -0
  117. package/web/assets/stateDiagram-D77RDMKH-CZiiW4Zy.js +1 -0
  118. package/web/assets/stateDiagram-v2-MP3YSRHH-B9Vt7f0v.js +1 -0
  119. package/web/assets/swimlanes-42K2YHIH-CYgOds43.js +1 -0
  120. package/web/assets/swimlanesDiagram-VR7AAH4N-CfED88ct.js +8 -0
  121. package/web/assets/timeline-definition-24CTP7MA-CnVLFUDs.js +120 -0
  122. package/web/assets/treeView-Q6P3EWNA-D6CxJQkK.js +1 -0
  123. package/web/assets/treemap-WGGIJYW6-DFCHLN8D.js +1 -0
  124. package/web/assets/useStore-DcIB7b1-.js +27 -0
  125. package/web/assets/vennDiagram-4TSXK5OY-CX5aQZNu.js +34 -0
  126. package/web/assets/wardley-WFR3VGLG-BCPDkPQh.js +1 -0
  127. package/web/assets/wardleyDiagram-VM6X3IG4-Cf0fwWD7.js +78 -0
  128. package/web/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  129. package/web/assets/xychartDiagram-S5SC5T6Z-DalYEP3d.js +7 -0
  130. package/web/fonts/OFL.txt +90 -0
  131. package/web/fonts/newsreader-italic.woff2 +0 -0
  132. package/web/fonts/newsreader-roman.woff2 +0 -0
  133. package/web/icon-192.png +0 -0
  134. package/web/icon-512.png +0 -0
  135. package/web/icon.svg +16 -0
  136. package/web/index.html +36 -0
  137. package/web/manifest.webmanifest +1 -0
  138. package/web/runtime-config.js +3 -0
  139. package/web/sw.js +1 -0
  140. package/web/tent-logo.png +0 -0
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 { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
43
44
  import { resolveAttachSessionId } from "./attach-session-id.mjs";
44
45
  import { detectInstallKind as classifyInstallKind, npmGlobalPrefix } from "./install-kind.mjs";
45
46
  import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
@@ -2553,6 +2554,30 @@ async function cmdSuggest(args = []) {
2553
2554
  console.log(c.green("Suggested in the chat. The user can start it in one tap."));
2554
2555
  }
2555
2556
 
2557
+ // `bivy title "<title>" [--session <id>]` — rename the session the agent runs in.
2558
+ async function cmdTitle(args = []) {
2559
+ const usage = 'Usage: bivy title "<title>" [--session <id>] [--json]';
2560
+ if (args.includes("-h") || args.includes("--help")) {
2561
+ console.log(`${usage}\n\nRename this session in the user's session list. Use a short title that says what the work is, when the first message made a poor title or the work changed direction. --json prints {"ok","title"}.`);
2562
+ return;
2563
+ }
2564
+ const json = wantsJson(args);
2565
+ const fail = (error) => cliError(error, { json, paint: c.red });
2566
+ const sessionFlag = args.indexOf("--session");
2567
+ const title = args.filter((a, i) => !a.startsWith("-") && !(sessionFlag >= 0 && i === sessionFlag + 1)).join(" ").trim();
2568
+ const sessionId = resolveAttachSessionId({ sessionFlag: sessionFlag >= 0 ? args[sessionFlag + 1] : undefined, env: process.env });
2569
+ if (!title) return fail({ code: "usage", message: usage, exit: EXIT.usage });
2570
+ if (!sessionId) return fail({ code: "no_session", message: "No session id.", hint: "Run inside an agent session ($BIVY_SESSION_ID) or pass --session <id>.", next: "bivy sessions --json", exit: EXIT.usage });
2571
+ const config = loadConfig();
2572
+ if (!(await ensureNodeRunning(config))) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node at ${url(config)}.`, next: "bivy status", exit: EXIT.unavailable });
2573
+ const res = await sessionPost(config, sessionId, "title", { title }).catch((error) => error);
2574
+ if (res instanceof Error) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable });
2575
+ const body = await res.json().catch(() => ({}));
2576
+ if (!res.ok) return fail(sessionHttpError("Title", res.status, body));
2577
+ if (json) { console.log(JSON.stringify(body)); return; }
2578
+ console.log(c.green(`Renamed this session to "${body.title}".`));
2579
+ }
2580
+
2556
2581
  // Flags shared by the session commands: `--name value` pairs and bare switches,
2557
2582
  // with every other word collected as positional text.
2558
2583
  function parseSessionArgs(args, valueFlags) {
@@ -5673,6 +5698,122 @@ async function cmdLinkPhone(args = []) {
5673
5698
  }
5674
5699
  }
5675
5700
 
5701
+ const tailscaleConfigPath = path.join(appDir, "tailscale.json");
5702
+ const LOCAL_TOKEN_NAMES = ["Bivy CLI", "Local Remote UI"];
5703
+
5704
+ function runTailscale(args) {
5705
+ const res = spawnSync("tailscale", args, { encoding: "utf8" });
5706
+ if (res.error?.code === "ENOENT") return null;
5707
+ return { ok: res.status === 0, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
5708
+ }
5709
+
5710
+ async function printPairLink(config) {
5711
+ const data = await localApi(config, "/api/auth/pair-codes", { method: "POST", body: "{}" });
5712
+ if (!data?.url) throw new Error("The node did not return a pairing link.");
5713
+ const qr = terminalQr(data.url);
5714
+ if (qr) console.log(qr);
5715
+ console.log(`\nOpen on your phone (single use, 10 minutes):\n${c.cyan(data.url)}\n`);
5716
+ }
5717
+
5718
+ // `bivy tailscale` — reach this machine from anywhere on your tailnet with no
5719
+ // control plane or relay: `tailscale serve` puts HTTPS on the machine's ts.net
5720
+ // name and forwards it to the node's direct listener, which serves the web app.
5721
+ async function cmdTailscale(args = []) {
5722
+ const [verb = "on"] = args;
5723
+ if (args.includes("-h") || args.includes("--help") || !["on", "pair", "status", "devices", "revoke", "off"].includes(verb)) {
5724
+ console.log(`Usage: bivy tailscale [on|pair|status|devices|revoke <id>|off]
5725
+
5726
+ on Serve this machine's Bivy on https://<machine>.<tailnet>.ts.net (tailnet only) and show a pairing link
5727
+ pair Show a new one-time pairing link for another device
5728
+ status Show the Tailscale address and whether it is served
5729
+ devices List the devices holding a token for this node
5730
+ revoke <id> Revoke a device's token and disconnect it
5731
+ off Stop serving Bivy over Tailscale`);
5732
+ return;
5733
+ }
5734
+ const config = loadConfig();
5735
+ if (!(await isReachable(config))) {
5736
+ console.log(c.yellow(`The node is not reachable at ${url(config)}. Start it first: ${c.cyan(config.service ? "bivy restart" : "bivy start")}`));
5737
+ process.exitCode = EXIT.unavailable;
5738
+ return;
5739
+ }
5740
+ const saved = (() => { try { return JSON.parse(fs.readFileSync(tailscaleConfigPath, "utf8")); } catch { return null; } })();
5741
+
5742
+ if (verb === "pair") {
5743
+ if (!saved) { console.log(`Tailscale access is off. Turn it on with ${c.cyan("bivy tailscale")}.`); process.exitCode = EXIT.failed; return; }
5744
+ await printPairLink(config);
5745
+ return;
5746
+ }
5747
+ if (verb === "devices") {
5748
+ // The CLI and loopback web client mint their own tokens on this machine; list them as a count.
5749
+ const all = (await localApi(config, "/api/auth/devices")) ?? [];
5750
+ const devices = all.filter((device) => !LOCAL_TOKEN_NAMES.includes(device.name));
5751
+ for (const device of devices) console.log(`${c.cyan(device.id)} ${device.name}${device.createdAt ? c.dim(` paired ${device.createdAt}`) : ""}`);
5752
+ if (!devices.length) console.log("No paired devices.");
5753
+ if (all.length > devices.length) console.log(c.dim(`(+${all.length - devices.length} tokens used on this machine by the CLI)`));
5754
+ return;
5755
+ }
5756
+ if (verb === "revoke") {
5757
+ const id = args[1];
5758
+ if (!id) { console.log("Usage: bivy tailscale revoke <id> (ids from 'bivy tailscale devices')"); process.exitCode = EXIT.usage; return; }
5759
+ const res = await fetch(`${url(config)}/api/auth/devices/${encodeURIComponent(id)}`, { method: "DELETE", headers: { authorization: `Bearer ${await localDeviceToken(config)}` } });
5760
+ if (!res.ok) { console.log(c.yellow(`No device ${id}.`)); process.exitCode = EXIT.notFound; return; }
5761
+ console.log(`${c.green("✓")} Revoked ${id}.`);
5762
+ return;
5763
+ }
5764
+ if (verb === "status") {
5765
+ console.log(saved ? `${c.green("✓")} Served on ${c.cyan(`https://${saved.hostname}`)} (tailnet only) → 127.0.0.1:${saved.port}` : `Tailscale access is off. Turn it on with ${c.cyan("bivy tailscale")}.`);
5766
+ return;
5767
+ }
5768
+ if (verb === "off") {
5769
+ if (saved) {
5770
+ const state = serveState(runTailscale(["serve", "status", "--json"])?.stdout, saved.hostname);
5771
+ if (state.target === `http://127.0.0.1:${saved.port}`) runTailscale(serveOffArgs());
5772
+ }
5773
+ fs.rmSync(tailscaleConfigPath, { force: true });
5774
+ await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5775
+ console.log(`${c.green("✓")} Bivy is no longer served over Tailscale. Paired devices keep their tokens; see them with 'bivy tailscale devices' and revoke with 'bivy tailscale revoke <id>'.`);
5776
+ return;
5777
+ }
5778
+
5779
+ const status = runTailscale(["status", "--json"]);
5780
+ if (!status) {
5781
+ console.log(`${c.yellow("Tailscale isn't installed.")} Install it from ${c.cyan("https://tailscale.com/download")} (Arch: ${c.cyan("sudo pacman -S tailscale && sudo systemctl enable --now tailscaled")}), sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
5782
+ process.exitCode = EXIT.unavailable;
5783
+ return;
5784
+ }
5785
+ const { running, hostname } = parseTailscaleStatus(status.stdout);
5786
+ if (!running || !hostname) {
5787
+ console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
5788
+ process.exitCode = EXIT.unavailable;
5789
+ return;
5790
+ }
5791
+ const port = saved?.port ?? await findAvailablePort(config.port + 2, "127.0.0.1");
5792
+ const conflict = serveConflict(serveState(runTailscale(["serve", "status", "--json"])?.stdout, hostname), port);
5793
+ if (conflict) {
5794
+ console.log(c.yellow(conflict));
5795
+ process.exitCode = EXIT.conflict;
5796
+ return;
5797
+ }
5798
+ fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
5799
+ const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5800
+ const served = runTailscale(serveOnArgs(port));
5801
+ if (!served?.ok) {
5802
+ fs.rmSync(tailscaleConfigPath, { force: true });
5803
+ await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" }).catch(() => {});
5804
+ console.error(c.red(`tailscale serve failed: ${(served?.stderr || served?.stdout || "").trim()}`));
5805
+ const hint = serveFailureHint(served?.stderr);
5806
+ if (hint) console.log(hint);
5807
+ process.exitCode = EXIT.failed;
5808
+ return;
5809
+ }
5810
+ console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
5811
+ console.log(c.dim(" Only devices on your tailnet can reach it. Pair each one once with a link from 'bivy tailscale pair'."));
5812
+ 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."));
5813
+ console.log("");
5814
+ await printPairLink(config);
5815
+ }
5816
+
5676
5817
  function printUninstallHelp() {
5677
5818
  console.log(`
5678
5819
  ${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
@@ -6087,6 +6228,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
6087
6228
  case "suggest":
6088
6229
  await cmdSuggest(args);
6089
6230
  break;
6231
+ case "title":
6232
+ await cmdTitle(args);
6233
+ break;
6090
6234
  case "notify":
6091
6235
  await cmdNotify(args);
6092
6236
  break;
@@ -6270,6 +6414,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
6270
6414
  case "link":
6271
6415
  await cmdLinkPhone(args);
6272
6416
  break;
6417
+ case "tailscale":
6418
+ await cmdTailscale(args);
6419
+ break;
6273
6420
  case "relay:setup":
6274
6421
  await cmdRelaySetup(args);
6275
6422
  break;
@@ -48,6 +48,8 @@ export const COMMANDS = [
48
48
  tools: [{ name: "notify_user", description: "Send the user a message: a card in the chat, and a push to their phone when nobody has the app open (the push names the session, not the text). Use it when long work finishes, you are blocked, or something needs their attention. At most one push a minute per session.", input: { message: { type: "string", description: "What to tell the user.", required: true }, urgent: { type: "boolean", description: "Push even while the user has the app open." } }, argv: ["notify", { arg: "message" }, { when: "urgent", flag: "--urgent" }] }] },
49
49
  { name: "ask", group: "session", scope: "session", json: true, usage: 'ask "<question>" [--option A --option B] [--async]', summary: "Ask the user a question and wait for the answer", subcommands: ["status", "wait"],
50
50
  tools: [{ name: "ask_user", description: "Ask the user a question in the chat and wait for their answer (their phone gets a push). Offer 2-8 options, or none for a free-text answer; they can always write their own. Returns {status, answer}: status is answered, dismissed (decide yourself) or expired.", input: { question: { type: "string", description: "The question.", required: true }, options: { type: "array", items: { type: "string" }, description: "Choices to pick from (2-8)." }, multiple: { type: "boolean", description: "Allow several choices." }, header: { type: "string", description: "A short label for the card." }, timeout_seconds: { type: "number", description: "How long to wait (default 600)." } }, argv: ["ask", { arg: "question" }, { flag: "--option", from: "options" }, { when: "multiple", flag: "--multi" }, { flag: "--header", from: "header" }, { flag: "--timeout", from: "timeout_seconds" }] }] },
51
+ { name: "title", group: "session", scope: "session", json: true, usage: 'title "<title>"', summary: "Rename this session",
52
+ tools: [{ name: "set_session_title", description: "Rename this session in the user's session list. Use a short title (under 60 characters) that says what the work is, when the first message made a poor title or the work changed direction.", input: { title: { type: "string", description: "The new title.", required: true } }, argv: ["title", { arg: "title" }] }] },
51
53
  { name: "suggest", group: "session", scope: "session", json: true, usage: 'suggest "<task>" [--title "label"] [--run here|subagents|new]', summary: "Propose a task the user can start in one tap",
52
54
  tools: [{ name: "suggest_task", description: "Propose a next step the user can start in one tap: in this session, through your sub-agents, or in a parallel session with its own copy of the project. Write the task as a complete instruction with paths relative to the project root. Post one per idea instead of listing them.", input: { task: { type: "string", description: "The complete instruction.", required: true }, title: { type: "string", description: "A short label for the card." }, run: { type: "string", enum: ["here", "subagents", "new"], description: "Where you recommend running it: here (builds on this conversation), subagents (independent tasks you can split across your own sub-agents; only if you have them), or new (bigger independent work in its own session). Defaults: here for one card, new for several." } }, argv: ["suggest", { arg: "task" }, { flag: "--title", from: "title" }, { flag: "--run", from: "run" }] }] },
53
55
  { 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"],
@@ -103,6 +105,7 @@ export const COMMANDS = [
103
105
  { name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
104
106
  { name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
105
107
  { name: "link", group: "node", scope: "node", usage: "link", summary: "Show a web app link as a QR code in the terminal" },
108
+ { 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"] },
106
109
  { name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
107
110
  { name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
108
111
 
@@ -41,6 +41,13 @@ instead of a bulleted list.
41
41
  | `subagents` | Independent tasks you can split across your own sub-agents and report back on. Only if you have sub-agents. |
42
42
  | `new` | Bigger independent work the user will want to follow, review or merge on its own. Default for several cards. |
43
43
 
44
+ ## Name the session: title
45
+
46
+ bivy title "Fix login redirect loop"
47
+
48
+ The session list shows a title taken from the first message. When that doesn't
49
+ say what the work is, or the work changes direction, rename it. Keep it short.
50
+
44
51
  ## Is anyone there?
45
52
 
46
53
  `bivy context --json` reports `userConnected` (some device has the app open) and
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Pure helpers for `bivy tailscale`: read `tailscale status --json` and
4
+ // `tailscale serve status --json`, and build the `tailscale serve` arguments.
5
+ // No process spawning here, so each decision is testable on its own.
6
+
7
+ /** The one HTTPS port Bivy claims on the machine's tailnet name. */
8
+ export const SERVE_HTTPS_PORT = 443;
9
+
10
+ /** `{ running, hostname }` from `tailscale status --json` output. */
11
+ export function parseTailscaleStatus(text) {
12
+ let status;
13
+ try { status = JSON.parse(text); } catch { return { running: false, hostname: null }; }
14
+ const hostname = String(status?.Self?.DNSName ?? "").replace(/\.$/, "") || null;
15
+ return { running: status?.BackendState === "Running", hostname };
16
+ }
17
+
18
+ /** Where `tailscale serve` should send the machine's HTTPS traffic. */
19
+ export function serveTarget(port) {
20
+ return `http://127.0.0.1:${port}`;
21
+ }
22
+
23
+ export function serveOnArgs(port) {
24
+ return ["serve", "--bg", `--https=${SERVE_HTTPS_PORT}`, serveTarget(port)];
25
+ }
26
+
27
+ export function serveOffArgs() {
28
+ return ["serve", `--https=${SERVE_HTTPS_PORT}`, "off"];
29
+ }
30
+
31
+ /**
32
+ * What already sits on `<hostname>:443`, from `tailscale serve status --json`:
33
+ * `funnel` when it is public (Tailscale Funnel), and the proxy target of `/`
34
+ * when something is served there. Bivy refuses to share a Funnel'd name, and
35
+ * refuses to replace a target that isn't its own.
36
+ */
37
+ export function serveState(text, hostname) {
38
+ let status;
39
+ try { status = JSON.parse(text || "{}"); } catch { status = {}; }
40
+ const key = `${hostname}:${SERVE_HTTPS_PORT}`;
41
+ const handlers = status?.Web?.[key]?.Handlers ?? {};
42
+ return {
43
+ funnel: status?.AllowFunnel?.[key] === true,
44
+ target: typeof handlers["/"]?.Proxy === "string" ? handlers["/"].Proxy : null,
45
+ otherPaths: Object.keys(handlers).filter((p) => p !== "/"),
46
+ };
47
+ }
48
+
49
+ /** Why `bivy tailscale` can't take `<hostname>:443`, or null when it can. */
50
+ export function serveConflict(state, port) {
51
+ if (state.funnel) return "Tailscale Funnel is on for this machine's HTTPS name, which would put Bivy on the public internet. Turn it off first: tailscale funnel reset";
52
+ if (state.target && state.target !== serveTarget(port)) return `tailscale serve already sends https:// on this machine to ${state.target}. Remove it first: tailscale serve --https=${SERVE_HTTPS_PORT} off`;
53
+ if (state.otherPaths.length) return `tailscale serve already has paths on this machine's HTTPS name (${state.otherPaths.join(", ")}). Remove them first: tailscale serve --https=${SERVE_HTTPS_PORT} off`;
54
+ return null;
55
+ }
56
+
57
+ /** A one-line hint for a failed `tailscale serve`, from its stderr. */
58
+ export function serveFailureHint(stderr) {
59
+ const text = String(stderr ?? "");
60
+ if (/access denied|permission denied|must be root|operator/i.test(text)) return "Let your user manage Tailscale once: sudo tailscale set --operator=$USER";
61
+ if (/https.*not enabled|enable https|certificates/i.test(text)) return "Turn on HTTPS certificates for your tailnet (admin console → DNS → HTTPS Certificates), then run this again.";
62
+ return null;
63
+ }
@@ -45,6 +45,8 @@ export const BIVY_AGENT_NOTE = [
45
45
  "project root. `--run` is the one you recommend: here when it builds on this conversation, subagents for " +
46
46
  "independent tasks you can split and supervise (only if you have sub-agents), new for bigger work worth its " +
47
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>"`.',
48
50
  "- When you finish long work, get blocked, or need the user to look at something: " +
49
51
  '`bivy notify "<message>"` (a chat card, plus a push to their phone when they are away). To ask and wait for ' +
50
52
  'an answer: `bivy ask "<question>" [--option A --option B]` prints their answer (or use your own ask-the-user tool).',
package/dist/auth.js CHANGED
@@ -112,6 +112,18 @@ 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 WeakSet();
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));
122
+ }
123
+ /** True when a request physically arrived over loopback from a local caller. */
124
+ export function isLoopbackRequest(req) {
125
+ return !!req.socket && !remoteSockets.has(req.socket) && isLoopbackAddress(req.socket.remoteAddress);
126
+ }
115
127
  export function extractToken(headerValue) {
116
128
  if (!headerValue)
117
129
  return null;
@@ -141,7 +153,7 @@ export function tokenFromRequest(req) {
141
153
  * `/api/auth/bootstrap`) can rely on it even when the general bypass in
142
154
  * `isAuthorized` is off. */
143
155
  export function resolveAuth(identity, req) {
144
- const loopback = isLoopbackAddress(req.socket?.remoteAddress);
156
+ const loopback = isLoopbackRequest(req);
145
157
  const deviceId = identity.verifyToken(tokenFromRequest(req));
146
158
  return { deviceId, loopback };
147
159
  }
@@ -160,7 +172,8 @@ export function isAuthorized(ctx) {
160
172
  * web client (?local=1), a LAN device browsing the node directly, or CLI/native
161
173
  * clients (no Origin).
162
174
  * 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
175
+ * *outbound* — those never arrive here as inbound upgrades — or over Tailscale
176
+ * on the `*.ts.net` name, which counts as local here. So we allow only
164
177
  * local/private hostnames and reject a public Host (rebinding) or public Origin
165
178
  * (cross-site). Escape hatches: BIVY_ALLOWED_HOSTS (comma-separated extra
166
179
  * hostnames, e.g. a reverse-proxy domain) and BIVY_ALLOW_ANY_ORIGIN=1.
@@ -272,7 +285,7 @@ export function authMiddleware(identity, sessionTokens) {
272
285
  return;
273
286
  }
274
287
  sessionTokens.used?.(sessionId, call);
275
- req.auth = { deviceId: null, loopback: isLoopbackAddress(req.socket?.remoteAddress), sessionId };
288
+ req.auth = { deviceId: null, loopback: isLoopbackRequest(req), sessionId };
276
289
  next();
277
290
  return;
278
291
  }
@@ -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
+ }
@@ -0,0 +1,23 @@
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
+ return { port, hostname: typeof raw.hostname === "string" && raw.hostname ? raw.hostname : undefined };
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
@@ -12,3 +12,4 @@
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";
package/dist/server.js CHANGED
@@ -84,8 +84,11 @@ import { canOpenBrowser, openBrowser } from "./browser-open.js";
84
84
  import { openOAuthLoginOnNode } from "./runtime/oauth/oauth-node-open.js";
85
85
  import { collectNodeStats } from "./node-stats.js";
86
86
  import { SessionEventCoalescer } from "./session-event-coalescer.js";
87
- import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed } from "./auth.js";
88
- import { RelayConnector, loadRelayConfig, soloCredentials } from "./remote/index.js";
87
+ import { authMiddleware, resolveAuth, isAuthorized, requestOriginAllowed, markRemoteListener } from "./auth.js";
88
+ import { PairCodes } from "./pair-codes.js";
89
+ import http from "node:http";
90
+ import { webAppDirs, webAppRouter } from "./web-app.js";
91
+ import { RelayConnector, loadRelayConfig, loadDirectListenerConfig, soloCredentials } from "./remote/index.js";
89
92
  import { readEphemeralTeardownConfig, shouldSelfTeardown, snapshotsDurableForTeardown, performSelfTeardown } from "./ephemeral-teardown.js";
90
93
  import { buildSessionSnapshot, applySessionSnapshot } from "./session/snapshot.js";
91
94
  import { clearTurnActivity } from "./session/turn-activity.js";
@@ -1070,9 +1073,16 @@ const accessDevices = createAccessDeviceController({
1070
1073
  create: (name) => identity.createDevice(name),
1071
1074
  revoke: (id) => identity.revokeDevice(id),
1072
1075
  onCreated: (device) => broadcast({ type: "device.created", device }),
1073
- onRevoked: (id) => broadcast({ type: "device.revoked", id }),
1076
+ onRevoked: (id) => {
1077
+ broadcast({ type: "device.revoked", id });
1078
+ // A revoked token loses its open sockets now, not at its next reconnect.
1079
+ for (const socket of clients)
1080
+ if (socketDeviceIds.get(socket) === id)
1081
+ socket.close(1008, "Unauthorized");
1082
+ },
1074
1083
  });
1075
1084
  const clients = new Set();
1085
+ const socketDeviceIds = new WeakMap();
1076
1086
  const commandProcesses = new Map();
1077
1087
  const oauthLogins = new Map();
1078
1088
  // A browser-initiated subscription login parks the node on `manualCodePromise`
@@ -2518,10 +2528,7 @@ const RELAY_COMMANDS = {
2518
2528
  const rec = resolveSession(sid);
2519
2529
  if (!rec)
2520
2530
  return;
2521
- rec.session.setName(newName);
2522
- persistSessionMetadata(rec);
2523
- ctx.broadcast({ type: "session.renamed", sessionId: rec.id, sessionFile: rec.sessionFile, name: newName });
2524
- scheduleAdvertise();
2531
+ sessionNamer.setSessionName(rec, newName);
2525
2532
  },
2526
2533
  abort(msg, ctx) {
2527
2534
  const sessionId = String(msg.sessionId ?? "");
@@ -9337,7 +9344,8 @@ app.use((_req, res, next) => {
9337
9344
  // (OAuth callback, GitHub App manifest). It does NOT host the web UI — the
9338
9345
  // React/Vite PWA (@bivy/web) is served exclusively by the hosted or self-hosted
9339
9346
  // control plane. `/` returns a small informational stub for anyone who points a
9340
- // browser straight at the node.
9347
+ // browser straight at the node. The one exception is the direct listener for
9348
+ // `bivy tailscale` (below), which has no control plane and serves the app itself.
9341
9349
  app.get("/", (_req, res) => {
9342
9350
  res.setHeader("Cache-Control", "no-store, max-age=0");
9343
9351
  res
@@ -9447,6 +9455,20 @@ app.post("/api/auth/bootstrap", sensitiveRateLimiter, (req, res) => {
9447
9455
  const { device, token } = accessDevices.create(name);
9448
9456
  res.json({ ok: true, device, token });
9449
9457
  });
9458
+ // Pairing a device that reaches the node directly (Tailscale): trade a one-time
9459
+ // code minted on this machine for a device token. Registered before the auth
9460
+ // middleware because the caller has no token yet; the code is the credential.
9461
+ const pairCodes = new PairCodes();
9462
+ app.post("/api/auth/pair", sensitiveRateLimiter, (req, res) => {
9463
+ if (!requestOriginAllowed(req))
9464
+ return res.status(403).json({ error: "Forbidden origin" });
9465
+ if (!pairCodes.redeem(String(req.body?.code ?? ""))) {
9466
+ return res.status(401).json({ error: "This pairing link is used or expired. Run 'bivy tailscale pair' on your machine for a new one." });
9467
+ }
9468
+ const name = String(req.body?.name ?? "").trim().slice(0, 80) || "Paired device";
9469
+ const { device, token } = accessDevices.create(name);
9470
+ res.json({ ok: true, device, token });
9471
+ });
9450
9472
  // All other /api routes require auth (loopback may bypass, per config and host
9451
9473
  // — see loopbackAllowed()/isMultiUserHost() in src/auth.ts).
9452
9474
  app.use("/api", authMiddleware(identity, {
@@ -9457,6 +9479,23 @@ app.use("/api", authMiddleware(identity, {
9457
9479
  used: (sessionId, call) => { if (call.method !== "GET")
9458
9480
  auditLog.record({ kind: "agent.call", session: sessionId, tool: `${call.method} ${call.path}` }); },
9459
9481
  }));
9482
+ // Mint a pairing code for a direct (Tailscale) device. Only callers already
9483
+ // trusted by this node get one: the owner's CLI, or an already-paired device.
9484
+ app.post("/api/auth/pair-codes", (_req, res) => {
9485
+ const direct = loadDirectListenerConfig(appDir);
9486
+ const { code, expiresAt } = pairCodes.issue();
9487
+ const url = direct?.hostname ? `https://${direct.hostname}/#pair=${code}` : undefined;
9488
+ res.json({ ok: true, code, expiresAt, url });
9489
+ });
9490
+ // Start, move or stop the direct listener after `bivy tailscale` changes it.
9491
+ app.post("/api/direct/reload", async (_req, res, next) => {
9492
+ try {
9493
+ res.json({ ok: true, ...(await reloadDirectListener()) });
9494
+ }
9495
+ catch (error) {
9496
+ next(error);
9497
+ }
9498
+ });
9460
9499
  // Reload relay.json without forcing the user to restart the whole node. This is
9461
9500
  // used by `bivy relay:setup` after it enrolls the node.
9462
9501
  app.post("/api/relay/reload", (_req, res) => {
@@ -9823,10 +9862,7 @@ app.post("/api/sessions/rename", (req, res, next) => {
9823
9862
  const rec = resolveSession(sid);
9824
9863
  if (!rec)
9825
9864
  return res.status(404).json({ error: "Session not found" });
9826
- rec.session.setName(newName);
9827
- persistSessionMetadata(rec);
9828
- broadcast({ type: "session.renamed", sessionId: rec.id, sessionFile: rec.sessionFile, name: newName });
9829
- scheduleAdvertise();
9865
+ sessionNamer.setSessionName(rec, newName);
9830
9866
  res.json({ ok: true, name: newName });
9831
9867
  }
9832
9868
  catch (error) {
@@ -11665,6 +11701,19 @@ app.get("/api/session/:id/automations/apply/:proposalId", (req, res) => {
11665
11701
  return res.status(404).json({ error: "Proposal not found" });
11666
11702
  res.json(proposal);
11667
11703
  });
11704
+ // `bivy title "<title>"`: the agent names its own session, e.g. when the first
11705
+ // message made a poor title or the work changed direction.
11706
+ const MAX_SESSION_TITLE = 100;
11707
+ app.post("/api/session/:id/title", (req, res) => {
11708
+ const record = openSessions.get(String(req.params.id));
11709
+ if (!record)
11710
+ return res.status(404).json({ error: "Session not found" });
11711
+ const title = typeof req.body?.title === "string" ? req.body.title.replace(/\s+/g, " ").trim() : "";
11712
+ if (!title || title.length > MAX_SESSION_TITLE)
11713
+ return res.status(400).json({ error: `A title needs 1 to ${MAX_SESSION_TITLE} characters.` });
11714
+ sessionNamer.setSessionName(record, title);
11715
+ res.json({ ok: true, title });
11716
+ });
11668
11717
  // `bivy suggest "<task>"`: the agent proposes a task the user can start in one
11669
11718
  // tap, here, through this agent's sub-agents, or in a parallel session; `run`
11670
11719
  // is the one the agent recommends (see packages/web SuggestionCard).
@@ -12007,6 +12056,7 @@ const server = app.listen(port, host, async () => {
12007
12056
  console.log(`Agent data dir: ${piDir}`);
12008
12057
  console.log(`Workspace: ${defaultWorkspace}`);
12009
12058
  startRelayIfConfigured();
12059
+ void reloadDirectListener().catch((error) => console.error("[direct] Could not start the direct listener:", error instanceof Error ? error.message : error));
12010
12060
  void syncAgentBridges().catch((error) => console.warn("[bridges] Could not install agent bridges; they will install on first use:", error instanceof Error ? error.message : error));
12011
12061
  if (appGateway) {
12012
12062
  appGateway.server.on("error", (error) => { console.error("[apps] Preview gateway could not start:", error); shutdown("preview gateway failure"); });
@@ -12073,6 +12123,46 @@ server.on("error", (error) => {
12073
12123
  process.exit(1);
12074
12124
  });
12075
12125
  const wss = new WebSocketServer({ server, path: "/ws" });
12126
+ // Direct listener (`bivy tailscale`): the same API and WebSocket, plus the web
12127
+ // app, on a loopback port `tailscale serve` exposes to the tailnet over HTTPS.
12128
+ // markRemoteListener keeps its proxied, loopback-looking connections from ever
12129
+ // getting loopback trust; every caller needs a device token.
12130
+ const directWebApp = webAppRouter(webAppDirs(repoRoot));
12131
+ let directListener = null;
12132
+ async function reloadDirectListener() {
12133
+ const config = loadDirectListenerConfig(appDir);
12134
+ if (directListener && directListener.port !== config?.port) {
12135
+ const previous = directListener;
12136
+ directListener = null;
12137
+ for (const client of previous.wss.clients)
12138
+ client.terminate();
12139
+ previous.wss.close();
12140
+ previous.server.closeAllConnections();
12141
+ await new Promise((resolve) => previous.server.close(() => resolve()));
12142
+ }
12143
+ if (config && !directListener) {
12144
+ const directApp = express();
12145
+ directApp.disable("x-powered-by");
12146
+ if (directWebApp)
12147
+ directApp.use(directWebApp);
12148
+ directApp.use(app);
12149
+ const listener = http.createServer(directApp);
12150
+ markRemoteListener(listener);
12151
+ const directWss = new WebSocketServer({ server: listener, path: "/ws" });
12152
+ directWss.on("connection", (socket, req) => wss.emit("connection", socket, req));
12153
+ await new Promise((resolve, reject) => {
12154
+ listener.once("error", reject);
12155
+ listener.listen(config.port, "127.0.0.1", () => {
12156
+ listener.off("error", reject);
12157
+ resolve();
12158
+ });
12159
+ });
12160
+ listener.on("error", (error) => console.error("[direct] Listener error:", error));
12161
+ directListener = { port: config.port, server: listener, wss: directWss };
12162
+ console.log(`[direct] Listening on 127.0.0.1:${config.port}${config.hostname ? ` for https://${config.hostname}` : ""}${directWebApp ? "" : " (web app not built; API only)"}`);
12163
+ }
12164
+ return { enabled: Boolean(directListener), port: directListener?.port, webApp: Boolean(directWebApp) };
12165
+ }
12076
12166
  wss.on("connection", (socket, req) => {
12077
12167
  // Reject cross-origin / DNS-rebinding upgrades before auth: loopback bypasses
12078
12168
  // auth by default, so a page the user merely visited could otherwise open a
@@ -12081,11 +12171,14 @@ wss.on("connection", (socket, req) => {
12081
12171
  socket.close(1008, "Forbidden origin");
12082
12172
  return;
12083
12173
  }
12084
- if (!isAuthorized(resolveAuth(identity, req))) {
12174
+ const socketAuth = resolveAuth(identity, req);
12175
+ if (!isAuthorized(socketAuth)) {
12085
12176
  socket.close(1008, "Unauthorized");
12086
12177
  return;
12087
12178
  }
12088
12179
  clients.add(socket);
12180
+ if (socketAuth.deviceId)
12181
+ socketDeviceIds.set(socket, socketAuth.deviceId);
12089
12182
  // Terminals opened on this socket; closed when the socket disconnects so a
12090
12183
  // dropped browser tab doesn't leak shells. Output is unicast to this socket.
12091
12184
  const ownedTerminals = new Set();
@@ -180,6 +180,12 @@ export function createSessionNamer(deps) {
180
180
  catch (error) {
181
181
  console.warn("Session naming suggestion failed", error);
182
182
  }
183
+ // A title set while the model was thinking (the user, or the agent with
184
+ // `bivy title`) wins over the refinement.
185
+ if (record.session.getName() !== fallback) {
186
+ record.namedFromFirstPrompt = true;
187
+ return;
188
+ }
183
189
  if (finalName !== fallback) {
184
190
  record.session.setName(finalName);
185
191
  deps.persistSessionMetadata(record);
@@ -211,8 +217,8 @@ export function createSessionNamer(deps) {
211
217
  record.naming = false;
212
218
  }
213
219
  }
214
- /** Manually set a session's title (e.g. GitHub-issue pickup) and lock it so the
215
- * first-prompt namer won't overwrite it. */
220
+ /** Manually set a session's title (a rename in the app, `bivy title`, a
221
+ * GitHub-issue pickup) and lock it so the first-prompt namer won't overwrite it. */
216
222
  function setSessionName(record, name) {
217
223
  const clean = name.trim();
218
224
  if (!clean)
@@ -44,7 +44,7 @@ export function createSessionTokenCodec(key = randomBytes(32)) {
44
44
  */
45
45
  export const SESSION_TOKEN_ROUTES = [
46
46
  { method: "GET", path: /^\/api\/session\/([^/]+)\/context$/, session: "path" },
47
- { method: "POST", path: /^\/api\/session\/([^/]+)\/(attach|suggest|notify)$/, session: "path" },
47
+ { method: "POST", path: /^\/api\/session\/([^/]+)\/(attach|suggest|notify|title)$/, session: "path" },
48
48
  { method: "POST", path: /^\/api\/session\/([^/]+)\/ask$/, session: "path" },
49
49
  { method: "GET", path: /^\/api\/session\/([^/]+)\/ask\/[^/]+$/, session: "path" },
50
50
  { method: "POST", path: /^\/api\/session\/([^/]+)\/ask\/[^/]+\/wait$/, session: "path" },