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