@bivy/bivy 0.20.1-staging.1 → 0.20.1-staging.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +5 -0
  2. package/bin/access-view.mjs +43 -0
  3. package/bin/bivy.mjs +300 -18
  4. package/bin/cli-commands.mjs +7 -2
  5. package/bin/guides/talk-to-the-user.md +19 -3
  6. package/bin/install-kind.mjs +20 -0
  7. package/bin/tailscale.mjs +70 -0
  8. package/bin/tui/app.mjs +189 -0
  9. package/bin/tui/model.mjs +112 -0
  10. package/bin/tui/render.mjs +164 -0
  11. package/bin/tui/text.mjs +72 -0
  12. package/bin/tui/transcript.mjs +54 -0
  13. package/dist/access.js +72 -0
  14. package/dist/agent-instructions.js +8 -3
  15. package/dist/apps/listeners.js +31 -3
  16. package/dist/apps/service.js +8 -1
  17. package/dist/apps/styles.css +16 -2
  18. package/dist/apps/tokens.css +40 -0
  19. package/dist/auth.js +31 -4
  20. package/dist/controllers/app-commands.js +7 -2
  21. package/dist/machine-theme.js +118 -0
  22. package/dist/node-update.js +16 -0
  23. package/dist/pair-codes.js +41 -0
  24. package/dist/protocol/client-command-routes.js +2 -0
  25. package/dist/protocol/client-command-schemas.js +3 -1
  26. package/dist/remote/direct.js +24 -0
  27. package/dist/remote/index.js +2 -0
  28. package/dist/remote/tailnet.js +44 -0
  29. package/dist/server.js +165 -18
  30. package/dist/session/run-terminal.js +6 -2
  31. package/dist/session/session-namer.js +8 -2
  32. package/dist/session/session-tokens.js +1 -1
  33. package/dist/session/suggestions.js +5 -1
  34. package/dist/web-app.js +55 -0
  35. package/package.json +2 -2
  36. package/web/apple-touch-icon.png +0 -0
  37. package/web/assets/AutomationsView-CjTCAznJ.js +51 -0
  38. package/web/assets/ChangesCard-CBV_oCpv.js +4 -0
  39. package/web/assets/LibraryView-tZ-SLR74.js +2 -0
  40. package/web/assets/ProviderConnect-di0nwwfS.js +1 -0
  41. package/web/assets/ReadinessChecklist-Bbgq2GQ8.js +1 -0
  42. package/web/assets/Settings-wr27-Utd.js +10 -0
  43. package/web/assets/Sheet-DyGDs8Ap.js +2 -0
  44. package/web/assets/StatusDot-dpbztK53.js +1 -0
  45. package/web/assets/Terminal-B1njb2Cr.css +1 -0
  46. package/web/assets/Terminal-BnewuT2I.js +96 -0
  47. package/web/assets/VoiceSettings-gkjVXlwh.js +1 -0
  48. package/web/assets/abnfDiagram-VCTEODGH-xPExYTbq.js +1 -0
  49. package/web/assets/arc-Pry-7G1_.js +1 -0
  50. package/web/assets/architecture-7GRP2DOG-C2VA8oJp.js +1 -0
  51. package/web/assets/architectureDiagram-5GKGNRK7-DY9aKTXO.js +36 -0
  52. package/web/assets/array-BifhSqXX.js +1 -0
  53. package/web/assets/blockDiagram-I7D4REHJ-oAtHEG_S.js +129 -0
  54. package/web/assets/c4Diagram-7LVT6UL2-DAW5aIn-.js +38 -0
  55. package/web/assets/channel-DfQdWzMf.js +1 -0
  56. package/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  57. package/web/assets/chunk-4HAMMTFA-k55Imkrb.js +62 -0
  58. package/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  59. package/web/assets/chunk-75Z2AOVW-9BoEka2V.js +2 -0
  60. package/web/assets/chunk-DU6HZSFF-v3xjhwis.js +127 -0
  61. package/web/assets/chunk-F27PBJKO-ukCww9iX.js +1 -0
  62. package/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
  63. package/web/assets/chunk-GMAD6QVW-eEDdT117.js +72 -0
  64. package/web/assets/chunk-GVQU2GXP-BJ7btc9s.js +1 -0
  65. package/web/assets/chunk-IMKFNOWR-Cmcy1775.js +231 -0
  66. package/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  67. package/web/assets/chunk-L3NEJ4N5-BjMoq7Ow.js +1 -0
  68. package/web/assets/chunk-OSK3NFVY-03uwrVwP.js +10 -0
  69. package/web/assets/chunk-P2QGCYS3-C3poXv15.js +1 -0
  70. package/web/assets/chunk-POPQ4Y6H-DmAcZLrU.js +1 -0
  71. package/web/assets/chunk-PWAF6VOD-Bi69-MYG.js +1 -0
  72. package/web/assets/chunk-SHT3W25Y-DBVBaWqA.js +168 -0
  73. package/web/assets/chunk-SVP7TREG-DA4UOsHJ.js +88 -0
  74. package/web/assets/chunk-TICWLB2K-CcUmJxSS.js +206 -0
  75. package/web/assets/chunk-XXDRQBXY-DqAYeljN.js +1 -0
  76. package/web/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  77. package/web/assets/classDiagram-ZZMXUADV-DDfEWWro.js +1 -0
  78. package/web/assets/classDiagram-v2-VYDZK3BY-DDfEWWro.js +1 -0
  79. package/web/assets/common-DvIV9UK2.js +5 -0
  80. package/web/assets/cose-bilkent-JH36ORCC-BIqBF2fY.js +1 -0
  81. package/web/assets/cynefin-OW5HDTMX-B-p1iHVa.js +1 -0
  82. package/web/assets/cynefinDiagram-5FMLGOSQ-D3t5rjlb.js +62 -0
  83. package/web/assets/cytoscape.esm-NgwPT4ez.js +321 -0
  84. package/web/assets/dagre-C-rnJpZF.js +1 -0
  85. package/web/assets/dagre-GXQ25YYZ-CAVvMffj.js +4 -0
  86. package/web/assets/defaultLocale-BFoDCU3G.js +1 -0
  87. package/web/assets/diagram-S7CK7UJ4-5avxKhVt.js +30 -0
  88. package/web/assets/diagram-UQ7AKVKN-Dre36bGM.js +41 -0
  89. package/web/assets/diagram-VSXAHHWV-DiIGVyBf.js +3 -0
  90. package/web/assets/diagram-VX7I27RA-J24keGuv.js +24 -0
  91. package/web/assets/diagram-Z3DM3KII-CO6mpfCn.js +24 -0
  92. package/web/assets/dist-v5Q1xZ2K.js +1 -0
  93. package/web/assets/ebnfDiagram-PWID7BFC-Cj8nHw-p.js +1 -0
  94. package/web/assets/erDiagram-RLTQ6QDP-DCnatJWn.js +99 -0
  95. package/web/assets/eventmodeling-NTZA5JFV-CUmkoHPv.js +1 -0
  96. package/web/assets/flowDiagram-HODETNUW-CAMIZvHT.js +1 -0
  97. package/web/assets/ganttDiagram-EL5Y4UJY-CnoyY5yJ.js +292 -0
  98. package/web/assets/gitGraph-4MIJSDKK-5QxKBdhV.js +1 -0
  99. package/web/assets/gitGraphDiagram-WWUBYQGX-m72whYP5.js +106 -0
  100. package/web/assets/graphlib-DS17s2tU.js +1 -0
  101. package/web/assets/index-D36pVYmX.css +1 -0
  102. package/web/assets/index-DiYWjsXW.js +3 -0
  103. package/web/assets/info-A6RAGUB7-D0yTLuOD.js +1 -0
  104. package/web/assets/infoDiagram-27XIBGKW-DhMqaJK2.js +2 -0
  105. package/web/assets/init-C-OQMol4.js +1 -0
  106. package/web/assets/ishikawaDiagram-5VMMS53U-D43D69Lw.js +70 -0
  107. package/web/assets/journeyDiagram-3NMN7TZE-BGvdefN8.js +139 -0
  108. package/web/assets/jsx-runtime-NZYk81nU.js +1 -0
  109. package/web/assets/kanban-definition-UXKFOSKX-DTLCjXTM.js +89 -0
  110. package/web/assets/katex-ZlcWpGUi.js +257 -0
  111. package/web/assets/line-D3e4bvOi.js +1 -0
  112. package/web/assets/linear-SzVmGSw4.js +1 -0
  113. package/web/assets/mermaid-parser.core-hF3j4BfN.js +7 -0
  114. package/web/assets/mermaid.core-Dt4GvTcF.js +44 -0
  115. package/web/assets/mindmap-definition-YA3MSWOX-kGjt1kFy.js +96 -0
  116. package/web/assets/mount-BjlUftih.css +1 -0
  117. package/web/assets/mount-DFFc-s3e.js +27 -0
  118. package/web/assets/ordinal-BDEzSJ7C.js +1 -0
  119. package/web/assets/packet-AYTQ26CC-uWunEOCP.js +1 -0
  120. package/web/assets/path-fybaL0A-.js +1 -0
  121. package/web/assets/pegDiagram-XKGWAZYB-DHUvSUTF.js +1 -0
  122. package/web/assets/pie-WAS4IAKB-CDjHTH5e.js +1 -0
  123. package/web/assets/pieDiagram-E7YTZNPT-BXmHg_oo.js +39 -0
  124. package/web/assets/quadrantDiagram-AXDQQJYC-wrGDXaUj.js +7 -0
  125. package/web/assets/radar-RG4KPBEZ-iriLOEHZ.js +1 -0
  126. package/web/assets/railroad-74A4TZTK-Bji_D229.js +1 -0
  127. package/web/assets/railroad-abnf-HS5TGJTU-C2rjIz3_.js +1 -0
  128. package/web/assets/railroad-ebnf-LZEXJU2U-D1IwbAAk.js +1 -0
  129. package/web/assets/railroad-peg-WCYAUIDC-D6AkTvV0.js +1 -0
  130. package/web/assets/railroadDiagram-O6MQD6OU-DaMvYvYd.js +1 -0
  131. package/web/assets/requirementDiagram-BXWQKSXE-CSk6pyHt.js +84 -0
  132. package/web/assets/rolldown-runtime-hePW80VL.js +1 -0
  133. package/web/assets/rough.esm-Dy-Kn_BL.js +1 -0
  134. package/web/assets/sankeyDiagram-P5KCCOFB-CDYh3o0x.js +40 -0
  135. package/web/assets/sequenceDiagram-WJ2MYXX4-Cebdnnr3.js +162 -0
  136. package/web/assets/settingsRoute-D0Hlh8NA.js +1 -0
  137. package/web/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  138. package/web/assets/src-B6xuSHsQ.js +1 -0
  139. package/web/assets/stateDiagram-D77RDMKH-B9UCBm56.js +1 -0
  140. package/web/assets/stateDiagram-v2-MP3YSRHH-Btx-OjtR.js +1 -0
  141. package/web/assets/swimlanes-42K2YHIH-zH5kmbnV.js +1 -0
  142. package/web/assets/swimlanesDiagram-VR7AAH4N-CZInbEeM.js +8 -0
  143. package/web/assets/timeline-definition-24CTP7MA-D2tCs5Ws.js +120 -0
  144. package/web/assets/treeView-Q6P3EWNA-BCo4q2Xs.js +1 -0
  145. package/web/assets/treemap-WGGIJYW6-C2Xuxw-p.js +1 -0
  146. package/web/assets/useStore-DECYZPZP.js +27 -0
  147. package/web/assets/vennDiagram-4TSXK5OY-CMD5TuHM.js +34 -0
  148. package/web/assets/wardley-WFR3VGLG-0MDv_diU.js +1 -0
  149. package/web/assets/wardleyDiagram-VM6X3IG4-BOQSNZzn.js +78 -0
  150. package/web/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
  151. package/web/assets/xychartDiagram-S5SC5T6Z-B79lzllh.js +7 -0
  152. package/web/fonts/OFL.txt +90 -0
  153. package/web/fonts/newsreader-italic.woff2 +0 -0
  154. package/web/fonts/newsreader-roman.woff2 +0 -0
  155. package/web/icon-192.png +0 -0
  156. package/web/icon-512.png +0 -0
  157. package/web/icon.svg +16 -0
  158. package/web/index.html +36 -0
  159. package/web/manifest.webmanifest +1 -0
  160. package/web/runtime-config.js +3 -0
  161. package/web/sw.js +1 -0
  162. package/web/tent-logo.png +0 -0
