@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.
- package/bin/access-view.mjs +43 -0
- package/bin/bivy.mjs +135 -15
- package/bin/cli-commands.mjs +2 -0
- package/bin/install-kind.mjs +20 -0
- package/bin/tailscale.mjs +10 -3
- package/bin/tui/app.mjs +189 -0
- package/bin/tui/model.mjs +112 -0
- package/bin/tui/render.mjs +164 -0
- package/bin/tui/text.mjs +72 -0
- package/bin/tui/transcript.mjs +54 -0
- package/dist/access.js +72 -0
- package/dist/apps/styles.css +2 -1
- package/dist/apps/tokens.css +40 -0
- package/dist/auth.js +18 -4
- package/dist/machine-theme.js +118 -0
- package/dist/node-update.js +16 -0
- package/dist/protocol/client-command-routes.js +2 -0
- package/dist/protocol/client-command-schemas.js +2 -0
- package/dist/remote/direct.js +2 -1
- package/dist/remote/index.js +1 -0
- package/dist/remote/tailnet.js +44 -0
- package/dist/server.js +55 -3
- package/package.json +1 -1
- package/web/assets/AutomationsView-DjOv4IMn.js +51 -0
- package/web/assets/{ChangesCard-BzRQTCVs.js → ChangesCard-Bcd6mp08.js} +1 -1
- package/web/assets/{LibraryView-C6mluzPl.js → LibraryView-CiRErGG3.js} +1 -1
- package/web/assets/{ProviderConnect-BK6oEwlu.js → ProviderConnect-htTjSJzb.js} +1 -1
- package/web/assets/Settings-oBsZwFcG.js +10 -0
- package/web/assets/{Sheet-YqJCwCtR.js → Sheet-lYov8PLo.js} +1 -1
- package/web/assets/{Terminal-CWjLg3i2.js → Terminal-B9d4Ijmc.js} +1 -1
- package/web/assets/{VoiceSettings-DVlH29qG.js → VoiceSettings-BIADw4Ll.js} +1 -1
- package/web/assets/{abnfDiagram-VCTEODGH-DR5nREn3.js → abnfDiagram-VCTEODGH-gRoFYqU5.js} +1 -1
- package/web/assets/architecture-7GRP2DOG-BEJ5aLoK.js +1 -0
- package/web/assets/{architectureDiagram-5GKGNRK7-BTtzbbP7.js → architectureDiagram-5GKGNRK7-C4cNJboP.js} +1 -1
- package/web/assets/{blockDiagram-I7D4REHJ-B02cXkpO.js → blockDiagram-I7D4REHJ-DAICqtG4.js} +1 -1
- package/web/assets/{c4Diagram-7LVT6UL2-DuMEAvRa.js → c4Diagram-7LVT6UL2-DRswRDNz.js} +1 -1
- package/web/assets/channel-BPXvQ5Jt.js +1 -0
- package/web/assets/{chunk-4HAMMTFA-C3z4iXsh.js → chunk-4HAMMTFA-2joFftNJ.js} +1 -1
- package/web/assets/{chunk-75Z2AOVW-BaELqtNa.js → chunk-75Z2AOVW-XWOFp1H_.js} +1 -1
- package/web/assets/{chunk-DU6HZSFF-BBrnNwn_.js → chunk-DU6HZSFF-DS4Nz4nA.js} +1 -1
- package/web/assets/{chunk-F27PBJKO-Bfuzd8Q8.js → chunk-F27PBJKO-Dx_X9qdN.js} +1 -1
- package/web/assets/{chunk-GMAD6QVW-B0Np7gv0.js → chunk-GMAD6QVW-DxV6twrr.js} +1 -1
- package/web/assets/{chunk-GVQU2GXP-CiZeRFSW.js → chunk-GVQU2GXP-MSIDLTHB.js} +1 -1
- package/web/assets/{chunk-IMKFNOWR-DJGDP5h1.js → chunk-IMKFNOWR-CELsloet.js} +1 -1
- package/web/assets/{chunk-L3NEJ4N5-u67SBNSZ.js → chunk-L3NEJ4N5-BZ3ozIZf.js} +1 -1
- package/web/assets/{chunk-OSK3NFVY-DJUfOWEW.js → chunk-OSK3NFVY-BXjogSMs.js} +1 -1
- package/web/assets/{chunk-P2QGCYS3-CYmch4EI.js → chunk-P2QGCYS3-DFcISUhA.js} +1 -1
- package/web/assets/{chunk-POPQ4Y6H-BOjVOrGx.js → chunk-POPQ4Y6H-CMv8VcTR.js} +1 -1
- package/web/assets/{chunk-PWAF6VOD-ChmjsLTV.js → chunk-PWAF6VOD-Ck43wPZJ.js} +1 -1
- package/web/assets/{chunk-SHT3W25Y-BU57QSGG.js → chunk-SHT3W25Y-Dd3YYPpo.js} +1 -1
- package/web/assets/{chunk-SVP7TREG-3okF7bFJ.js → chunk-SVP7TREG-1wHIX2u8.js} +1 -1
- package/web/assets/{chunk-TICWLB2K-DervhtUR.js → chunk-TICWLB2K-C2RL-jix.js} +1 -1
- package/web/assets/classDiagram-ZZMXUADV-DicIinQk.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-DicIinQk.js +1 -0
- package/web/assets/{cynefin-OW5HDTMX-BpkHeQ4U.js → cynefin-OW5HDTMX-L3cCx0-a.js} +1 -1
- package/web/assets/{cynefinDiagram-5FMLGOSQ-BY9V3rN7.js → cynefinDiagram-5FMLGOSQ-B-p9_QH8.js} +1 -1
- package/web/assets/{dagre-GXQ25YYZ-DAmjU9c2.js → dagre-GXQ25YYZ-CNcD81ab.js} +1 -1
- package/web/assets/{diagram-S7CK7UJ4-BE7RKay-.js → diagram-S7CK7UJ4-CLXtZ4aE.js} +1 -1
- package/web/assets/{diagram-UQ7AKVKN-CkizMHlG.js → diagram-UQ7AKVKN-Bj1-misl.js} +1 -1
- package/web/assets/{diagram-VSXAHHWV-BELxVnhK.js → diagram-VSXAHHWV-BsncRf1X.js} +1 -1
- package/web/assets/{diagram-VX7I27RA-C3wu2cVu.js → diagram-VX7I27RA-DgnUNiqR.js} +1 -1
- package/web/assets/{diagram-Z3DM3KII-CYX5IZ30.js → diagram-Z3DM3KII-CYA1FOfJ.js} +1 -1
- package/web/assets/{ebnfDiagram-PWID7BFC-CgyMDCw8.js → ebnfDiagram-PWID7BFC-EN37F-ZX.js} +1 -1
- package/web/assets/{erDiagram-RLTQ6QDP-CC1ZIoBl.js → erDiagram-RLTQ6QDP-7khCs6R0.js} +1 -1
- package/web/assets/eventmodeling-NTZA5JFV-DPlKwCgg.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-CuyexHJ1.js +1 -0
- package/web/assets/{ganttDiagram-EL5Y4UJY-CLUlox-U.js → ganttDiagram-EL5Y4UJY-X2fCrUiA.js} +1 -1
- package/web/assets/{gitGraph-4MIJSDKK-B_yZBnXo.js → gitGraph-4MIJSDKK-C1jg4oXv.js} +1 -1
- package/web/assets/{gitGraphDiagram-WWUBYQGX-DNGTGoq6.js → gitGraphDiagram-WWUBYQGX-aRlKIG1q.js} +1 -1
- package/web/assets/index-BYhnjcv5.js +3 -0
- package/web/assets/index-Cik3irwJ.css +1 -0
- package/web/assets/{info-A6RAGUB7-O_QhqiG0.js → info-A6RAGUB7-knTxGQzP.js} +1 -1
- package/web/assets/{infoDiagram-27XIBGKW-CfBbmLPf.js → infoDiagram-27XIBGKW-CZ5eWH_5.js} +1 -1
- package/web/assets/{ishikawaDiagram-5VMMS53U-BJj25HdR.js → ishikawaDiagram-5VMMS53U-BaN7oiXe.js} +1 -1
- package/web/assets/{journeyDiagram-3NMN7TZE-D547gNvv.js → journeyDiagram-3NMN7TZE-DSKzoitB.js} +1 -1
- package/web/assets/{kanban-definition-UXKFOSKX-BziklmYw.js → kanban-definition-UXKFOSKX-Do-Ybw2Z.js} +1 -1
- package/web/assets/{line-CjB_oA8i.js → line-CwAjwUaG.js} +1 -1
- package/web/assets/{mermaid-parser.core-Bj5-yX0d.js → mermaid-parser.core-DgfSoRZF.js} +3 -3
- package/web/assets/{mermaid.core-CWsczu0T.js → mermaid.core-BmmLukUA.js} +4 -4
- package/web/assets/{mindmap-definition-YA3MSWOX-BqsBs-D4.js → mindmap-definition-YA3MSWOX-BvYA7HOJ.js} +1 -1
- package/web/assets/mount-YpsogRG1.js +26 -0
- package/web/assets/{packet-AYTQ26CC-B8U6sMom.js → packet-AYTQ26CC-z5xJrrsj.js} +1 -1
- package/web/assets/{pegDiagram-XKGWAZYB-BXV85Hj-.js → pegDiagram-XKGWAZYB-Dlnf2qqP.js} +1 -1
- package/web/assets/{pie-WAS4IAKB-CD71AvCo.js → pie-WAS4IAKB-Bcc2ZLgF.js} +1 -1
- package/web/assets/{pieDiagram-E7YTZNPT-DNq8_A1r.js → pieDiagram-E7YTZNPT-DK6Bm2ct.js} +1 -1
- package/web/assets/{quadrantDiagram-AXDQQJYC-ZNiXqFdJ.js → quadrantDiagram-AXDQQJYC-CGdNrgH7.js} +1 -1
- package/web/assets/{radar-RG4KPBEZ-CpvF1rs-.js → radar-RG4KPBEZ-DvDzdzlV.js} +1 -1
- package/web/assets/{railroad-74A4TZTK-BIfz4cd-.js → railroad-74A4TZTK-DAk4jBzt.js} +1 -1
- package/web/assets/railroad-abnf-HS5TGJTU-BboXzbI7.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-2aWLf6tt.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-DkEFbMwn.js +1 -0
- package/web/assets/{railroadDiagram-O6MQD6OU-Fao_FZJL.js → railroadDiagram-O6MQD6OU-qghivQgJ.js} +1 -1
- package/web/assets/{requirementDiagram-BXWQKSXE-QnxN_viD.js → requirementDiagram-BXWQKSXE-BB2VHwAe.js} +1 -1
- package/web/assets/{sankeyDiagram-P5KCCOFB-DDhcKN50.js → sankeyDiagram-P5KCCOFB-DYsK6FvP.js} +1 -1
- package/web/assets/{sequenceDiagram-WJ2MYXX4-bVK0aV05.js → sequenceDiagram-WJ2MYXX4-DN7_ECVu.js} +1 -1
- package/web/assets/settingsRoute-BryKVhsu.js +1 -0
- package/web/assets/{stateDiagram-D77RDMKH-CZiiW4Zy.js → stateDiagram-D77RDMKH-CMoUI-nz.js} +1 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-DVVMJFff.js +1 -0
- package/web/assets/{swimlanes-42K2YHIH-CYgOds43.js → swimlanes-42K2YHIH-Dg9Ds-zo.js} +1 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-CMp2P5bx.js +8 -0
- package/web/assets/{timeline-definition-24CTP7MA-CnVLFUDs.js → timeline-definition-24CTP7MA-B71AxnxY.js} +1 -1
- package/web/assets/{treeView-Q6P3EWNA-D6CxJQkK.js → treeView-Q6P3EWNA-CcYDvc71.js} +1 -1
- package/web/assets/{treemap-WGGIJYW6-DFCHLN8D.js → treemap-WGGIJYW6-COuLLLjA.js} +1 -1
- package/web/assets/useStore-CVAcdLfm.js +27 -0
- package/web/assets/{vennDiagram-4TSXK5OY-CX5aQZNu.js → vennDiagram-4TSXK5OY-Dyg63v_c.js} +1 -1
- package/web/assets/{wardley-WFR3VGLG-BCPDkPQh.js → wardley-WFR3VGLG-FAnOXgo0.js} +1 -1
- package/web/assets/{wardleyDiagram-VM6X3IG4-Cf0fwWD7.js → wardleyDiagram-VM6X3IG4-fhlKAloZ.js} +1 -1
- package/web/assets/{xychartDiagram-S5SC5T6Z-DalYEP3d.js → xychartDiagram-S5SC5T6Z-DbM80CJG.js} +1 -1
- package/web/index.html +2 -2
- package/web/sw.js +1 -1
- package/web/assets/AutomationsView-BJ3gBIGf.js +0 -51
- package/web/assets/Settings-CG236bpZ.js +0 -10
- package/web/assets/architecture-7GRP2DOG-Mvf9rpFA.js +0 -1
- package/web/assets/channel-BETA4C1-.js +0 -1
- package/web/assets/classDiagram-ZZMXUADV-DG1LzruI.js +0 -1
- package/web/assets/classDiagram-v2-VYDZK3BY-DG1LzruI.js +0 -1
- package/web/assets/eventmodeling-NTZA5JFV-YiXMtkrP.js +0 -1
- package/web/assets/flowDiagram-HODETNUW-UVK9K8aR.js +0 -1
- package/web/assets/index-D-0NDZis.js +0 -3
- package/web/assets/index-Dk7VU5lG.css +0 -1
- package/web/assets/mount-BlTXC3tw.js +0 -26
- package/web/assets/railroad-abnf-HS5TGJTU-oAj9W4Xw.js +0 -1
- package/web/assets/railroad-ebnf-LZEXJU2U-B5J5VpFh.js +0 -1
- package/web/assets/railroad-peg-WCYAUIDC-DpqDcjfx.js +0 -1
- package/web/assets/stateDiagram-v2-MP3YSRHH-B9Vt7f0v.js +0 -1
- package/web/assets/swimlanesDiagram-VR7AAH4N-CfED88ct.js +0 -8
- 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
|
-
"
|
|
4643
|
+
"How will you reach this machine?",
|
|
4613
4644
|
[
|
|
4614
|
-
{ key: "h", label: "hosted (recommended — sign in with GitHub or email;
|
|
4615
|
-
{ key: "
|
|
4616
|
-
{ key: "
|
|
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("
|
|
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.
|
|
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.
|
|
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("
|
|
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("
|
|
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(
|
|
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;
|
package/bin/cli-commands.mjs
CHANGED
|
@@ -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" },
|
package/bin/install-kind.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
19
|
+
const tagged = Array.isArray(status?.Self?.Tags) && status.Self.Tags.length > 0;
|
|
20
|
+
const login = status?.User?.[String(status?.Self?.UserID)]?.LoginName;
|
|
21
|
+
const owner = !tagged && typeof login === "string" && login.includes("@") ? login : null;
|
|
22
|
+
return { running: status?.BackendState === "Running", hostname, owner };
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
/** Where `tailscale serve` should send the machine's HTTPS traffic. */
|
package/bin/tui/app.mjs
ADDED
|
@@ -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
|
+
}
|