@bivy/bivy 0.20.1-staging.4 → 0.20.1-staging.6
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 +128 -6
- package/bin/cli-commands.mjs +3 -2
- package/bin/guides/talk-to-the-user.md +12 -3
- package/bin/tailscale.mjs +63 -0
- package/dist/agent-instructions.js +6 -3
- package/dist/apps/styles.css +3 -2
- 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 +97 -9
- package/dist/session/suggestions.js +5 -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-C5FTAk5H.js +51 -0
- package/web/assets/ChangesCard-Bq3EQIem.js +4 -0
- package/web/assets/LibraryView-DrOvEcbv.js +2 -0
- package/web/assets/ProviderConnect-B5UJ6o1x.js +1 -0
- package/web/assets/ReadinessChecklist-Bbgq2GQ8.js +1 -0
- package/web/assets/Settings-BV1LvpeB.js +10 -0
- package/web/assets/Sheet-Db4Mndim.js +2 -0
- package/web/assets/StatusDot-dpbztK53.js +1 -0
- package/web/assets/Terminal-B1njb2Cr.css +1 -0
- package/web/assets/Terminal-CvIBOJzq.js +96 -0
- package/web/assets/VoiceSettings-96Bha24X.js +1 -0
- package/web/assets/abnfDiagram-VCTEODGH-D2Y5sPRp.js +1 -0
- package/web/assets/arc-Pry-7G1_.js +1 -0
- package/web/assets/architecture-7GRP2DOG-BgbAf7AC.js +1 -0
- package/web/assets/architectureDiagram-5GKGNRK7-BRYXZYo0.js +36 -0
- package/web/assets/array-BifhSqXX.js +1 -0
- package/web/assets/blockDiagram-I7D4REHJ-BbZ6-1wB.js +129 -0
- package/web/assets/c4Diagram-7LVT6UL2-CvOQdXuZ.js +38 -0
- package/web/assets/channel-Ccxgwn49.js +1 -0
- package/web/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web/assets/chunk-4HAMMTFA-BI825_ZV.js +62 -0
- package/web/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web/assets/chunk-75Z2AOVW-CxSd6Zxl.js +2 -0
- package/web/assets/chunk-DU6HZSFF-1-mK49JE.js +127 -0
- package/web/assets/chunk-F27PBJKO-jlR5FL7E.js +1 -0
- package/web/assets/chunk-FOHPRMQF-DzxwRta7.js +161 -0
- package/web/assets/chunk-GMAD6QVW-BBnwhF5K.js +72 -0
- package/web/assets/chunk-GVQU2GXP-CHUTVqWw.js +1 -0
- package/web/assets/chunk-IMKFNOWR-D0BbcDj6.js +231 -0
- package/web/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web/assets/chunk-L3NEJ4N5-DvDZiWe-.js +1 -0
- package/web/assets/chunk-OSK3NFVY-JHuvasbP.js +10 -0
- package/web/assets/chunk-P2QGCYS3-wjuBF4Pd.js +1 -0
- package/web/assets/chunk-POPQ4Y6H-CngK4WUA.js +1 -0
- package/web/assets/chunk-PWAF6VOD-CTRtPw_P.js +1 -0
- package/web/assets/chunk-SHT3W25Y-ABe0cu9b.js +168 -0
- package/web/assets/chunk-SVP7TREG-cnVb9wRh.js +88 -0
- package/web/assets/chunk-TICWLB2K-DfKefWT6.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-B_nUbchH.js +1 -0
- package/web/assets/classDiagram-v2-VYDZK3BY-B_nUbchH.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-6Eym2I8q.js +1 -0
- package/web/assets/cynefinDiagram-5FMLGOSQ-DMcNShU2.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-jyxO30Ra.js +4 -0
- package/web/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/web/assets/diagram-S7CK7UJ4-DyJSgUWQ.js +30 -0
- package/web/assets/diagram-UQ7AKVKN-Bu-awgYc.js +41 -0
- package/web/assets/diagram-VSXAHHWV-CDG8WcFV.js +3 -0
- package/web/assets/diagram-VX7I27RA-eJK6IXRm.js +24 -0
- package/web/assets/diagram-Z3DM3KII-4bfg0YVc.js +24 -0
- package/web/assets/dist-v5Q1xZ2K.js +1 -0
- package/web/assets/ebnfDiagram-PWID7BFC-DLpKX0PA.js +1 -0
- package/web/assets/erDiagram-RLTQ6QDP-C9XoVCNz.js +99 -0
- package/web/assets/eventmodeling-NTZA5JFV-DB9bp7xg.js +1 -0
- package/web/assets/flowDiagram-HODETNUW-Bt84xHsk.js +1 -0
- package/web/assets/ganttDiagram-EL5Y4UJY-6GH5Yrgv.js +292 -0
- package/web/assets/gitGraph-4MIJSDKK-DFBb9ZSe.js +1 -0
- package/web/assets/gitGraphDiagram-WWUBYQGX-DVuclljM.js +106 -0
- package/web/assets/graphlib-DS17s2tU.js +1 -0
- package/web/assets/index-Dk7VU5lG.css +1 -0
- package/web/assets/index-H1S-0dr5.js +3 -0
- package/web/assets/info-A6RAGUB7-DS--l7eH.js +1 -0
- package/web/assets/infoDiagram-27XIBGKW-A9tXXovL.js +2 -0
- package/web/assets/init-C-OQMol4.js +1 -0
- package/web/assets/ishikawaDiagram-5VMMS53U-C3bulOe4.js +70 -0
- package/web/assets/journeyDiagram-3NMN7TZE-PFpzT1t0.js +139 -0
- package/web/assets/jsx-runtime-NZYk81nU.js +1 -0
- package/web/assets/kanban-definition-UXKFOSKX-C96gUxZE.js +89 -0
- package/web/assets/katex-ZlcWpGUi.js +257 -0
- package/web/assets/line-Bq-4Ddcs.js +1 -0
- package/web/assets/linear-SzVmGSw4.js +1 -0
- package/web/assets/mermaid-parser.core-BQCW99Ok.js +7 -0
- package/web/assets/mermaid.core-B30Wokvl.js +44 -0
- package/web/assets/mindmap-definition-YA3MSWOX-ClwtCWm0.js +96 -0
- package/web/assets/mount-B_ShSdyJ.js +26 -0
- package/web/assets/mount-BjlUftih.css +1 -0
- package/web/assets/ordinal-BDEzSJ7C.js +1 -0
- package/web/assets/packet-AYTQ26CC-YkeLgzv0.js +1 -0
- package/web/assets/path-fybaL0A-.js +1 -0
- package/web/assets/pegDiagram-XKGWAZYB-CaCibFki.js +1 -0
- package/web/assets/pie-WAS4IAKB-OyMK5OfZ.js +1 -0
- package/web/assets/pieDiagram-E7YTZNPT-DfGlDcKu.js +39 -0
- package/web/assets/quadrantDiagram-AXDQQJYC-C8ugAM99.js +7 -0
- package/web/assets/radar-RG4KPBEZ-DPu0k7LD.js +1 -0
- package/web/assets/railroad-74A4TZTK-Co59jOcB.js +1 -0
- package/web/assets/railroad-abnf-HS5TGJTU-Cw5NEYkY.js +1 -0
- package/web/assets/railroad-ebnf-LZEXJU2U-nB_gqNFI.js +1 -0
- package/web/assets/railroad-peg-WCYAUIDC-2XU5RRzN.js +1 -0
- package/web/assets/railroadDiagram-O6MQD6OU-COh170ve.js +1 -0
- package/web/assets/requirementDiagram-BXWQKSXE-Dh-wxNt2.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-BHOSWXWw.js +40 -0
- package/web/assets/sequenceDiagram-WJ2MYXX4-ik5jL4SR.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-Be31rRv9.js +1 -0
- package/web/assets/stateDiagram-v2-MP3YSRHH-DV2_zzd4.js +1 -0
- package/web/assets/swimlanes-42K2YHIH-DBK3hVdk.js +1 -0
- package/web/assets/swimlanesDiagram-VR7AAH4N-D_QAhSyB.js +8 -0
- package/web/assets/timeline-definition-24CTP7MA-DlW5MrNZ.js +120 -0
- package/web/assets/treeView-Q6P3EWNA-CfAW_qLZ.js +1 -0
- package/web/assets/treemap-WGGIJYW6-QxQMH5Y8.js +1 -0
- package/web/assets/useStore-BxmpBglW.js +27 -0
- package/web/assets/vennDiagram-4TSXK5OY-D91iwoPB.js +34 -0
- package/web/assets/wardley-WFR3VGLG-hq1e1BaT.js +1 -0
- package/web/assets/wardleyDiagram-VM6X3IG4-BDiSY_RF.js +78 -0
- package/web/assets/workbox-window.prod.es5-Bd17z0YL.js +2 -0
- package/web/assets/xychartDiagram-S5SC5T6Z-DX1lbV1q.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";
|
|
@@ -2521,18 +2522,18 @@ async function cmdContext(args = []) {
|
|
|
2521
2522
|
console.log(`\nEverything else: ${c.cyan("bivy help")} (or ${c.cyan("bivy help --json")}).`);
|
|
2522
2523
|
}
|
|
2523
2524
|
|
|
2524
|
-
// `bivy suggest "<task>" [--title "…"] [--session <id>]`
|
|
2525
|
-
// user can start in one tap,
|
|
2525
|
+
// `bivy suggest "<task>" [--title "…"] [--run here|subagents|new] [--session <id>]`
|
|
2526
|
+
// — propose a task the user can start in one tap, in this session or beside it. For an agent
|
|
2526
2527
|
// offering next steps: write the task as a complete instruction.
|
|
2527
2528
|
async function cmdSuggest(args = []) {
|
|
2528
|
-
const usage = 'Usage: bivy suggest "<task>" [--title "short label"] [--session <id>] [--json]';
|
|
2529
|
+
const usage = 'Usage: bivy suggest "<task>" [--title "short label"] [--run here|subagents|new] [--session <id>] [--json]';
|
|
2529
2530
|
if (args.includes("-h") || args.includes("--help")) {
|
|
2530
|
-
console.log(`${usage}\n\nPost a task the user can start in one tap
|
|
2531
|
+
console.log(`${usage}\n\nPost a task the user can start in one tap: in this session, through your sub-agents, or in a parallel session that works in its own copy of the project. Write it as a complete instruction, with paths relative to the project root.\n\n--run says where you recommend running it, which becomes the card's main button: here (builds on this conversation), subagents (independent tasks you can split across your own sub-agents; only if you have them), or new (bigger independent work the user will want to follow in its own session). Without it, one card recommends here and several recommend new.\n\n--json prints {"ok","id"}.`);
|
|
2531
2532
|
return;
|
|
2532
2533
|
}
|
|
2533
2534
|
const json = wantsJson(args);
|
|
2534
2535
|
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2535
|
-
const flagsWithValue = new Set(["--session", "--title"]);
|
|
2536
|
+
const flagsWithValue = new Set(["--session", "--title", "--run"]);
|
|
2536
2537
|
const flag = (name) => {
|
|
2537
2538
|
const i = args.indexOf(name);
|
|
2538
2539
|
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
|
|
@@ -2540,10 +2541,12 @@ async function cmdSuggest(args = []) {
|
|
|
2540
2541
|
const text = args.filter((a, i) => !a.startsWith("-") && !(i > 0 && flagsWithValue.has(args[i - 1]))).join(" ").trim();
|
|
2541
2542
|
const sessionId = resolveAttachSessionId({ sessionFlag: flag("--session"), env: process.env });
|
|
2542
2543
|
if (!text) return fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2544
|
+
const run = flag("--run");
|
|
2545
|
+
if (run !== undefined && !["here", "subagents", "new"].includes(run)) return fail({ code: "usage", message: `--run is here, subagents or new (got "${run}").`, exit: EXIT.usage });
|
|
2543
2546
|
if (!sessionId) return fail({ code: "no_session", message: "No session id.", hint: "Run inside an agent session ($BIVY_SESSION_ID) or pass --session <id>.", next: "bivy sessions --json", exit: EXIT.usage });
|
|
2544
2547
|
const config = loadConfig();
|
|
2545
2548
|
if (!(await ensureNodeRunning(config))) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node at ${url(config)}.`, next: "bivy status", exit: EXIT.unavailable });
|
|
2546
|
-
const res = await sessionPost(config, sessionId, "suggest", { text, title: flag("--title") }).catch((error) => error);
|
|
2549
|
+
const res = await sessionPost(config, sessionId, "suggest", { text, title: flag("--title"), run }).catch((error) => error);
|
|
2547
2550
|
if (res instanceof Error) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable });
|
|
2548
2551
|
const body = await res.json().catch(() => ({}));
|
|
2549
2552
|
if (!res.ok) return fail(sessionHttpError("Suggest", res.status, body));
|
|
@@ -5671,6 +5674,122 @@ async function cmdLinkPhone(args = []) {
|
|
|
5671
5674
|
}
|
|
5672
5675
|
}
|
|
5673
5676
|
|
|
5677
|
+
const tailscaleConfigPath = path.join(appDir, "tailscale.json");
|
|
5678
|
+
const LOCAL_TOKEN_NAMES = ["Bivy CLI", "Local Remote UI"];
|
|
5679
|
+
|
|
5680
|
+
function runTailscale(args) {
|
|
5681
|
+
const res = spawnSync("tailscale", args, { encoding: "utf8" });
|
|
5682
|
+
if (res.error?.code === "ENOENT") return null;
|
|
5683
|
+
return { ok: res.status === 0, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
5684
|
+
}
|
|
5685
|
+
|
|
5686
|
+
async function printPairLink(config) {
|
|
5687
|
+
const data = await localApi(config, "/api/auth/pair-codes", { method: "POST", body: "{}" });
|
|
5688
|
+
if (!data?.url) throw new Error("The node did not return a pairing link.");
|
|
5689
|
+
const qr = terminalQr(data.url);
|
|
5690
|
+
if (qr) console.log(qr);
|
|
5691
|
+
console.log(`\nOpen on your phone (single use, 10 minutes):\n${c.cyan(data.url)}\n`);
|
|
5692
|
+
}
|
|
5693
|
+
|
|
5694
|
+
// `bivy tailscale` — reach this machine from anywhere on your tailnet with no
|
|
5695
|
+
// control plane or relay: `tailscale serve` puts HTTPS on the machine's ts.net
|
|
5696
|
+
// name and forwards it to the node's direct listener, which serves the web app.
|
|
5697
|
+
async function cmdTailscale(args = []) {
|
|
5698
|
+
const [verb = "on"] = args;
|
|
5699
|
+
if (args.includes("-h") || args.includes("--help") || !["on", "pair", "status", "devices", "revoke", "off"].includes(verb)) {
|
|
5700
|
+
console.log(`Usage: bivy tailscale [on|pair|status|devices|revoke <id>|off]
|
|
5701
|
+
|
|
5702
|
+
on Serve this machine's Bivy on https://<machine>.<tailnet>.ts.net (tailnet only) and show a pairing link
|
|
5703
|
+
pair Show a new one-time pairing link for another device
|
|
5704
|
+
status Show the Tailscale address and whether it is served
|
|
5705
|
+
devices List the devices holding a token for this node
|
|
5706
|
+
revoke <id> Revoke a device's token and disconnect it
|
|
5707
|
+
off Stop serving Bivy over Tailscale`);
|
|
5708
|
+
return;
|
|
5709
|
+
}
|
|
5710
|
+
const config = loadConfig();
|
|
5711
|
+
if (!(await isReachable(config))) {
|
|
5712
|
+
console.log(c.yellow(`The node is not reachable at ${url(config)}. Start it first: ${c.cyan(config.service ? "bivy restart" : "bivy start")}`));
|
|
5713
|
+
process.exitCode = EXIT.unavailable;
|
|
5714
|
+
return;
|
|
5715
|
+
}
|
|
5716
|
+
const saved = (() => { try { return JSON.parse(fs.readFileSync(tailscaleConfigPath, "utf8")); } catch { return null; } })();
|
|
5717
|
+
|
|
5718
|
+
if (verb === "pair") {
|
|
5719
|
+
if (!saved) { console.log(`Tailscale access is off. Turn it on with ${c.cyan("bivy tailscale")}.`); process.exitCode = EXIT.failed; return; }
|
|
5720
|
+
await printPairLink(config);
|
|
5721
|
+
return;
|
|
5722
|
+
}
|
|
5723
|
+
if (verb === "devices") {
|
|
5724
|
+
// The CLI and loopback web client mint their own tokens on this machine; list them as a count.
|
|
5725
|
+
const all = (await localApi(config, "/api/auth/devices")) ?? [];
|
|
5726
|
+
const devices = all.filter((device) => !LOCAL_TOKEN_NAMES.includes(device.name));
|
|
5727
|
+
for (const device of devices) console.log(`${c.cyan(device.id)} ${device.name}${device.createdAt ? c.dim(` paired ${device.createdAt}`) : ""}`);
|
|
5728
|
+
if (!devices.length) console.log("No paired devices.");
|
|
5729
|
+
if (all.length > devices.length) console.log(c.dim(`(+${all.length - devices.length} tokens used on this machine by the CLI)`));
|
|
5730
|
+
return;
|
|
5731
|
+
}
|
|
5732
|
+
if (verb === "revoke") {
|
|
5733
|
+
const id = args[1];
|
|
5734
|
+
if (!id) { console.log("Usage: bivy tailscale revoke <id> (ids from 'bivy tailscale devices')"); process.exitCode = EXIT.usage; return; }
|
|
5735
|
+
const res = await fetch(`${url(config)}/api/auth/devices/${encodeURIComponent(id)}`, { method: "DELETE", headers: { authorization: `Bearer ${await localDeviceToken(config)}` } });
|
|
5736
|
+
if (!res.ok) { console.log(c.yellow(`No device ${id}.`)); process.exitCode = EXIT.notFound; return; }
|
|
5737
|
+
console.log(`${c.green("✓")} Revoked ${id}.`);
|
|
5738
|
+
return;
|
|
5739
|
+
}
|
|
5740
|
+
if (verb === "status") {
|
|
5741
|
+
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")}.`);
|
|
5742
|
+
return;
|
|
5743
|
+
}
|
|
5744
|
+
if (verb === "off") {
|
|
5745
|
+
if (saved) {
|
|
5746
|
+
const state = serveState(runTailscale(["serve", "status", "--json"])?.stdout, saved.hostname);
|
|
5747
|
+
if (state.target === `http://127.0.0.1:${saved.port}`) runTailscale(serveOffArgs());
|
|
5748
|
+
}
|
|
5749
|
+
fs.rmSync(tailscaleConfigPath, { force: true });
|
|
5750
|
+
await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
|
|
5751
|
+
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>'.`);
|
|
5752
|
+
return;
|
|
5753
|
+
}
|
|
5754
|
+
|
|
5755
|
+
const status = runTailscale(["status", "--json"]);
|
|
5756
|
+
if (!status) {
|
|
5757
|
+
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.`);
|
|
5758
|
+
process.exitCode = EXIT.unavailable;
|
|
5759
|
+
return;
|
|
5760
|
+
}
|
|
5761
|
+
const { running, hostname } = parseTailscaleStatus(status.stdout);
|
|
5762
|
+
if (!running || !hostname) {
|
|
5763
|
+
console.log(`${c.yellow("Tailscale isn't connected.")} Sign in with ${c.cyan("sudo tailscale up")}, then run ${c.cyan("bivy tailscale")} again.`);
|
|
5764
|
+
process.exitCode = EXIT.unavailable;
|
|
5765
|
+
return;
|
|
5766
|
+
}
|
|
5767
|
+
const port = saved?.port ?? await findAvailablePort(config.port + 2, "127.0.0.1");
|
|
5768
|
+
const conflict = serveConflict(serveState(runTailscale(["serve", "status", "--json"])?.stdout, hostname), port);
|
|
5769
|
+
if (conflict) {
|
|
5770
|
+
console.log(c.yellow(conflict));
|
|
5771
|
+
process.exitCode = EXIT.conflict;
|
|
5772
|
+
return;
|
|
5773
|
+
}
|
|
5774
|
+
fs.writeFileSync(tailscaleConfigPath, `${JSON.stringify({ port, hostname }, null, 2)}\n`, { mode: 0o600 });
|
|
5775
|
+
const reload = await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" });
|
|
5776
|
+
const served = runTailscale(serveOnArgs(port));
|
|
5777
|
+
if (!served?.ok) {
|
|
5778
|
+
fs.rmSync(tailscaleConfigPath, { force: true });
|
|
5779
|
+
await localApi(config, "/api/direct/reload", { method: "POST", body: "{}" }).catch(() => {});
|
|
5780
|
+
console.error(c.red(`tailscale serve failed: ${(served?.stderr || served?.stdout || "").trim()}`));
|
|
5781
|
+
const hint = serveFailureHint(served?.stderr);
|
|
5782
|
+
if (hint) console.log(hint);
|
|
5783
|
+
process.exitCode = EXIT.failed;
|
|
5784
|
+
return;
|
|
5785
|
+
}
|
|
5786
|
+
console.log(c.bold(c.green(`\n Bivy is on https://${hostname}\n`)));
|
|
5787
|
+
console.log(c.dim(" Only devices on your tailnet can reach it. Pair each one once with a link from 'bivy tailscale pair'."));
|
|
5788
|
+
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."));
|
|
5789
|
+
console.log("");
|
|
5790
|
+
await printPairLink(config);
|
|
5791
|
+
}
|
|
5792
|
+
|
|
5674
5793
|
function printUninstallHelp() {
|
|
5675
5794
|
console.log(`
|
|
5676
5795
|
${c.bold("bivy uninstall")} — remove Bivy and all its data from this machine
|
|
@@ -6268,6 +6387,9 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
|
|
|
6268
6387
|
case "link":
|
|
6269
6388
|
await cmdLinkPhone(args);
|
|
6270
6389
|
break;
|
|
6390
|
+
case "tailscale":
|
|
6391
|
+
await cmdTailscale(args);
|
|
6392
|
+
break;
|
|
6271
6393
|
case "relay:setup":
|
|
6272
6394
|
await cmdRelaySetup(args);
|
|
6273
6395
|
break;
|
package/bin/cli-commands.mjs
CHANGED
|
@@ -48,8 +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: "suggest", group: "session", scope: "session", json: true, usage: 'suggest "<task>" [--title "label"]', summary: "Propose a task the user can start in one tap",
|
|
52
|
-
tools: [{ name: "suggest_task", description: "Propose a next step the user can start in one tap
|
|
51
|
+
{ 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
|
+
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
53
|
{ name: "app", group: "session", scope: "session", json: true, usage: "app <publish|shot|present|share|run|…>", summary: "Live previews: publish a web/terminal/desktop app, screenshot it, present it", subcommands: ["publish", "list", "remove", "shot", "present", "share", "notes", "run", "click", "type", "key", "scroll", "drag", "move", "menu"],
|
|
54
54
|
tools: [
|
|
55
55
|
{ name: "app_publish", description: "Give the user a live preview of something with a UI, from an app manifest file (web server port, static build, terminal, or desktop app). Run `bivy app --help` for the manifest format.", input: { manifest_path: { type: "string", description: "Path to the manifest JSON.", required: true } }, argv: ["app", "publish", { arg: "manifest_path" }] },
|
|
@@ -103,6 +103,7 @@ export const COMMANDS = [
|
|
|
103
103
|
{ name: "token", group: "node", scope: "node", usage: "token", summary: "Print a device token for this node" },
|
|
104
104
|
{ name: "open", group: "node", scope: "node", usage: "open", summary: "Open the remote web app" },
|
|
105
105
|
{ name: "link", group: "node", scope: "node", usage: "link", summary: "Show a web app link as a QR code in the terminal" },
|
|
106
|
+
{ 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
107
|
{ name: "relay:setup", group: "node", scope: "node", usage: "relay:setup", summary: "Enable secure remote web app access" },
|
|
107
108
|
{ name: "uninstall", group: "node", scope: "node", usage: "uninstall [--keep-sessions] [--dry-run]", summary: "Remove Bivy and all its data" },
|
|
108
109
|
|
|
@@ -28,9 +28,18 @@ Ask only when you can't reasonably decide yourself.
|
|
|
28
28
|
|
|
29
29
|
bivy suggest "Add a GET /version endpoint that returns the package version." --title "Add /version"
|
|
30
30
|
|
|
31
|
-
Each suggestion is a card the user can start in one tap
|
|
32
|
-
session with its own copy of the project. Write it as
|
|
33
|
-
paths relative to the project root. Post one per idea
|
|
31
|
+
Each suggestion is a card the user can start in one tap: here, through your
|
|
32
|
+
sub-agents, or in a parallel session with its own copy of the project. Write it as
|
|
33
|
+
a complete instruction with paths relative to the project root. Post one per idea
|
|
34
|
+
instead of a bulleted list.
|
|
35
|
+
|
|
36
|
+
`--run` picks the card's main button; the others stay one tap away:
|
|
37
|
+
|
|
38
|
+
| `--run` | When |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `here` | It builds on this conversation, or it's small. Default for a single card. |
|
|
41
|
+
| `subagents` | Independent tasks you can split across your own sub-agents and report back on. Only if you have sub-agents. |
|
|
42
|
+
| `new` | Bigger independent work the user will want to follow, review or merge on its own. Default for several cards. |
|
|
34
43
|
|
|
35
44
|
## Is anyone there?
|
|
36
45
|
|
|
@@ -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
|
+
}
|
|
@@ -39,9 +39,12 @@ export const BIVY_AGENT_NOTE = [
|
|
|
39
39
|
"can check your work; `bivy app present` tells the user a visible change is ready to look at. " +
|
|
40
40
|
"`bivy app --help` has the details.",
|
|
41
41
|
"- Proposing tasks the user could hand you (next steps, ideas, options): post each with " +
|
|
42
|
-
'`bivy suggest "<complete instruction>" [--title "short label"]` instead of only
|
|
43
|
-
"start each in one tap, in this session or in a parallel
|
|
44
|
-
"write it to stand on its own, with paths relative to the
|
|
42
|
+
'`bivy suggest "<complete instruction>" [--title "short label"] [--run here|subagents|new]` instead of only ' +
|
|
43
|
+
"listing them. The user can start each in one tap, in this session, through your sub-agents, or in a parallel " +
|
|
44
|
+
"one that works in its own copy of the project, so write it to stand on its own, with paths relative to the " +
|
|
45
|
+
"project root. `--run` is the one you recommend: here when it builds on this conversation, subagents for " +
|
|
46
|
+
"independent tasks you can split and supervise (only if you have sub-agents), new for bigger work worth its " +
|
|
47
|
+
"own session.",
|
|
45
48
|
"- When you finish long work, get blocked, or need the user to look at something: " +
|
|
46
49
|
'`bivy notify "<message>"` (a chat card, plus a push to their phone when they are away). To ask and wait for ' +
|
|
47
50
|
'an answer: `bivy ask "<question>" [--option A --option B]` prints their answer (or use your own ask-the-user tool).',
|
package/dist/apps/styles.css
CHANGED
|
@@ -3128,8 +3128,9 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
|
|
|
3128
3128
|
.suggestion-title { margin: 0; font-size: var(--text-base); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
|
3129
3129
|
.suggestion-text { margin: 0; color: var(--muted); font-size: var(--text-sm); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
|
3130
3130
|
.suggestion-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
|
|
3131
|
-
.suggestion-all { display: flex; flex-direction: column; gap: var(--space-1); margin-top: var(--space-2);
|
|
3132
|
-
.suggestion-all
|
|
3131
|
+
.suggestion-bar, .suggestion-all { display: flex; flex-direction: column; gap: var(--space-1); margin-top: var(--space-2); }
|
|
3132
|
+
.suggestion-all { padding-top: var(--space-2); border-top: 1px solid var(--line); }
|
|
3133
|
+
.suggestion-bar .suggestion-actions, .suggestion-all .suggestion-actions { margin-top: 0; }
|
|
3133
3134
|
.suggestion-hint { margin: 0; color: var(--muted); font-size: var(--text-xs); }
|
|
3134
3135
|
/* In a set, the title is the checkbox's label, so the whole line is the tap target. */
|
|
3135
3136
|
.suggestion-pick { display: flex; align-items: flex-start; gap: var(--space-2); cursor: pointer; }
|
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
|
@@ -74,7 +74,7 @@ import { exportProviderAuth, exportAccountApiKeys, exportAccountOAuthCredentials
|
|
|
74
74
|
import { listProviders } from "./runtime/provider-catalog.js";
|
|
75
75
|
import { exportLocalModels, importLocalModels } from "./runtime/local-model-store.js";
|
|
76
76
|
import { sessionLikeFields } from "./session/start-like.js";
|
|
77
|
-
import { MAX_SUGGESTION_TEXT, MAX_SUGGESTION_TITLE, isTaskSuggestion } from "./session/suggestions.js";
|
|
77
|
+
import { MAX_SUGGESTION_TEXT, MAX_SUGGESTION_TITLE, SUGGESTION_RUNS, isTaskSuggestion } from "./session/suggestions.js";
|
|
78
78
|
import { BIVY_AGENT_NOTE, mergeSyncedAgentInstructions, readAgentInstructions, sessionInstructions, writeAgentInstructions, MAX_AGENT_INSTRUCTIONS_BYTES } from "./agent-instructions.js";
|
|
79
79
|
import { execEphemeralRequest } from "./ephemeral-exec.js";
|
|
80
80
|
import { ApprovalManager } from "./approval.js";
|
|
@@ -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`
|
|
@@ -9337,7 +9347,8 @@ app.use((_req, res, next) => {
|
|
|
9337
9347
|
// (OAuth callback, GitHub App manifest). It does NOT host the web UI — the
|
|
9338
9348
|
// React/Vite PWA (@bivy/web) is served exclusively by the hosted or self-hosted
|
|
9339
9349
|
// control plane. `/` returns a small informational stub for anyone who points a
|
|
9340
|
-
// browser straight at the node.
|
|
9350
|
+
// browser straight at the node. The one exception is the direct listener for
|
|
9351
|
+
// `bivy tailscale` (below), which has no control plane and serves the app itself.
|
|
9341
9352
|
app.get("/", (_req, res) => {
|
|
9342
9353
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
9343
9354
|
res
|
|
@@ -9447,6 +9458,20 @@ app.post("/api/auth/bootstrap", sensitiveRateLimiter, (req, res) => {
|
|
|
9447
9458
|
const { device, token } = accessDevices.create(name);
|
|
9448
9459
|
res.json({ ok: true, device, token });
|
|
9449
9460
|
});
|
|
9461
|
+
// Pairing a device that reaches the node directly (Tailscale): trade a one-time
|
|
9462
|
+
// code minted on this machine for a device token. Registered before the auth
|
|
9463
|
+
// middleware because the caller has no token yet; the code is the credential.
|
|
9464
|
+
const pairCodes = new PairCodes();
|
|
9465
|
+
app.post("/api/auth/pair", sensitiveRateLimiter, (req, res) => {
|
|
9466
|
+
if (!requestOriginAllowed(req))
|
|
9467
|
+
return res.status(403).json({ error: "Forbidden origin" });
|
|
9468
|
+
if (!pairCodes.redeem(String(req.body?.code ?? ""))) {
|
|
9469
|
+
return res.status(401).json({ error: "This pairing link is used or expired. Run 'bivy tailscale pair' on your machine for a new one." });
|
|
9470
|
+
}
|
|
9471
|
+
const name = String(req.body?.name ?? "").trim().slice(0, 80) || "Paired device";
|
|
9472
|
+
const { device, token } = accessDevices.create(name);
|
|
9473
|
+
res.json({ ok: true, device, token });
|
|
9474
|
+
});
|
|
9450
9475
|
// All other /api routes require auth (loopback may bypass, per config and host
|
|
9451
9476
|
// — see loopbackAllowed()/isMultiUserHost() in src/auth.ts).
|
|
9452
9477
|
app.use("/api", authMiddleware(identity, {
|
|
@@ -9457,6 +9482,23 @@ app.use("/api", authMiddleware(identity, {
|
|
|
9457
9482
|
used: (sessionId, call) => { if (call.method !== "GET")
|
|
9458
9483
|
auditLog.record({ kind: "agent.call", session: sessionId, tool: `${call.method} ${call.path}` }); },
|
|
9459
9484
|
}));
|
|
9485
|
+
// Mint a pairing code for a direct (Tailscale) device. Only callers already
|
|
9486
|
+
// trusted by this node get one: the owner's CLI, or an already-paired device.
|
|
9487
|
+
app.post("/api/auth/pair-codes", (_req, res) => {
|
|
9488
|
+
const direct = loadDirectListenerConfig(appDir);
|
|
9489
|
+
const { code, expiresAt } = pairCodes.issue();
|
|
9490
|
+
const url = direct?.hostname ? `https://${direct.hostname}/#pair=${code}` : undefined;
|
|
9491
|
+
res.json({ ok: true, code, expiresAt, url });
|
|
9492
|
+
});
|
|
9493
|
+
// Start, move or stop the direct listener after `bivy tailscale` changes it.
|
|
9494
|
+
app.post("/api/direct/reload", async (_req, res, next) => {
|
|
9495
|
+
try {
|
|
9496
|
+
res.json({ ok: true, ...(await reloadDirectListener()) });
|
|
9497
|
+
}
|
|
9498
|
+
catch (error) {
|
|
9499
|
+
next(error);
|
|
9500
|
+
}
|
|
9501
|
+
});
|
|
9460
9502
|
// Reload relay.json without forcing the user to restart the whole node. This is
|
|
9461
9503
|
// used by `bivy relay:setup` after it enrolls the node.
|
|
9462
9504
|
app.post("/api/relay/reload", (_req, res) => {
|
|
@@ -11666,16 +11708,18 @@ app.get("/api/session/:id/automations/apply/:proposalId", (req, res) => {
|
|
|
11666
11708
|
res.json(proposal);
|
|
11667
11709
|
});
|
|
11668
11710
|
// `bivy suggest "<task>"`: the agent proposes a task the user can start in one
|
|
11669
|
-
// tap, here or in a parallel session
|
|
11711
|
+
// tap, here, through this agent's sub-agents, or in a parallel session; `run`
|
|
11712
|
+
// is the one the agent recommends (see packages/web SuggestionCard).
|
|
11670
11713
|
app.post("/api/session/:id/suggest", (req, res) => {
|
|
11671
11714
|
const record = openSessions.get(String(req.params.id));
|
|
11672
11715
|
if (!record)
|
|
11673
11716
|
return res.status(404).json({ error: "Session not found" });
|
|
11674
11717
|
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
11675
11718
|
const title = typeof req.body?.title === "string" && req.body.title.trim() ? req.body.title.trim() : undefined;
|
|
11676
|
-
const
|
|
11719
|
+
const run = req.body?.run || undefined;
|
|
11720
|
+
const suggestion = { id: `suggestion-${randomBytes(8).toString("hex")}`, text, ...(title ? { title } : {}), ...(run ? { run } : {}) };
|
|
11677
11721
|
if (!isTaskSuggestion(suggestion))
|
|
11678
|
-
return res.status(400).json({ error: `A suggestion needs text (up to ${MAX_SUGGESTION_TEXT} characters)
|
|
11722
|
+
return res.status(400).json({ error: `A suggestion needs text (up to ${MAX_SUGGESTION_TEXT} characters), an optional title (up to ${MAX_SUGGESTION_TITLE}) and an optional run (${SUGGESTION_RUNS.join(", ")}).` });
|
|
11679
11723
|
eventLog.appendSuggestion(record.id, { afterMessageCount: record.session.getMessages().length, suggestion });
|
|
11680
11724
|
broadcast(stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "suggestion", id: suggestion.id, suggestion } }));
|
|
11681
11725
|
res.json({ ok: true, id: suggestion.id });
|
|
@@ -12005,6 +12049,7 @@ const server = app.listen(port, host, async () => {
|
|
|
12005
12049
|
console.log(`Agent data dir: ${piDir}`);
|
|
12006
12050
|
console.log(`Workspace: ${defaultWorkspace}`);
|
|
12007
12051
|
startRelayIfConfigured();
|
|
12052
|
+
void reloadDirectListener().catch((error) => console.error("[direct] Could not start the direct listener:", error instanceof Error ? error.message : error));
|
|
12008
12053
|
void syncAgentBridges().catch((error) => console.warn("[bridges] Could not install agent bridges; they will install on first use:", error instanceof Error ? error.message : error));
|
|
12009
12054
|
if (appGateway) {
|
|
12010
12055
|
appGateway.server.on("error", (error) => { console.error("[apps] Preview gateway could not start:", error); shutdown("preview gateway failure"); });
|
|
@@ -12071,6 +12116,46 @@ server.on("error", (error) => {
|
|
|
12071
12116
|
process.exit(1);
|
|
12072
12117
|
});
|
|
12073
12118
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
|
12119
|
+
// Direct listener (`bivy tailscale`): the same API and WebSocket, plus the web
|
|
12120
|
+
// app, on a loopback port `tailscale serve` exposes to the tailnet over HTTPS.
|
|
12121
|
+
// markRemoteListener keeps its proxied, loopback-looking connections from ever
|
|
12122
|
+
// getting loopback trust; every caller needs a device token.
|
|
12123
|
+
const directWebApp = webAppRouter(webAppDirs(repoRoot));
|
|
12124
|
+
let directListener = null;
|
|
12125
|
+
async function reloadDirectListener() {
|
|
12126
|
+
const config = loadDirectListenerConfig(appDir);
|
|
12127
|
+
if (directListener && directListener.port !== config?.port) {
|
|
12128
|
+
const previous = directListener;
|
|
12129
|
+
directListener = null;
|
|
12130
|
+
for (const client of previous.wss.clients)
|
|
12131
|
+
client.terminate();
|
|
12132
|
+
previous.wss.close();
|
|
12133
|
+
previous.server.closeAllConnections();
|
|
12134
|
+
await new Promise((resolve) => previous.server.close(() => resolve()));
|
|
12135
|
+
}
|
|
12136
|
+
if (config && !directListener) {
|
|
12137
|
+
const directApp = express();
|
|
12138
|
+
directApp.disable("x-powered-by");
|
|
12139
|
+
if (directWebApp)
|
|
12140
|
+
directApp.use(directWebApp);
|
|
12141
|
+
directApp.use(app);
|
|
12142
|
+
const listener = http.createServer(directApp);
|
|
12143
|
+
markRemoteListener(listener);
|
|
12144
|
+
const directWss = new WebSocketServer({ server: listener, path: "/ws" });
|
|
12145
|
+
directWss.on("connection", (socket, req) => wss.emit("connection", socket, req));
|
|
12146
|
+
await new Promise((resolve, reject) => {
|
|
12147
|
+
listener.once("error", reject);
|
|
12148
|
+
listener.listen(config.port, "127.0.0.1", () => {
|
|
12149
|
+
listener.off("error", reject);
|
|
12150
|
+
resolve();
|
|
12151
|
+
});
|
|
12152
|
+
});
|
|
12153
|
+
listener.on("error", (error) => console.error("[direct] Listener error:", error));
|
|
12154
|
+
directListener = { port: config.port, server: listener, wss: directWss };
|
|
12155
|
+
console.log(`[direct] Listening on 127.0.0.1:${config.port}${config.hostname ? ` for https://${config.hostname}` : ""}${directWebApp ? "" : " (web app not built; API only)"}`);
|
|
12156
|
+
}
|
|
12157
|
+
return { enabled: Boolean(directListener), port: directListener?.port, webApp: Boolean(directWebApp) };
|
|
12158
|
+
}
|
|
12074
12159
|
wss.on("connection", (socket, req) => {
|
|
12075
12160
|
// Reject cross-origin / DNS-rebinding upgrades before auth: loopback bypasses
|
|
12076
12161
|
// auth by default, so a page the user merely visited could otherwise open a
|
|
@@ -12079,11 +12164,14 @@ wss.on("connection", (socket, req) => {
|
|
|
12079
12164
|
socket.close(1008, "Forbidden origin");
|
|
12080
12165
|
return;
|
|
12081
12166
|
}
|
|
12082
|
-
|
|
12167
|
+
const socketAuth = resolveAuth(identity, req);
|
|
12168
|
+
if (!isAuthorized(socketAuth)) {
|
|
12083
12169
|
socket.close(1008, "Unauthorized");
|
|
12084
12170
|
return;
|
|
12085
12171
|
}
|
|
12086
12172
|
clients.add(socket);
|
|
12173
|
+
if (socketAuth.deviceId)
|
|
12174
|
+
socketDeviceIds.set(socket, socketAuth.deviceId);
|
|
12087
12175
|
// Terminals opened on this socket; closed when the socket disconnects so a
|
|
12088
12176
|
// dropped browser tab doesn't leak shells. Output is unicast to this socket.
|
|
12089
12177
|
const ownedTerminals = new Set();
|