package/README.md CHANGED
@@ -11,6 +11,11 @@ Pi, OpenCode and other agents on your own machine. Reach them from a browser or
11
11
  your phone, with the live session, its terminal, its approvals, and the app
12
12
  they're building right in the chat. Try it, mark what's wrong, and send it back.
13
13
 
14
+ <p align="center">
15
+ <img src="docs/images/preview-feedback-loop.gif" width="360"
16
+ alt="On a phone: open the app an agent built, circle the packed items, send a note, and compare the result before and after the agent's fix.">
17
+ </p>
18
+
14
19
  Bivy is not another coding agent and not a cloud development machine. The agent
15
20
  stays local and does the coding with your model provider. Your repos, tools,
16
21
  databases, and services stay where they are. Bivy gives you remote access to all
@@ -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,8 +40,10 @@ 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";
44
+ import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
43
45
  import { resolveAttachSessionId } from "./attach-session-id.mjs";
44
- import { detectInstallKind as classifyInstallKind, npmGlobalPrefix } from "./install-kind.mjs";
46
+ import { detectInstallKind as classifyInstallKind, managedInstall, npmGlobalPrefix } from "./install-kind.mjs";
45
47
  import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
46
48
  import { COMMANDS, EXIT, cliError, completionWords, describeCli, describeCommand, describeTools, renderHelp, resolveCommand, subcommandTable, suggestCommands, toolArgv, wantsJson } from "./cli-commands.mjs";
