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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/bin/access-view.mjs +43 -0
  2. package/bin/bivy.mjs +135 -15
  3. package/bin/cli-commands.mjs +2 -0
  4. package/bin/install-kind.mjs +20 -0
  5. package/bin/tailscale.mjs +10 -3
  6. package/bin/tui/app.mjs +189 -0
  7. package/bin/tui/model.mjs +112 -0
  8. package/bin/tui/render.mjs +164 -0
  9. package/bin/tui/text.mjs +72 -0
  10. package/bin/tui/transcript.mjs +54 -0
  11. package/dist/access.js +72 -0
  12. package/dist/apps/styles.css +2 -1
  13. package/dist/apps/tokens.css +40 -0
  14. package/dist/auth.js +18 -4
  15. package/dist/machine-theme.js +118 -0
  16. package/dist/node-update.js +16 -0
  17. package/dist/protocol/client-command-routes.js +2 -0
  18. package/dist/protocol/client-command-schemas.js +2 -0
  19. package/dist/remote/direct.js +2 -1
  20. package/dist/remote/index.js +1 -0
  21. package/dist/remote/tailnet.js +44 -0
  22. package/dist/server.js +55 -3
  23. package/package.json +1 -1
  24. package/web/assets/AutomationsView-DjOv4IMn.js +51 -0
  25. package/web/assets/{ChangesCard-BzRQTCVs.js → ChangesCard-Bcd6mp08.js} +1 -1
  26. package/web/assets/{LibraryView-C6mluzPl.js → LibraryView-CiRErGG3.js} +1 -1
  27. package/web/assets/{ProviderConnect-BK6oEwlu.js → ProviderConnect-htTjSJzb.js} +1 -1
  28. package/web/assets/Settings-oBsZwFcG.js +10 -0
  29. package/web/assets/{Sheet-YqJCwCtR.js → Sheet-lYov8PLo.js} +1 -1
  30. package/web/assets/{Terminal-CWjLg3i2.js → Terminal-B9d4Ijmc.js} +1 -1
  31. package/web/assets/{VoiceSettings-DVlH29qG.js → VoiceSettings-BIADw4Ll.js} +1 -1
  32. package/web/assets/{abnfDiagram-VCTEODGH-DR5nREn3.js → abnfDiagram-VCTEODGH-gRoFYqU5.js} +1 -1
  33. package/web/assets/architecture-7GRP2DOG-BEJ5aLoK.js +1 -0
  34. package/web/assets/{architectureDiagram-5GKGNRK7-BTtzbbP7.js → architectureDiagram-5GKGNRK7-C4cNJboP.js} +1 -1
  35. package/web/assets/{blockDiagram-I7D4REHJ-B02cXkpO.js → blockDiagram-I7D4REHJ-DAICqtG4.js} +1 -1
  36. package/web/assets/{c4Diagram-7LVT6UL2-DuMEAvRa.js → c4Diagram-7LVT6UL2-DRswRDNz.js} +1 -1
  37. package/web/assets/channel-BPXvQ5Jt.js +1 -0
  38. package/web/assets/{chunk-4HAMMTFA-C3z4iXsh.js → chunk-4HAMMTFA-2joFftNJ.js} +1 -1
  39. package/web/assets/{chunk-75Z2AOVW-BaELqtNa.js → chunk-75Z2AOVW-XWOFp1H_.js} +1 -1
  40. package/web/assets/{chunk-DU6HZSFF-BBrnNwn_.js → chunk-DU6HZSFF-DS4Nz4nA.js} +1 -1
  41. package/web/assets/{chunk-F27PBJKO-Bfuzd8Q8.js → chunk-F27PBJKO-Dx_X9qdN.js} +1 -1
  42. package/web/assets/{chunk-GMAD6QVW-B0Np7gv0.js → chunk-GMAD6QVW-DxV6twrr.js} +1 -1
  43. package/web/assets/{chunk-GVQU2GXP-CiZeRFSW.js → chunk-GVQU2GXP-MSIDLTHB.js} +1 -1
  44. package/web/assets/{chunk-IMKFNOWR-DJGDP5h1.js → chunk-IMKFNOWR-CELsloet.js} +1 -1
  45. package/web/assets/{chunk-L3NEJ4N5-u67SBNSZ.js → chunk-L3NEJ4N5-BZ3ozIZf.js} +1 -1
  46. package/web/assets/{chunk-OSK3NFVY-DJUfOWEW.js → chunk-OSK3NFVY-BXjogSMs.js} +1 -1
  47. package/web/assets/{chunk-P2QGCYS3-CYmch4EI.js → chunk-P2QGCYS3-DFcISUhA.js} +1 -1
  48. package/web/assets/{chunk-POPQ4Y6H-BOjVOrGx.js → chunk-POPQ4Y6H-CMv8VcTR.js} +1 -1
  49. package/web/assets/{chunk-PWAF6VOD-ChmjsLTV.js → chunk-PWAF6VOD-Ck43wPZJ.js} +1 -1
  50. package/web/assets/{chunk-SHT3W25Y-BU57QSGG.js → chunk-SHT3W25Y-Dd3YYPpo.js} +1 -1
  51. package/web/assets/{chunk-SVP7TREG-3okF7bFJ.js → chunk-SVP7TREG-1wHIX2u8.js} +1 -1
  52. package/web/assets/{chunk-TICWLB2K-DervhtUR.js → chunk-TICWLB2K-C2RL-jix.js} +1 -1
  53. package/web/assets/classDiagram-ZZMXUADV-DicIinQk.js +1 -0
  54. package/web/assets/classDiagram-v2-VYDZK3BY-DicIinQk.js +1 -0
  55. package/web/assets/{cynefin-OW5HDTMX-BpkHeQ4U.js → cynefin-OW5HDTMX-L3cCx0-a.js} +1 -1
  56. package/web/assets/{cynefinDiagram-5FMLGOSQ-BY9V3rN7.js → cynefinDiagram-5FMLGOSQ-B-p9_QH8.js} +1 -1
  57. package/web/assets/{dagre-GXQ25YYZ-DAmjU9c2.js → dagre-GXQ25YYZ-CNcD81ab.js} +1 -1
  58. package/web/assets/{diagram-S7CK7UJ4-BE7RKay-.js → diagram-S7CK7UJ4-CLXtZ4aE.js} +1 -1
  59. package/web/assets/{diagram-UQ7AKVKN-CkizMHlG.js → diagram-UQ7AKVKN-Bj1-misl.js} +1 -1
  60. package/web/assets/{diagram-VSXAHHWV-BELxVnhK.js → diagram-VSXAHHWV-BsncRf1X.js} +1 -1
  61. package/web/assets/{diagram-VX7I27RA-C3wu2cVu.js → diagram-VX7I27RA-DgnUNiqR.js} +1 -1
  62. package/web/assets/{diagram-Z3DM3KII-CYX5IZ30.js → diagram-Z3DM3KII-CYA1FOfJ.js} +1 -1
  63. package/web/assets/{ebnfDiagram-PWID7BFC-CgyMDCw8.js → ebnfDiagram-PWID7BFC-EN37F-ZX.js} +1 -1
  64. package/web/assets/{erDiagram-RLTQ6QDP-CC1ZIoBl.js → erDiagram-RLTQ6QDP-7khCs6R0.js} +1 -1
  65. package/web/assets/eventmodeling-NTZA5JFV-DPlKwCgg.js +1 -0
  66. package/web/assets/flowDiagram-HODETNUW-CuyexHJ1.js +1 -0
  67. package/web/assets/{ganttDiagram-EL5Y4UJY-CLUlox-U.js → ganttDiagram-EL5Y4UJY-X2fCrUiA.js} +1 -1
  68. package/web/assets/{gitGraph-4MIJSDKK-B_yZBnXo.js → gitGraph-4MIJSDKK-C1jg4oXv.js} +1 -1
  69. package/web/assets/{gitGraphDiagram-WWUBYQGX-DNGTGoq6.js → gitGraphDiagram-WWUBYQGX-aRlKIG1q.js} +1 -1
  70. package/web/assets/index-BYhnjcv5.js +3 -0
  71. package/web/assets/index-Cik3irwJ.css +1 -0
  72. package/web/assets/{info-A6RAGUB7-O_QhqiG0.js → info-A6RAGUB7-knTxGQzP.js} +1 -1
  73. package/web/assets/{infoDiagram-27XIBGKW-CfBbmLPf.js → infoDiagram-27XIBGKW-CZ5eWH_5.js} +1 -1
  74. package/web/assets/{ishikawaDiagram-5VMMS53U-BJj25HdR.js → ishikawaDiagram-5VMMS53U-BaN7oiXe.js} +1 -1
  75. package/web/assets/{journeyDiagram-3NMN7TZE-D547gNvv.js → journeyDiagram-3NMN7TZE-DSKzoitB.js} +1 -1
  76. package/web/assets/{kanban-definition-UXKFOSKX-BziklmYw.js → kanban-definition-UXKFOSKX-Do-Ybw2Z.js} +1 -1
  77. package/web/assets/{line-CjB_oA8i.js → line-CwAjwUaG.js} +1 -1
  78. package/web/assets/{mermaid-parser.core-Bj5-yX0d.js → mermaid-parser.core-DgfSoRZF.js} +3 -3
  79. package/web/assets/{mermaid.core-CWsczu0T.js → mermaid.core-BmmLukUA.js} +4 -4
  80. package/web/assets/{mindmap-definition-YA3MSWOX-BqsBs-D4.js → mindmap-definition-YA3MSWOX-BvYA7HOJ.js} +1 -1
  81. package/web/assets/mount-YpsogRG1.js +26 -0
  82. package/web/assets/{packet-AYTQ26CC-B8U6sMom.js → packet-AYTQ26CC-z5xJrrsj.js} +1 -1
  83. package/web/assets/{pegDiagram-XKGWAZYB-BXV85Hj-.js → pegDiagram-XKGWAZYB-Dlnf2qqP.js} +1 -1
  84. package/web/assets/{pie-WAS4IAKB-CD71AvCo.js → pie-WAS4IAKB-Bcc2ZLgF.js} +1 -1
  85. package/web/assets/{pieDiagram-E7YTZNPT-DNq8_A1r.js → pieDiagram-E7YTZNPT-DK6Bm2ct.js} +1 -1
  86. package/web/assets/{quadrantDiagram-AXDQQJYC-ZNiXqFdJ.js → quadrantDiagram-AXDQQJYC-CGdNrgH7.js} +1 -1
  87. package/web/assets/{radar-RG4KPBEZ-CpvF1rs-.js → radar-RG4KPBEZ-DvDzdzlV.js} +1 -1
  88. package/web/assets/{railroad-74A4TZTK-BIfz4cd-.js → railroad-74A4TZTK-DAk4jBzt.js} +1 -1
  89. package/web/assets/railroad-abnf-HS5TGJTU-BboXzbI7.js +1 -0
  90. package/web/assets/railroad-ebnf-LZEXJU2U-2aWLf6tt.js +1 -0
  91. package/web/assets/railroad-peg-WCYAUIDC-DkEFbMwn.js +1 -0
  92. package/web/assets/{railroadDiagram-O6MQD6OU-Fao_FZJL.js → railroadDiagram-O6MQD6OU-qghivQgJ.js} +1 -1
  93. package/web/assets/{requirementDiagram-BXWQKSXE-QnxN_viD.js → requirementDiagram-BXWQKSXE-BB2VHwAe.js} +1 -1
  94. package/web/assets/{sankeyDiagram-P5KCCOFB-DDhcKN50.js → sankeyDiagram-P5KCCOFB-DYsK6FvP.js} +1 -1
  95. package/web/assets/{sequenceDiagram-WJ2MYXX4-bVK0aV05.js → sequenceDiagram-WJ2MYXX4-DN7_ECVu.js} +1 -1
  96. package/web/assets/settingsRoute-BryKVhsu.js +1 -0
  97. package/web/assets/{stateDiagram-D77RDMKH-CZiiW4Zy.js → stateDiagram-D77RDMKH-CMoUI-nz.js} +1 -1
  98. package/web/assets/stateDiagram-v2-MP3YSRHH-DVVMJFff.js +1 -0
  99. package/web/assets/{swimlanes-42K2YHIH-CYgOds43.js → swimlanes-42K2YHIH-Dg9Ds-zo.js} +1 -1
  100. package/web/assets/swimlanesDiagram-VR7AAH4N-CMp2P5bx.js +8 -0
  101. package/web/assets/{timeline-definition-24CTP7MA-CnVLFUDs.js → timeline-definition-24CTP7MA-B71AxnxY.js} +1 -1
  102. package/web/assets/{treeView-Q6P3EWNA-D6CxJQkK.js → treeView-Q6P3EWNA-CcYDvc71.js} +1 -1
  103. package/web/assets/{treemap-WGGIJYW6-DFCHLN8D.js → treemap-WGGIJYW6-COuLLLjA.js} +1 -1
  104. package/web/assets/useStore-CVAcdLfm.js +27 -0
  105. package/web/assets/{vennDiagram-4TSXK5OY-CX5aQZNu.js → vennDiagram-4TSXK5OY-Dyg63v_c.js} +1 -1
  106. package/web/assets/{wardley-WFR3VGLG-BCPDkPQh.js → wardley-WFR3VGLG-FAnOXgo0.js} +1 -1
  107. package/web/assets/{wardleyDiagram-VM6X3IG4-Cf0fwWD7.js → wardleyDiagram-VM6X3IG4-fhlKAloZ.js} +1 -1
  108. package/web/assets/{xychartDiagram-S5SC5T6Z-DalYEP3d.js → xychartDiagram-S5SC5T6Z-DbM80CJG.js} +1 -1
  109. package/web/index.html +2 -2
  110. package/web/sw.js +1 -1
  111. package/web/assets/AutomationsView-BJ3gBIGf.js +0 -51
  112. package/web/assets/Settings-CG236bpZ.js +0 -10
  113. package/web/assets/architecture-7GRP2DOG-Mvf9rpFA.js +0 -1
  114. package/web/assets/channel-BETA4C1-.js +0 -1
  115. package/web/assets/classDiagram-ZZMXUADV-DG1LzruI.js +0 -1
  116. package/web/assets/classDiagram-v2-VYDZK3BY-DG1LzruI.js +0 -1
  117. package/web/assets/eventmodeling-NTZA5JFV-YiXMtkrP.js +0 -1
  118. package/web/assets/flowDiagram-HODETNUW-UVK9K8aR.js +0 -1
  119. package/web/assets/index-D-0NDZis.js +0 -3
  120. package/web/assets/index-Dk7VU5lG.css +0 -1
  121. package/web/assets/mount-BlTXC3tw.js +0 -26
  122. package/web/assets/railroad-abnf-HS5TGJTU-oAj9W4Xw.js +0 -1
  123. package/web/assets/railroad-ebnf-LZEXJU2U-B5J5VpFh.js +0 -1
  124. package/web/assets/railroad-peg-WCYAUIDC-DpqDcjfx.js +0 -1
  125. package/web/assets/stateDiagram-v2-MP3YSRHH-B9Vt7f0v.js +0 -1
  126. package/web/assets/swimlanesDiagram-VR7AAH4N-CfED88ct.js +0 -8
  127. package/web/assets/useStore-DcIB7b1-.js +0 -27
@@ -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,9 +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";
43
44
  import { parseTailscaleStatus, serveConflict, serveFailureHint, serveOffArgs, serveOnArgs, serveState } from "./tailscale.mjs";
44
45
  import { resolveAttachSessionId } from "./attach-session-id.mjs";
45
- import { detectInstallKind as classifyInstallKind, npmGlobalPrefix } from "./install-kind.mjs";
46
+ import { detectInstallKind as classifyInstallKind, managedInstall, npmGlobalPrefix } from "./install-kind.mjs";
46
47
  import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
47
48
  import { COMMANDS, EXIT, cliError, completionWords, describeCli, describeCommand, describeTools, renderHelp, resolveCommand, subcommandTable, suggestCommands, toolArgv, wantsJson } from "./cli-commands.mjs";
48
49
 
@@ -77,6 +78,7 @@ process.env.BIVY_DATA_DIR = appDir;
77
78
  // - "npx" ephemeral `npx bivy` run (repoRoot under an npm _npx cache)
78
79
  // - "npm-global" `npm i -g @bivy/bivy` (repoRoot is below node_modules/@bivy)
79
80
  // - "packaged" install.sh tarball tree (user-owned, self-preserving)
81
+ // - "managed" a system package (the AUR's `bivy`), updated by its manager
80
82
  function detectInstallKind() {
81
83
  return classifyInstallKind(repoRoot);
82
84
  }