47
49
 
@@ -76,6 +78,7 @@ process.env.BIVY_DATA_DIR = appDir;
76
78
  // - "npx" ephemeral `npx bivy` run (repoRoot under an npm _npx cache)
77
79
  // - "npm-global" `npm i -g @bivy/bivy` (repoRoot is below node_modules/@bivy)
78
80
  // - "packaged" install.sh tarball tree (user-owned, self-preserving)
81
+ // - "managed" a system package (the AUR's `bivy`), updated by its manager
79
82
  function detectInstallKind() {
80
83
  return classifyInstallKind(repoRoot);
81
84
  }
@@ -2132,6 +2135,31 @@ async function fetchJson(baseUrl, pathName, token) {
2132
2135
  // --json machine-readable list, no prompt
2133
2136
  // --limit/-n N cap how many saved sessions to show (default: unlimited — all of them)
2134
2137
  // <n> | <id> select non-interactively (index in the list, or a session id)
2138
+ // `bivy tui` — every session on this machine and your direct nodes in one
2139
+ // terminal view: live transcripts, approvals, messages to the agent.
2140
+ async function cmdTui(args = []) {
2141
+ if (args.includes("-h") || args.includes("--help")) {
2142
+ console.log("Usage: bivy tui\n\nA terminal view of the sessions on this machine and on the nodes you added with 'bivy nodes add': live transcripts, approvals and messages to the agent, all from the keyboard. Press ? inside for keys.");
2143
+ return;
2144
+ }
2145
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2146
+ console.error(c.red("bivy tui needs an interactive terminal."));
2147
+ process.exitCode = EXIT.usage;
2148
+ return;
2149
+ }
2150
+ const config = loadConfig();
2151
+ if (!(await ensureNodeRunning(config))) {
2152
+ console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
2153
+ process.exitCode = EXIT.unavailable;
2154
+ return;
2155
+ }
2156
+ const token = await localDeviceToken(config);
2157
+ const info = await fetchJson(url(config), "/api/node/info", token).catch(() => ({}));
2158
+ const direct = Object.entries(loadNodes().nodes).map(([name, node]) => ({ name, base: node.url, token: node.token }));
2159
+ const { runTui } = await import("./tui/app.mjs");
2160
+ await runTui({ machines: [{ name: info?.name || "this machine", base: url(config), token }, ...direct] });
2161
+ }
2162
+
2135
2163
  async function cmdSessions(args = [], opts = {}) {
2136
2164
  if (args.includes("-h") || args.includes("--help")) {
2137
2165
  console.log(
@@ -2521,18 +2549,18 @@ async function cmdContext(args = []) {
2521
2549
  console.log(`\nEverything else: ${c.cyan("bivy help")} (or ${c.cyan("bivy help --json")}).`);
2522
2550
  }
2523
2551
 
2524
- // `bivy suggest "<task>" [--title "…"] [--session <id>]` — propose a task the
2525
- // user can start in one tap, beside this session or in it. For an agent
2552
+ // `bivy suggest "<task>" [--title "…"] [--run here|subagents|new] [--session <id>]`
2553
+ // — propose a task the user can start in one tap, in this session or beside it. For an agent
2526
2554
  // offering next steps: write the task as a complete instruction.
2527
2555
  async function cmdSuggest(args = []) {
2528
- const usage = 'Usage: bivy suggest "<task>" [--title "short label"] [--session <id>] [--json]';
2556
+ const usage = 'Usage: bivy suggest "<task>" [--title "short label"] [--run here|subagents|new] [--session <id>] [--json]';
2529
2557
  if (args.includes("-h") || args.includes("--help")) {
2530
- console.log(`${usage}\n\nPost a task the user can start in one tap, in this session or in a parallel one that works in its own copy of the project. Write it as a complete instruction, with paths relative to the project root. --json prints {"ok","id"}.`);
2558
+ console.log(`${usage}\n\nPost a task the user can start in one tap: in this session, through your sub-agents, or in a parallel session that works in its own copy of the project. Write it as a complete instruction, with paths relative to the project root.\n\n--run says where you recommend running it, which becomes the card's main button: 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 the user will want to follow in its own session). Without it, one card recommends here and several recommend new.\n\n--json prints {"ok","id"}.`);
2531
2559
  return;
2532
2560
  }
2533
2561
  const json = wantsJson(args);
2534
2562
  const fail = (error) => cliError(error, { json, paint: c.red });
2535
- const flagsWithValue = new Set(["--session", "--title"]);
2563
+ const flagsWithValue = new Set(["--session", "--title", "--run"]);
2536
2564
  const flag = (name) => {
2537
2565
  const i = args.indexOf(name);
2538
2566
  return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
@@ -2540,10 +2568,12 @@ async function cmdSuggest(args = []) {
2540
2568
  const text = args.filter((a, i) => !a.startsWith("-") && !(i > 0 && flagsWithValue.has(args[i - 1]))).join(" ").trim();
2541
2569
  const sessionId = resolveAttachSessionId({ sessionFlag: flag("--session"), env: process.env });
2542
2570
  if (!text) return fail({ code: "usage", message: usage, exit: EXIT.usage });
2571
+ const run = flag("--run");
2572
+ if (run !== undefined && !["here", "subagents", "new"].includes(run)) return fail({ code: "usage", message: `--run is here, subagents or new (got "${run}").`, exit: EXIT.usage });
2543
2573
  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 });
2544
2574
  const config = loadConfig();
2545
2575
  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 });
2546
- const res = await sessionPost(config, sessionId, "suggest", { text, title: flag("--title") }).catch((error) => error);
2576
+ const res = await sessionPost(config, sessionId, "suggest", { text, title: flag("--title"), run }).catch((error) => error);
2547
2577
  if (res instanceof Error) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable });
2548
2578
  const body = await res.json().catch(() => ({}));
2549
2579
  if (!res.ok) return fail(sessionHttpError("Suggest", res.status, body));
@@ -2551,6 +2581,30 @@ async function cmdSuggest(args = []) {
2551
2581
  console.log(c.green("Suggested in the chat. The user can start it in one tap."));
2552
2582
  }
2553
2583
 
2584
+ // `bivy title "<title>" [--session <id>]` — rename the session the agent runs in.
2585
+ async function cmdTitle(args = []) {
2586
+ const usage = 'Usage: bivy title "<title>" [--session <id>] [--json]';
2587
+ if (args.includes("-h") || args.includes("--help")) {
2588
+ 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"}.`);
2589
+ return;
2590
+ }
2591
+ const json = wantsJson(args);
2592
+ const fail = (error) => cliError(error, { json, paint: c.red });
2593
+ const sessionFlag = args.indexOf("--session");
2594
+ const title = args.filter((a, i) => !a.startsWith("-") && !(sessionFlag >= 0 && i === sessionFlag + 1)).join(" ").trim();
2595
+ const sessionId = resolveAttachSessionId({ sessionFlag: sessionFlag >= 0 ? args[sessionFlag + 1] : undefined, env: process.env });
2596
+ if (!title) return fail({ code: "usage", message: usage, exit: EXIT.usage });
2597
+ 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 });
2598
+ const config = loadConfig();
2599
+ 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 });
2600
+ const res = await sessionPost(config, sessionId, "title", { title }).catch((error) => error);
2601
+ if (res instanceof Error) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable });
2602
+ const body = await res.json().catch(() => ({}));
2603
+ if (!res.ok) return fail(sessionHttpError("Title", res.status, body));
2604
+ if (json) { console.log(JSON.stringify(body)); return; }
2605
+ console.log(c.green(`Renamed this session to "${body.title}".`));
2606
+ }
2607
+
2554
2608
  // Flags shared by the session commands: `--name value` pairs and bare switches,
2555
2609
  // with every other word collected as positional text.