@@ -2133,6 +2135,31 @@ async function fetchJson(baseUrl, pathName, token) {
2133
2135
  // --json machine-readable list, no prompt
2134
2136
  // --limit/-n N cap how many saved sessions to show (default: unlimited — all of them)
2135
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
+
2136
2163
  async function cmdSessions(args = [], opts = {}) {
2137
2164
  if (args.includes("-h") || args.includes("--help")) {
2138
2165
  console.log(
@@ -4501,6 +4528,8 @@ async function cmdTokenSetup() {
4501
4528
  }
4502
4529
 
4503
4530
  async function cmdSetup(args = []) {
4531
+ // Set by the access question; `bivy tailscale` runs once the node is up.
4532
+ let wantTailscale = false;
4504
4533
  if (args.includes("-h") || args.includes("--help")) {
4505
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.");
4506
4535
  return;
@@ -4608,18 +4637,22 @@ async function cmdSetup(args = []) {
4608
4637
  // (BIVY_CONTROL_PLANE_URL / BIVY_RELAY_URL then pre-fill the URL prompts below).
4609
4638
  const nodeClaimCode = process.env.BIVY_NODE_CLAIM_CODE?.trim();
4610
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;
4611
4642
  const syncChoice = nodeClaimCode ? "h" : await askChoice(
4612
- "Remote access",
4643
+ "How will you reach this machine?",
4613
4644
  [
4614
- { key: "h", label: "hosted (recommended — sign in with GitHub or email; nothing caps your local usage)" },
4615
- { key: "s", label: "self-hosted (your own control plane + relay)" },
4616
- { 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')" },
4617
4649
  ],
4618
4650
  selfHostEnv ? "s" : "h",
4619
4651
  );
4652
+ wantTailscale = syncChoice === "t";
4620
4653
  const relayArgs = [];
4621
- if (syncChoice === "l") {
4622
- 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.`));
4623
4656
  }
4624
4657
  if (syncChoice === "s") {
4625
4658
  const endpoints = await getHostedEndpoints();
@@ -4629,7 +4662,7 @@ async function cmdSetup(args = []) {
4629
4662
  if (relayWs.trim()) relayArgs.push("--relay", relayWs.trim());
4630
4663
  }
4631
4664
 
4632
- if (syncChoice !== "l") {
4665
+ if (syncChoice !== "l" && syncChoice !== "t") {
4633
4666
  if (!nodeClaimCode) {
4634
4667
  const loginChoice = await askChoice(
4635
4668
  "Remote login",
@@ -4678,7 +4711,7 @@ async function cmdSetup(args = []) {
4678
4711
  setupSession = consumeSetupSession();
4679
4712
  }
4680
4713
  } else {
4681
- 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."));
4682
4715
  }
4683
4716
 
4684
4717
  // GitHub App — connect it from the web app (Settings → GitHub App) or with
@@ -4758,6 +4791,7 @@ async function cmdSetup(args = []) {
4758
4791
  ? Boolean(liveReadiness?.credential?.ok ?? hasModelConfig(finalConfig))
4759
4792
  : agentAuthReady;
4760
4793
  const repoReady = Boolean(liveReadiness?.repository?.ok);
4794
+ if (wantTailscale) await cmdTailscale(["on"]).catch((error) => console.log(c.yellow(`Tailscale setup didn't finish: ${error?.message || error}. Run 'bivy access tailscale' to retry.`)));
4761
4795
  console.log(c.bold(c.green("\n ✓ Node running. Check first-task readiness below.\n")));
4762
4796
  console.log(` ${c.green("✓")} node reachable at ${url(finalConfig)}`);
4763
4797
  console.log(` ${agentReady ? c.green("✓") : c.yellow("!")} runtime ${agentReady ? `${setupAgent?.label || "Pi"} available` : "not installed — run 'bivy agents:install'"}`);
@@ -4766,7 +4800,7 @@ async function cmdSetup(args = []) {
4766
4800
  const ghReady = githubConnected(finalConfig);
4767
4801
  console.log(` ${ghReady ? c.green("✓") : c.dim("○")} GitHub ${ghReady ? "connected — your repos will list in the app" : c.dim("optional — connect later in the app under Settings → GitHub App")}`);
4768
4802
  console.log(` ${agentReady && modelReady && repoReady ? c.green("✓") : c.yellow("!")} first task ${agentReady && modelReady && repoReady ? "ready to try" : "blocked by the stage above"}`);
4769
- console.log(` ${fs.existsSync(relayConfigPath) ? c.green("✓") : c.yellow("!")} remote ${fs.existsSync(relayConfigPath) ? "configured" : "not configured — run 'bivy relay:setup'"}\n`);
4803
+ console.log(` ${fs.existsSync(relayConfigPath) || fs.existsSync(tailscaleConfigPath) ? c.green("✓") : c.dim("○")} access ${fs.existsSync(relayConfigPath) || fs.existsSync(tailscaleConfigPath) ? "set up — 'bivy access' shows what it gives you" : "this machine only — 'bivy access' shows the options"}\n`);
4770
4804
  // Get the user into the product immediately; terminal commands are the
4771
4805
  // fallback/next-step checklist after the remote app has been opened or linked.
4772
4806
  await finishSetupRemote(finalConfig, setupSession);
@@ -4953,8 +4987,10 @@ async function cmdStatus(args = []) {
4953
4987
  const relay = loadRelayConfig();
4954
4988
  const relaySt = status?.relay;
4955
4989
  const relayConfigured = Boolean(relaySt?.configured || relay);
4990
+ const access = reachable ? await fetchAccess(config).catch(() => null) : null;
4991
+ if (access) console.log(` access: ${accessLine(access)}`);
4956
4992
  if (!relayConfigured) {
4957
- console.log(` remote: ${c.dim("local only")} ${c.dim("('bivy relay:setup' to enable remote access)")}`);
4993
+ if (!access) console.log(` remote: ${c.dim("this machine only")} ${c.dim("('bivy access' to see the options)")}`);
4958
4994
  } else {
4959
4995
  // The live link state is only knowable when the node is running; if it's
4960
4996
  // down we can say it's configured but not whether it's currently connected.
@@ -5149,7 +5185,7 @@ async function cmdDoctor(args = []) {
5149
5185
  const relayApp = status?.relay?.controlPlaneUrl;
5150
5186
  const relayErr = status?.relay?.lastError;
5151
5187
  const relayLine = !relayConfigured
5152
- ? c.dim("local only — 'bivy relay:setup' to enable")
5188
+ ? c.dim("this machine only — 'bivy access' to see the options")
5153
5189
  : relayConnected
5154
5190
  ? c.green("relay connected") + (relayApp ? c.dim(` ${relayApp}`) : "")
5155
5191
  : c.yellow("configured, not connected") + (relayErr ? c.dim(` (${relayErr})`) : "");
@@ -5314,6 +5350,13 @@ async function cmdUpdate(args = []) {
5314
5350
  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.");
5315
5351
  return;
5316
5352
  }
5353
+ const managed = managedInstall(repoRoot);
5354
+ if (managed) {
5355
+ console.log(`Bivy is installed by ${managed.manager} here, so it updates through it${managed.update ? `: ${c.cyan(managed.update)}` : "."}`);
5356
+ console.log(c.dim("Then run 'bivy restart' to pick up the new version."));
5357
+ process.exitCode = EXIT.failed;
5358
+ return;
5359
+ }
5317
5360
  // Inside a Bivy web/PWA terminal the shell is a child of the node's own
5318
5361
  // process, so `restartService()` — the final step of an update — tears down
5319
5362
  // this very terminal. Run inline and you lose all output the moment it lands.
@@ -5782,7 +5825,7 @@ async function cmdTailscale(args = []) {
5782
5825
  process.exitCode = EXIT.unavailable;
5783
5826
  return;
5784
5827
  }
5785
- const { running, hostname } = parseTailscaleStatus(status.stdout);
5828
+ const { running, hostname, owner } = parseTailscaleStatus(status.stdout);
5786
5829
  if (!running || !hostname) {
5787
5830
  console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
5788
5831
  process.exitCode = EXIT.unavailable;
@@ -5795,7 +5838,7 @@ async function cmdTailscale(args = []) {
5795
5838
  process.exitCode = EXIT.conflict;
5796
5839
  return;
5797
5840
  }
5798
- fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
5841
+ fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname, ...(owner ? { owner } : {}) }, null, 2)}\n`, { mode: 0o600 });
5799
5842
  const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
5800
5843
  const served = runTailscale(serveOnArgs(port));
5801
5844
  if (!served?.ok) {
@@ -5808,12 +5851,83 @@ async function cmdTailscale(args = []) {
5808
5851
  return;
5809
5852
  }
5810
5853
  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'."));
5854
+ console.log(c.dim(owner
5855
+ ? ` Only devices on your tailnet can reach it. Yours (signed in as ${owner}) are let in without pairing;\n anyone else's pairs once with a link from 'bivy tailscale pair'.`
5856
+ : " Only devices on your tailnet can reach it. This machine is tagged, so each device pairs once with a link from 'bivy tailscale pair'."));
5812
5857
  if (!reload?.webApp) console.log(c.yellow(" This install has no built web app, so the address serves the API only. Run 'pnpm run build:web' in a source checkout."));
5813
5858
  console.log("");
5859
+ if (owner) {
5860
+ console.log(`Open ${c.cyan(`https://${hostname}`)} on any of your devices.\n`);
5861
+ return;
5862
+ }
5814
5863
  await printPairLink(config);
5815
5864
  }
5816
5865
 
5866
+ async function fetchAccess(config) {
5867
+ return localApi(config, "/api/access/get", { method: "POST", body: "{}" });
5868
+ }
5869
+
5870
+ // `bivy access` — how this machine can be reached (this machine only,
5871
+ // Tailscale, Bivy hosted, your own server), what that gives you, and the next
5872
+ // step. The node owns the table (src/access.ts); this only renders and routes.
5873
+ async function cmdAccess(args = []) {
5874
+ const [verb, target] = args.filter((a) => !a.startsWith("-"));
5875
+ if (args.includes("-h") || args.includes("--help") || (verb && !["local", "tailscale", "hosted", "server"].includes(verb))) {
5876
+ console.log(`Usage: bivy access [--json]
5877
+ bivy access tailscale reach this machine from your devices over Tailscale
5878
+ bivy access hosted sign in to Bivy hosted: every machine, from anywhere
5879
+ bivy access server <url> [--relay <wss-url>] the same, through your own server
5880
+ bivy access local turn remote access off
5881
+
5882
+ Setups stack: Tailscale can stay on alongside hosted or your own server.`);
5883
+ if (verb && !["local", "tailscale", "hosted", "server"].includes(verb)) process.exitCode = EXIT.usage;
5884
+ return;
5885
+ }
5886
+ const config = loadConfig();
5887
+ if (!(await ensureNodeRunning(config))) {
5888
+ console.error(c.red(`Could not start the Bivy node at ${url(config)}.`));
5889
+ process.exitCode = EXIT.unavailable;
5890
+ return;
5891
+ }
5892
+ const report = await fetchAccess(config);
5893
+
5894
+ if (!verb) {
5895
+ if (wantsJson(args)) { console.log(JSON.stringify(report, null, 2)); return; }
5896
+ console.log(`\n${accessScreen(report, c).join("\n")}\n`);
5897
+ return;
5898
+ }
5899
+ if (verb === "tailscale") return cmdTailscale(["on"]);
5900
+ if (verb === "hosted" || verb === "server") {
5901
+ const linked = report.setups.find((s) => s.active && (s.id === "hosted" || s.id === "server"));
5902
+ if (linked && linked.id === verb && verb === "hosted") {
5903
+ console.log(`Already on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}.`);
5904
+ return;
5905
+ }
5906
+ if (linked) console.log(c.yellow(`This machine is on ${linked.label}${report.controlPlaneUrl ? ` (${report.controlPlaneUrl})` : ""}. Switching keeps everything on this machine; your devices sign in again at the new address.`));
5907
+ if (verb === "hosted") return cmdRelaySetup([]);
5908
+ if (!target) {
5909
+ console.log(`Usage: bivy access server <url> [--relay <wss-url>]\n\nThe address of your Bivy server, e.g. https://bivy.example.com. Set one up with docs/self-host-quickstart.md.`);
5910
+ process.exitCode = EXIT.usage;
5911
+ return;
5912
+ }
5913
+ let appUrl;
5914
+ try { appUrl = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`); } catch { appUrl = null; }
5915
+ if (!appUrl) { console.error(c.red(`Not a URL: ${target}`)); process.exitCode = EXIT.usage; return; }
5916
+ // Bivy's server installs serve the relay at /relay on the app's own domain.
5917
+ const relayUrl = argValue(args, "relay") || `${appUrl.protocol === "http:" ? "ws" : "wss"}://${appUrl.host}/relay`;
5918
+ return cmdRelaySetup(["--control-plane", appUrl.origin, "--relay", relayUrl]);
5919
+ }
5920
+ // local: turn off every remote setup that is on.
5921
+ const on = report.setups.filter((s) => s.active && s.id !== "local");
5922
+ if (!on.length) { console.log("Already this machine only."); return; }
5923
+ const rl = createPrompter();
5924
+ const yes = await rl.askYesNo(`Turn off ${on.map((s) => s.label).join(" and ")}? Sessions and credentials on this machine stay.`, false);
5925
+ rl.close();
5926
+ if (!yes) return;
5927
+ if (on.some((s) => s.id === "tailscale")) await cmdTailscale(["off"]);
5928
+ if (on.some((s) => s.id === "hosted" || s.id === "server")) await cmdAccountLogout([]);
5929
+ }
5930
+
5817
5931
  function printUninstallHelp() {
5818
5932
  console.log(`
5819
5933
  ${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
@@ -6414,9 +6528,15 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
6414
6528
  case "link":
6415
6529
  await cmdLinkPhone(args);
6416
6530
  break;
6531
+ case "tui":
6532
+ await cmdTui(args);
6533
+ break;
6417
6534
  case "tailscale":
6418
6535
  await cmdTailscale(args);
6419
6536
  break;
6537
+ case "access":
6538
+ await cmdAccess(args);
6539
+ break;
6420
6540
  case "relay:setup":
6421
6541
  await cmdRelaySetup(args);
6422
6542
  break;
@@ -65,6 +65,7 @@ export const COMMANDS = [
65
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" },
66
66
  { name: "exec", group: "sessions", scope: "node", json: true, usage: 'exec "<prompt>" [--agent …] [--session …]', summary: "One-shot headless session: prints the answer to stdout" },
67
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" },
68
69
  { name: "sessions", aliases: ["ls"], group: "sessions", scope: "node", json: true, usage: "sessions [--json]", summary: "List recent sessions (live and saved) and resume one" },
69
70
  { name: "resume", group: "sessions", scope: "node", usage: "resume [n|id]", summary: "Resume a session directly (default: most recent)" },
70
71
  { name: "kill", group: "sessions", scope: "node", usage: "kill <id> [--delete]", summary: "Stop a session or terminal" },
@@ -105,6 +106,7 @@ export const COMMANDS = [
105
106
  { name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
106
107
  { name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
107
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"] },
108
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"] },
109
111
  { name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
110
112
  { name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
@@ -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.
package/bin/tailscale.mjs CHANGED
@@ -7,12 +7,19 @@
7
7
  /** The one HTTPS port Bivy claims on the machine's tailnet name. */
8
8
  export const SERVE_HTTPS_PORT = 443;
9
9
 
10
- /** `{ running, hostname }` from `tailscale status --json` output. */
10
+ /**
11
+ * `{ running, hostname, owner }` from `tailscale status --json` output. `owner`
12
+ * is the login of the user this machine belongs to, or null for a tagged
13
+ * machine (owned by tags, not a person).
14
+ */
11
15
  export function parseTailscaleStatus(text) {
12
16
  let status;
13
- try { status = JSON.parse(text); } catch { return { running: false, hostname: null }; }
17
+ try { status = JSON.parse(text); } catch { return { running: false, hostname: null, owner: null }; }
14
18
  const hostname = String(status?.Self?.DNSName ?? "").replace(/\.$/, "") || null;
15
- return { running: status?.BackendState === "Running", hostname };
19
+ const tagged = Array.isArray(status?.Self?.Tags) && status.Self.Tags.length > 0;
20
+ const login = status?.User?.[String(status?.Self?.UserID)]?.LoginName;
21
+ const owner = !tagged && typeof login === "string" && login.includes("@") ? login : null;
22
+ return { running: status?.BackendState === "Running", hostname, owner };
16
23
  }
17
24
 
18
25
  /** Where `tailscale serve` should send the machine's HTTPS traffic. */
@@ -0,0 +1,189 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // `bivy tui`: the terminal around model.mjs and render.mjs. It polls each
4
+ // machine's node API, keeps the selected session's transcript current, turns
5
+ // keys into effects and draws only the lines that changed.
6
+
7
+ import { initialState, reduceKey, selectedSession, sessionKey, withData, isWorking } from "./model.mjs";
8
+ import { renderFrame } from "./render.mjs";
9
+ import { transcriptEntries } from "./transcript.mjs";
10
+
11
+ const POLL_MS = 1500;
12
+ const SPIN_MS = 120;
13
+
14
+ const KEYS = {
15
+ "\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", "\x1b[D": "left",
16
+ "\x1bOA": "up", "\x1bOB": "down", "\x1bOC": "right", "\x1bOD": "left",
17
+ "\x1b[5~": "pageup", "\x1b[6~": "pagedown",
18
+ "\r": "enter", "\n": "enter", "\t": "tab", "\x7f": "backspace", "\b": "backspace",
19
+ "\x1b": "esc", "\x03": "ctrl-c", "\x04": "ctrl-d", "\x15": "ctrl-u", " ": "space",
20
+ };
21
+
22
+ /** Raw terminal input as key names; a paste arrives as its characters. */
23
+ export function parseKeys(data) {
24
+ const text = String(data);
25
+ if (KEYS[text]) return [KEYS[text]];
26
+ if (text.startsWith("\x1b")) return [];
27
+ return [...text].map((ch) => KEYS[ch] ?? ch).filter((k) => k.length > 1 || k >= " ");
28
+ }
29
+
30
+ async function call(machine, method, path, body) {
31
+ const res = await fetch(`${machine.base}${path}`, {
32
+ method,
33
+ headers: { "content-type": "application/json", ...(machine.token ? { authorization: `Bearer ${machine.token}` } : {}) },
34
+ ...(body ? { body: JSON.stringify(body) } : {}),
35
+ signal: AbortSignal.timeout(method === "GET" ? 4000 : 15000),
36
+ });
37
+ const data = await res.json().catch(() => ({}));
38
+ if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
39
+ return data;
40
+ }
41
+
42
+ /** Sessions and approvals from every machine; one that doesn't answer is marked offline. */
43
+ async function snapshot(machines) {
44
+ const results = await Promise.all(machines.map(async (machine) => {
45
+ try {
46
+ const [sessions, approvals] = await Promise.all([call(machine, "GET", "/api/sessions"), call(machine, "GET", "/api/approvals")]);
47
+ return {
48
+ machine: { name: machine.name, online: true },
49
+ sessions: (Array.isArray(sessions) ? sessions : []).map((s) => ({
50
+ machine: machine.name,
51
+ id: s.id,
52
+ name: s.name || s.firstMessage || "",
53
+ agent: s.agentName || s.agent || "",
54
+ agentName: s.agentName || "",
55
+ status: s.sessionState?.displayStatus ?? s.status ?? "",
56
+ needsAction: Boolean(s.needsAction),
57
+ open: Boolean(s.open),
58
+ branch: s.branch,
59
+ lastActivityAt: s.lastActivityAt || s.updatedAt,
60
+ })),
61
+ approvals: (Array.isArray(approvals) ? approvals : []).map((a) => ({ ...a, machine: machine.name })),
62
+ };
63
+ } catch {
64
+ return { machine: { name: machine.name, online: false }, sessions: [], approvals: [] };
65
+ }
66
+ }));
67
+ return {
68
+ machines: results.map((r) => r.machine),
69
+ sessions: results.flatMap((r) => r.sessions),
70
+ approvals: results.flatMap((r) => r.approvals),
71
+ };
72
+ }
73
+
74
+ export async function runTui({ machines, stdin = process.stdin, stdout = process.stdout }) {
75
+ const byName = new Map(machines.map((m) => [m.name, m]));
76
+ let state = initialState();
77
+ let tick = 0;
78
+ let previous = [];
79
+ // Transcripts by session key: raw messages plus the node's history cursor.
80
+ const histories = new Map();
81
+ let loadingKey = null;
82
+
83
+ const view = () => {
84
+ const session = selectedSession(state);
85
+ const key = session ? sessionKey(session) : "";
86
+ const history = histories.get(key);
87
+ return { entries: history ? transcriptEntries(history.messages) : [], loading: loadingKey === key && !history };
88
+ };
89
+
90
+ const draw = (full = false) => {
91
+ const lines = renderFrame(state, view(), { width: stdout.columns || 80, height: stdout.rows || 24, tick });
92
+ let out = "";
93
+ lines.forEach((line, i) => { if (full || line !== previous[i]) out += `\x1b[${i + 1};1H${line}\x1b[0m`; });
94
+ previous = lines;
95
+ if (out) stdout.write(out);
96
+ };
97
+
98
+ const loadHistory = async (session) => {
99
+ const key = sessionKey(session);
100
+ const machine = byName.get(session.machine);
101
+ const have = histories.get(key);
102
+ const query = new URLSearchParams({ sessionId: session.id, ...(have ? { have: String(have.count), haveToken: have.hash } : {}) });
103
+ try {
104
+ const event = await call(machine, "GET", `/api/session/history?${query}`);
105
+ const messages = event.mode === "append" && have ? [...have.messages, ...(event.messages ?? [])] : event.messages ?? [];
106
+ histories.set(key, { messages, count: event.count ?? messages.length, hash: event.historyHash });
107
+ } catch (error) {
108
+ state = { ...state, notice: `Couldn't load the transcript: ${error.message}` };
109
+ } finally {
110
+ if (loadingKey === key) loadingKey = null;
111
+ }
112
+ };
113
+
114
+ const refresh = async () => {
115
+ // Read `state` only after the await: keys pressed meanwhile must survive.
116
+ const data = await snapshot(machines);
117
+ state = withData(state, data);
118
+ const session = selectedSession(state);
119
+ // Only sessions already open on their machine are followed; opening a closed one is a keypress.
120
+ if (session?.open) await loadHistory(session);
121
+ draw();
122
+ };
123
+
124
+ const run = async (effect) => {
125
+ const machine = effect.session ? byName.get(effect.session.machine) : effect.approval ? byName.get(effect.approval.machine) : null;
126
+ try {
127
+ if (effect.type === "open") {
128
+ loadingKey = sessionKey(effect.session);
129
+ draw();
130
+ await loadHistory(effect.session);
131
+ } else if (effect.type === "prompt") {
132
+ await call(machine, "POST", "/api/session/prompt", { sessionId: effect.session.id, text: effect.text });
133
+ state = { ...state, notice: "" };
134
+ } else if (effect.type === "answer") {
135
+ await call(machine, "POST", `/api/approvals/${encodeURIComponent(effect.approval.id)}/${effect.approve ? "approve" : "reject"}`, {});
136
+ state = { ...state, notice: effect.approve ? `Approved ${effect.approval.toolName}.` : `Rejected ${effect.approval.toolName}.` };
137
+ } else if (effect.type === "stop") {
138
+ await call(machine, "POST", "/api/session/abort", { sessionId: effect.session.id });
139
+ state = { ...state, notice: "Stopping…" };
140
+ }
141
+ } catch (error) {
142
+ state = { ...state, notice: error.message };
143
+ }
144
+ await refresh();
145
+ };
146
+
147
+ return new Promise((resolve) => {
148
+ stdout.write("\x1b[?1049h\x1b[?25l\x1b[2J");
149
+ stdin.setRawMode?.(true);
150
+ stdin.resume();
151
+ const poll = setInterval(() => { void refresh(); }, POLL_MS);
152
+ const spin = setInterval(() => {
153
+ if (!state.sessions.some(isWorking)) return;
154
+ tick++;
155
+ draw();
156
+ }, SPIN_MS);
157
+ const onResize = () => draw(true);
158
+ const quit = () => {
159
+ clearInterval(poll);
160
+ clearInterval(spin);
161
+ stdout.off("resize", onResize);
162
+ stdin.off("data", onData);
163
+ stdin.setRawMode?.(false);
164
+ stdin.pause();
165
+ stdout.write("\x1b[?25h\x1b[?1049l");
166
+ resolve();
167
+ };
168
+ const onData = (data) => {
169
+ for (const key of parseKeys(data)) {
170
+ const before = selectedSession(state);
171
+ const [next, effect] = reduceKey(state, key);
172
+ state = next;
173
+ if (effect?.type === "quit") return quit();
174
+ if (effect) void run(effect);
175
+ // Moving onto an open session shows its transcript straight away.
176
+ const after = selectedSession(state);
177
+ if (after && after !== before && after.open && !histories.has(sessionKey(after))) {
178
+ loadingKey = sessionKey(after);
179
+ void loadHistory(after).then(() => draw());
180
+ }
181
+ }
182
+ draw();
183
+ };
184
+ stdin.on("data", onData);
185
+ stdout.on("resize", onResize);
186
+ draw(true);
187
+ void refresh();
188
+ });
189
+ }