2556
2610
  function parseSessionArgs(args, valueFlags) {
@@ -4474,6 +4528,8 @@ async function cmdTokenSetup() {
4474
4528
  }
4475
4529
 
4476
4530
  async function cmdSetup(args = []) {
4531
+ // Set by the access question; `bivy tailscale` runs once the node is up.
4532
+ let wantTailscale = false;
4477
4533
  if (args.includes("-h") || args.includes("--help")) {
4478
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.");
4479
4535
  return;
@@ -4581,18 +4637,22 @@ async function cmdSetup(args = []) {
4581
4637
  // (BIVY_CONTROL_PLANE_URL / BIVY_RELAY_URL then pre-fill the URL prompts below).
4582
4638
  const nodeClaimCode = process.env.BIVY_NODE_CLAIM_CODE?.trim();
4583
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;
4584
4642
  const syncChoice = nodeClaimCode ? "h" : await askChoice(
4585
- "Remote access",
4643
+ "How will you reach this machine?",
4586
4644
  [
4587
- { key: "h", label: "hosted (recommended — sign in with GitHub or email; nothing caps your local usage)" },
4588
- { key: "s", label: "self-hosted (your own control plane + relay)" },
4589
- { 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')" },
4590
4649
  ],
4591
4650
  selfHostEnv ? "s" : "h",
4592
4651
  );
4652
+ wantTailscale = syncChoice === "t";
4593
4653
  const relayArgs = [];
4594
- if (syncChoice === "l") {
4595
- 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.`));
4596
4656
  }
4597
4657
  if (syncChoice === "s") {
4598
4658
  const endpoints = await getHostedEndpoints();
@@ -4602,7 +4662,7 @@ async function cmdSetup(args = []) {
4602
4662
  if (relayWs.trim()) relayArgs.push("--relay", relayWs.trim());
4603
4663
  }
4604
4664
 
4605
- if (syncChoice !== "l") {
4665
+ if (syncChoice !== "l" && syncChoice !== "t") {
4606
4666
  if (!nodeClaimCode) {
4607
4667
  const loginChoice = await askChoice(
4608
4668
  "Remote login",
@@ -4651,7 +4711,7 @@ async function cmdSetup(args = []) {
4651
4711
  setupSession = consumeSetupSession();
4652
4712
  }
4653
4713
  } else {
4654
- 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."));
4655
4715
  }
4656
4716
 
4657
4717
  // GitHub App — connect it from the web app (Settings → GitHub App) or with
@@ -4690,6 +4750,19 @@ async function cmdSetup(args = []) {
4690
4750
  }
4691
4751
  }
4692
4752
 
4753
+ // The terminal path to the same loop: the agent's own command starts a Bivy
4754
+ // session, so work begun in a terminal shows up in the app and reaches the
4755
+ // phone. Offered after sign-in, which runs the real agent.
4756
+ const shimAgent = setupAgent?.command;
4757
+ if (agentReady && shimAgent && AGENT_INTEGRATIONS.has(shimAgent) && !loadShims().shims[shimAgent]) {
4758
+ console.log(`\nWhen you type '${shimAgent}' in a terminal, Bivy can run it as a session: it shows up in the app, and your phone hears when it finishes or needs you.`);
4759
+ if (await askYesNo(`Start a Bivy session whenever you run '${shimAgent}'?`, true)) {
4760
+ rl.pause();
4761
+ await run(nodeBin, [selfScript, "shim", "install", shimAgent], { env: process.env });
4762
+ rl.resume();
4763
+ }
4764
+ }
4765
+
4693
4766
  // Materialize canonical typed config before starting/restarting the daemon,
4694
4767
  // so this setup run's workspace/port/agent choices are effective immediately.
4695
4768
  // Existing config keeps every advanced field.
@@ -4731,6 +4804,7 @@ async function cmdSetup(args = []) {
4731
4804
  ? Boolean(liveReadiness?.credential?.ok ?? hasModelConfig(finalConfig))
4732
4805
  : agentAuthReady;
4733
4806
  const repoReady = Boolean(liveReadiness?.repository?.ok);
4807
+ 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.`)));
4734
4808
  console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
4735
4809
  console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
4736
4810
  console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
@@ -4739,7 +4813,7 @@ async function cmdSetup(args = []) {
4739
4813
  const ghReady = githubConnected(finalConfig);
4740
4814
  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")}`);
4741
4815
  console.log(` ${agentReady && modelReady && repoReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady && repoReady ? "ready to try" : "blocked by the stage above"}`);
4742
- console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
4816
+ 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`);
4743
4817
  // Get the user into the product immediately; terminal commands are the
4744
4818
  // fallback/next-step checklist after the remote app has been opened or linked.
4745
4819
  await finishSetupRemote(finalConfig, setupSession);
@@ -4926,8 +5000,10 @@ async function cmdStatus(args = []) {
4926
5000
  const relay = loadRelayConfig();
4927
5001
  const relaySt = status?.relay;
4928
5002
  const relayConfigured = Boolean(relaySt?.configured || relay);
5003
+ const access = reachable ? await fetchAccess(config).catch(() => null) : null;
5004
+ if (access) console.log(` access: ${accessLine(access)}`);
4929
5005
  if (!relayConfigured) {
4930
- console.log(` remote: ${c.dim("local only")} ${c.dim("('bivy relay:setup' to enable remote access)")}`);
5006
+ if (!access) console.log(` remote: ${c.dim("this machine only")} ${c.dim("('bivy access' to see the options)")}`);
4931
5007
  } else {
4932
5008
  // The live link state is only knowable when the node is running; if it's
4933
5009
  // down we can say it's configured but not whether it's currently connected.
@@ -5122,7 +5198,7 @@ async function cmdDoctor(args = []) {
5122
5198
  const relayApp = status?.relay?.controlPlaneUrl;
5123
5199
  const relayErr = status?.relay?.lastError;
5124
5200
  const relayLine = !relayConfigured
5125
- ? c.dim("local only — 'bivy relay:setup' to enable")
5201
+ ? c.dim("this machine only — 'bivy access' to see the options")
5126
5202
  : relayConnected
5127
5203
  ? c.green("relay connected") + (relayApp ? c.dim(` ${relayApp}`) : "")
5128
5204
  : c.yellow("configured, not connected") + (relayErr ? c.dim(` (${relayErr})`) : "");
@@ -5287,6 +5363,13 @@ async function cmdUpdate(args = []) {
5287
5363
  console.log("Usage: bivy update [--force|--no-wait] [--staging|--stable|--channel <name>]\n\nUpdate Bivy + install deps + restart service. Stays on the release channel recorded at install time (default 'latest'); --staging/--stable/--channel switch channels and are remembered for future updates. Waits for active sessions to finish a turn first; --force/--no-wait skips the wait. See 'bivy update:log' for the last run's output.");
5288
5364
  return;
5289
5365
  }
5366
+ const managed = managedInstall(repoRoot);
5367
+ if (managed) {
5368
+ console.log(`Bivy is installed by ${managed.manager} here, so it updates through it${managed.update ? `: ${c.cyan(managed.update)}` : "."}`);
5369
+ console.log(c.dim("Then run 'bivy restart' to pick up the new version."));
5370
+ process.exitCode = EXIT.failed;
5371
+ return;
5372
+ }
5290
5373
  // Inside a Bivy web/PWA terminal the shell is a child of the node's own
5291
5374
  // process, so `restartService()` — the final step of an update — tears down
5292
5375
  // this very terminal. Run inline and you lose all output the moment it lands.
@@ -5671,6 +5754,193 @@ async function cmdLinkPhone(args = []) {
5671
5754
  }
5672
5755
  }
5673
5756
 
5757
+ const tailscaleConfigPath = path.join(appDir, "tailscale.json");
5758
+ const LOCAL_TOKEN_NAMES = ["Bivy CLI", "Local Remote UI"];
5759
+
5760
+ function runTailscale(args) {
5761
+ const res = spawnSync("tailscale", args, { encoding: "utf8" });
5762
+ if (res.error?.code === "ENOENT") return null;
5763
+ return { ok: res.status === 0, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
5764
+ }
5765
+
5766
+ async function printPairLink(config) {
5767
+ const data = await localApi(config, "/api/auth/pair-codes", { method: "POST", body: "{}" });
5768
+ if (!data?.url) throw new Error("The node did not return a pairing link.");
5769
+ const qr = terminalQr(data.url);
5770
+ if (qr) console.log(qr);
5771
+ console.log(`\nOpen on your phone (single use, 10 minutes):\n${c.cyan(data.url)}\n`);
5772
+ }
5773
+
5774
+ // `bivy tailscale` — reach this machine from anywhere on your tailnet with no
5775
+ // control plane or relay: `tailscale serve` puts HTTPS on the machine's ts.net
5776
+ // name and forwards it to the node's direct listener, which serves the web app.
5777
+ async function cmdTailscale(args = []) {
5778
+ const [verb = "on"] = args;
5779
+ if (args.includes("-h") || args.includes("--help") || !["on", "pair", "status", "devices", "revoke", "off"].includes(verb)) {
5780
+ console.log(`Usage: bivy tailscale [on|pair|status|devices|revoke <id>|off]
5781
+
5782
+ on Serve this machine's Bivy on https://<machine>.<tailnet>.ts.net (tailnet only) and show a pairing link
5783
+ pair Show a new one-time pairing link for another device
5784
+ status Show the Tailscale address and whether it is served
5785
+ devices List the devices holding a token for this node
5786
+ revoke <id> Revoke a device's token and disconnect it
5787
+ off Stop serving Bivy over Tailscale`);
5788
+ return;
5789
+ }
5790
+ const config = loadConfig();
5791
+ if (!(await isReachable(config))) {
5792
+ console.log(c.yellow(`The node is not reachable at ${url(config)}. Start it first: ${c.cyan(config.service ? "bivy restart" : "bivy start")}`));
5793
+ process.exitCode = EXIT.unavailable;
5794
+ return;
5795
+ }
5796
+ const saved = (() => { try { return JSON.parse(fs.readFileSync(tailscaleConfigPath, "utf8")); } catch { return null; } })();
5797
+
5798
+ if (verb === "pair") {
5799
+ if (!saved) { console.log(`Tailscale access is off. Turn it on with ${c.cyan("bivy tailscale")}.`); process.exitCode = EXIT.failed; return; }
5800
+ await printPairLink(config);
5801
+ return;
5802
+ }
5803
+ if (verb === "devices") {
5804
+ // The CLI and loopback web client mint their own tokens on this machine; list them as a count.
5805
+ const all = (await localApi(config, "/api/auth/devices")) ?? [];
5806
+ const devices = all.filter((device) => !LOCAL_TOKEN_NAMES.includes(device.name));
5807
+ for (const device of devices) console.log(`${c.cyan(device.id)} ${device.name}${device.createdAt ? c.dim(` paired ${device.createdAt}`) : ""}`);
5808
+ if (!devices.length) console.log("No paired devices.");
5809
+ if (all.length > devices.length) console.log(c.dim(`(+${all.length - devices.length} tokens used on this machine by the CLI)`));
5810
+ return;
5811
+ }
5812
+ if (verb === "revoke") {
5813
+ const id = args[1];
5814
+ if (!id) { console.log("Usage: bivy tailscale revoke <id> (ids from 'bivy tailscale devices')"); process.exitCode = EXIT.usage; return; }
5815
+ const res = await fetch(`${url(config)}/api/auth/devices/${encodeURIComponent(id)}`, { method: "DELETE", headers: { authorization: `Bearer ${await localDeviceToken(config)}` } });
5816
+ if (!res.ok) { console.log(c.yellow(`No device ${id}.`)); process.exitCode = EXIT.notFound; return; }
5817
+ console.log(`${c.green("✓")} Revoked ${id}.`);
5818
+ return;
5819
+ }
5820
+ if (verb === "status") {
5821
+ 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")}.`);
5822
+ return;
5823
+ }
5824
+ if (verb === "off") {
5825
+ if (saved) {
5826
+ const state = serveState(runTailscale(["serve", "status", "--json"])?.stdout, saved.hostname);
5827
+ if (state.target === `http://127.0.0.1:${saved.port}`) runTailscale(serveOffArgs());
5828
+ }
5829
+ fs.rmSync(tailscaleConfigPath, { force: true });
5830
+ await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5831
+ 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>'.`);
5832
+ return;
5833
+ }
5834
+
5835
+ const status = runTailscale(["status", "--json"]);
5836
+ if (!status) {
5837
+ 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.`);
5838
+ process.exitCode = EXIT.unavailable;
5839
+ return;
5840
+ }
5841
+ const { running, hostname, owner } = parseTailscaleStatus(status.stdout);
5842
+ if (!running || !hostname) {
5843
+ console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
5844
+ process.exitCode = EXIT.unavailable;
5845
+ return;
5846
+ }
5847
+ const port = saved?.port ?? await findAvailablePort(config.port + 2, "127.0.0.1");
5848
+ const conflict = serveConflict(serveState(runTailscale(["serve", "status", "--json"])?.stdout, hostname), port);
5849
+ if (conflict) {
5850
+ console.log(c.yellow(conflict));
5851
+ process.exitCode = EXIT.conflict;
5852
+ return;
5853
+ }
5854
+ fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname, ...(owner ? { owner } : {}) }, null, 2)}\n`, { mode: 0o600 });
5855
+ const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5856
+ const served = runTailscale(serveOnArgs(port));
5857
+ if (!served?.ok) {
5858
+ fs.rmSync(tailscaleConfigPath, { force: true });
5859
+ await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" }).catch(() => {});
5860
+ console.error(c.red(`tailscale serve failed: ${(served?.stderr || served?.stdout || "").trim()}`));
5861
+ const hint = serveFailureHint(served?.stderr);
5862
+ if (hint) console.log(hint);
5863
+ process.exitCode = EXIT.failed;
5864
+ return;
5865
+ }
5866
+ console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
5867
+ console.log(c.dim(owner
5868
+ ? ` 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'.`
5869
+ : " Only devices on your tailnet can reach it. This machine is tagged, so each device pairs once with a link from 'bivy tailscale pair'."));
5870
+ 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."));
5871
+ console.log("");
5872
+ if (owner) {
5873
+ console.log(`Open ${c.cyan(`https://${hostname}`)} on any of your devices.\n`);
5874
+ return;
5875
+ }
5876
+ await printPairLink(config);
5877
+ }
5878
+
5879
+ async function fetchAccess(config) {
5880
+ return localApi(config, "/api/access/get", { method: "POST", body: "{}" });
5881
+ }
5882
+
5883
+ // `bivy access` — how this machine can be reached (this machine only,
5884
+ // Tailscale, Bivy hosted, your own server), what that gives you, and the next
5885
+ // step. The node owns the table (src/access.ts); this only renders and routes.
5886
+ async function cmdAccess(args = []) {
5887
+ const [verb, target] = args.filter((a) => !a.startsWith("-"));
5888
+ if (args.includes("-h") || args.includes("--help") || (verb && !["local", "tailscale", "hosted", "server"].includes(verb))) {
5889
+ console.log(`Usage: bivy access [--json]
5890
+ bivy access tailscale reach this machine from your devices over Tailscale
5891
+ bivy access hosted sign in to Bivy hosted: every machine, from anywhere
5892
+ bivy access server <url> [--relay <wss-url>] the same, through your own server
5893
+ bivy access local turn remote access off
5894
+
5895
+ Setups stack: Tailscale can stay on alongside hosted or your own server.`);
5896
+ if (verb && !["local", "tailscale", "hosted", "server"].includes(verb)) process.exitCode = EXIT.usage;
5897
+ return;
5898
+ }
5899
+ const config = loadConfig();
5900
+ if (!(await ensureNodeRunning(config))) {
5901
+ console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
5902
+ process.exitCode = EXIT.unavailable;
5903
+ return;
5904
+ }
5905
+ const report = await fetchAccess(config);
5906
+
5907
+ if (!verb) {
5908
+ if (wantsJson(args)) { console.log(JSON.stringify(report, null, 2)); return; }
5909
+ console.log(`\n${accessScreen(report, c).join("\n")}\n`);
5910
+ return;
5911
+ }
5912
+ if (verb === "tailscale") return cmdTailscale(["on"]);
5913
+ if (verb === "hosted" || verb === "server") {
5914
+ const linked = report.setups.find((s) => s.active && (s.id === "hosted" || s.id === "server"));
5915
+ if (linked && linked.id === verb && verb === "hosted") {
5916
+ console.log(`Already on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}.`);
5917
+ return;
5918
+ }
5919
+ 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.`));
5920
+ if (verb === "hosted") return cmdRelaySetup([]);
5921
+ if (!target) {
5922
+ 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.`);
5923
+ process.exitCode = EXIT.usage;
5924
+ return;
5925
+ }
5926
+ let appUrl;
5927
+ try { appUrl = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`); } catch { appUrl = null; }
5928
+ if (!appUrl) { console.error(c.red(`Not a URL: ${target}`)); process.exitCode = EXIT.usage; return; }
5929
+ // Bivy's server installs serve the relay at /relay on the app's own domain.
5930
+ const relayUrl = argValue(args, "relay") || `${appUrl.protocol === "http:" ? "ws" : "wss"}://${appUrl.host}/relay`;
5931
+ return cmdRelaySetup(["--control-plane", appUrl.origin, "--relay", relayUrl]);
5932
+ }
5933
+ // local: turn off every remote setup that is on.
5934
+ const on = report.setups.filter((s) => s.active && s.id !== "local");
5935
+ if (!on.length) { console.log("Already this machine only."); return; }
5936
+ const rl = createPrompter();
5937
+ const yes = await rl.askYesNo(`Turn off ${on.map((s) => s.label).join(" and ")}? Sessions and credentials on this machine stay.`, false);
5938
+ rl.close();
5939
+ if (!yes) return;
5940
+ if (on.some((s) => s.id === "tailscale")) await cmdTailscale(["off"]);
5941
+ if (on.some((s) => s.id === "hosted" || s.id === "server")) await cmdAccountLogout([]);
5942
+ }
5943
+
5674
5944
  function printUninstallHelp() {
5675
5945
  console.log(`
5676
5946
  ${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
@@ -6085,6 +6355,9 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
6085
6355
  case "suggest":
6086
6356
  await cmdSuggest(args);
6087
6357
  break;
6358
+ case "title":
6359
+ await cmdTitle(args);
6360
+ break;
6088
6361
  case "notify":
6089
6362
  await cmdNotify(args);
6090
6363
  break;
@@ -6268,6 +6541,15 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
6268
6541
  case "link":
6269
6542
  await cmdLinkPhone(args);
6270
6543
  break;
6544
+ case "tui":
6545
+ await cmdTui(args);
6546
+ break;
6547
+ case "tailscale":
6548
+ await cmdTailscale(args);
6549
+ break;
6550
+ case "access":
6551
+ await cmdAccess(args);
6552
+ break;
6271
6553
  case "relay:setup":
6272
6554
  await cmdRelaySetup(args);
6273
6555
  break;
@@ -48,8 +48,10 @@ 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: "suggest", group: "session", scope: "session", json: true, usage: 'suggest "<task>" [--title "label"]', summary: "Propose a task the user can start in one tap",
52
- tools: [{ name: "suggest_task", description: "Propose a next step the user can start in one tap, in this session or in a parallel one 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." } }, argv: ["suggest", { arg: "task" }, { flag: "--title", from: "title" }] }] },
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" }] }] },
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",
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"],
54
56
  tools: [
55
57
  { name: "app_publish", description: "Give the user a live preview of something with a UI, from an app manifest file (web server port, static build, terminal, or desktop app). Run `bivy app --help` for the manifest format.", input: { manifest_path: { type: "string", description: "Path to the manifest JSON.", required: true } }, argv: ["app", "publish", { arg: "manifest_path" }] },
@@ -63,6 +65,7 @@ export const COMMANDS = [
63
65
  { name: "run", group: "sessions", scope: "node", usage: "run <agent> [--chat] [--name …] [--model …] [--node …] [--workspace …]", summary: "Run an agent (native CLI/TUI) as a session the app can see" },
64
66
  { name: "exec", group: "sessions", scope: "node", json: true, usage: 'exec "<prompt>" [--agent …] [--session …]', summary: "One-shot headless session: prints the answer to stdout" },
65
67
  { name: "send", group: "sessions", scope: "node", json: true, usage: 'send <id> "<message>"', summary: "Send a prompt to an existing session and stream the reply" },
68
+ { name: "tui", group: "sessions", scope: "node", usage: "tui", summary: "Sessions across your machines in the terminal: transcripts, approvals, messages" },
66
69
  { name: "sessions", aliases: ["ls"], group: "sessions", scope: "node", json: true, usage: "sessions [--json]", summary: "List recent sessions (live and saved) and resume one" },
67
70
  { name: "resume", group: "sessions", scope: "node", usage: "resume [n|id]", summary: "Resume a session directly (default: most recent)" },
68
71
  { name: "kill", group: "sessions", scope: "node", usage: "kill <id> [--delete]", summary: "Stop a session or terminal" },
@@ -103,6 +106,8 @@ export const COMMANDS = [
103
106
  { name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
104
107
  { name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
105
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"] },
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"] },
106
111
  { name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
107
112
  { name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
108
113
 
@@ -28,9 +28,25 @@ Ask only when you can't reasonably decide yourself.
28
28
 
29
29
  bivy suggest "Add a GET /version endpoint that returns the package version." --title "Add /version"
30
30
 
31
- Each suggestion is a card the user can start in one tap, here or in a parallel
32
- session with its own copy of the project. Write it as a complete instruction with
33
- paths relative to the project root. Post one per idea instead of a bulleted list.
31
+ Each suggestion is a card the user can start in one tap: here, through your
32
+ sub-agents, or in a parallel session with its own copy of the project. Write it as
33
+ a complete instruction with paths relative to the project root. Post one per idea
34
+ instead of a bulleted list.
35
+
36
+ `--run` picks the card's main button; the others stay one tap away:
37
+
38
+ | `--run` | When |
39
+ |---|---|
40
+ | `here` | It builds on this conversation, or it's small. Default for a single card. |
41
+ | `subagents` | Independent tasks you can split across your own sub-agents and report back on. Only if you have sub-agents. |
42
+ | `new` | Bigger independent work the user will want to follow, review or merge on its own. Default for several cards. |
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.
34
50
 
35
51
  ## Is anyone there?
36
52
 
@@ -3,9 +3,29 @@
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
5
 
6
+ /** The file a system package (pacman, …) writes into the install it owns. */
7
+ export const MANAGED_INSTALL_FILE = ".bivy-install.json";
8
+
9
+ /**
10
+ * `{ manager, update }` when a system package manager owns this install, so
11
+ * Bivy must not replace its files; null otherwise.
12
+ */
13
+ export function managedInstall(repoRoot, readFileSync = fs.readFileSync) {
14
+ try {
15
+ const data = JSON.parse(readFileSync(path.join(repoRoot, MANAGED_INSTALL_FILE), "utf8"));
16
+ if (typeof data?.manager === "string" && data.manager) {
17
+ return { manager: data.manager, update: typeof data.update === "string" ? data.update : "" };
18
+ }
19
+ } catch {
20
+ /* not managed */
21
+ }
22
+ return null;
23
+ }
24
+
6
25
  /** Classify a Bivy package root without assuming unscoped npm package layout. */
7
26
  export function detectInstallKind(repoRoot, existsSync = fs.existsSync) {
8
27
  if (existsSync(path.join(repoRoot, ".git"))) return "git";
28
+ if (existsSync(path.join(repoRoot, MANAGED_INSTALL_FILE))) return "managed";
9
29
 
10
30
  // npm installs an unscoped package at node_modules/name and a scoped package
11
31
  // at node_modules/@scope/name. Bivy uses the latter layout.