@bivy/bivy 0.19.4-staging.3 → 0.19.4-staging.5
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 +353 -1
- package/bin/cli-commands.mjs +66 -3
- package/bin/guides/automate.md +19 -0
- package/bin/guides/long-work.md +23 -0
- package/bin/guides/more-hands.md +24 -0
- package/bin/guides/show-the-user.md +49 -0
- package/bin/guides/talk-to-the-user.md +38 -0
- package/dist/agent-instructions.js +5 -1
- package/dist/apps/styles.css +8 -2
- package/dist/harness/mcp-serve-cli.js +70 -8
- package/dist/protocol/client-command-routes.js +1 -0
- package/dist/protocol/client-command-schemas.js +1 -0
- package/dist/question.js +1 -1
- package/dist/server.js +58 -0
- package/dist/session/agent-context.js +1 -0
- package/dist/session/agent-questions.js +94 -0
- package/dist/session/event-log.js +17 -2
- package/dist/session/notices.js +25 -0
- package/package.json +1 -1
package/bin/bivy.mjs
CHANGED
|
@@ -43,7 +43,7 @@ import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
|
|
|
43
43
|
import { resolveAttachSessionId } from "./attach-session-id.mjs";
|
|
44
44
|
import { detectInstallKind as classifyInstallKind, npmGlobalPrefix } from "./install-kind.mjs";
|
|
45
45
|
import { hasConfiguredService as configuredServiceExists } from "./service-state.mjs";
|
|
46
|
-
import { COMMANDS, EXIT, cliError, completionWords, describeCli, describeCommand, renderHelp, resolveCommand, subcommandTable, suggestCommands, wantsJson } from "./cli-commands.mjs";
|
|
46
|
+
import { COMMANDS, EXIT, cliError, completionWords, describeCli, describeCommand, describeTools, renderHelp, resolveCommand, subcommandTable, suggestCommands, toolArgv, wantsJson } from "./cli-commands.mjs";
|
|
47
47
|
|
|
48
48
|
const selfScript = fileURLToPath(import.meta.url);
|
|
49
49
|
const __dirname = path.dirname(selfScript);
|
|
@@ -2541,6 +2541,283 @@ async function cmdSuggest(args = []) {
|
|
|
2541
2541
|
console.log(c.green("Suggested in the chat. The user can start it in one tap."));
|
|
2542
2542
|
}
|
|
2543
2543
|
|
|
2544
|
+
// Flags shared by the session commands: `--name value` pairs and bare switches,
|
|
2545
|
+
// with every other word collected as positional text.
|
|
2546
|
+
function parseSessionArgs(args, valueFlags) {
|
|
2547
|
+
const values = {};
|
|
2548
|
+
const positional = [];
|
|
2549
|
+
for (let i = 0; i < args.length; i++) {
|
|
2550
|
+
const a = args[i];
|
|
2551
|
+
if (valueFlags.includes(a)) {
|
|
2552
|
+
const v = args[i + 1];
|
|
2553
|
+
if (v === undefined) return { error: `${a} requires a value.` };
|
|
2554
|
+
(values[a] ??= []).push(v);
|
|
2555
|
+
i++;
|
|
2556
|
+
} else if (!a.startsWith("--")) positional.push(a);
|
|
2557
|
+
}
|
|
2558
|
+
return { values, positional, has: (flag) => args.includes(flag), one: (flag) => values[flag]?.[0] };
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
// "90", "90s", "10m", "2h" → seconds.
|
|
2562
|
+
function durationSeconds(text) {
|
|
2563
|
+
const m = /^(\d+)(s|m|h)?$/.exec(String(text ?? "").trim());
|
|
2564
|
+
return m ? Number(m[1]) * (m[2] === "h" ? 3600 : m[2] === "m" ? 60 : 1) : NaN;
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
// `bivy notify "<message>" [--urgent]` — reach the user: a card in the chat,
|
|
2568
|
+
// and a push naming the session (never the text) when nobody has the app open.
|
|
2569
|
+
async function cmdNotify(args = []) {
|
|
2570
|
+
const usage = 'Usage: bivy notify "<message>" [--urgent] [--session <id>] [--json]';
|
|
2571
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2572
|
+
console.log(`${usage}
|
|
2573
|
+
|
|
2574
|
+
Send the user a message: it appears as a card in the chat, and their devices get
|
|
2575
|
+
a push notification (naming this session, not the text) when nobody has the app
|
|
2576
|
+
open. --urgent pushes even while they do. At most one push a minute per session;
|
|
2577
|
+
later messages still reach the chat. Use it when you finish long work, get
|
|
2578
|
+
blocked, or need the user to look at something. --json prints
|
|
2579
|
+
{"ok","id","push":"sent"|"user_watching"|"rate_limited","userWatching"}.`);
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
const json = wantsJson(args);
|
|
2583
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2584
|
+
const parsed = parseSessionArgs(args, ["--session"]);
|
|
2585
|
+
if (parsed.error) return fail({ code: "usage", message: parsed.error, exit: EXIT.usage });
|
|
2586
|
+
const text = parsed.positional.join(" ").trim();
|
|
2587
|
+
if (!text) return fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2588
|
+
const sessionId = resolveAttachSessionId({ sessionFlag: parsed.one("--session"), env: process.env });
|
|
2589
|
+
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 });
|
|
2590
|
+
const config = loadConfig();
|
|
2591
|
+
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 });
|
|
2592
|
+
const res = await sessionPost(config, sessionId, "notify", { text, urgent: parsed.has("--urgent") }).catch((error) => error);
|
|
2593
|
+
if (res instanceof Error) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable });
|
|
2594
|
+
const body = await res.json().catch(() => ({}));
|
|
2595
|
+
if (!res.ok) return fail(sessionHttpError("Notify", res.status, body));
|
|
2596
|
+
if (json) { console.log(JSON.stringify(body)); return; }
|
|
2597
|
+
const pushed = { sent: "and pushed to the user's devices", user_watching: "(no push: the user has the app open; add --urgent to push anyway)", rate_limited: "(no push: this session pushed less than a minute ago)", unavailable: "(no push: this machine isn't signed in to a Bivy account)" }[body.push] ?? "";
|
|
2598
|
+
console.log(c.green(`Posted in the chat ${pushed}.`.replace(" .", ".")));
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
// `bivy ask "<question>" [--option …]` — the question card, for any agent.
|
|
2602
|
+
// Blocks until the user answers (printing the answer), or with --async returns
|
|
2603
|
+
// an id to read later with `bivy ask status|wait <id>`.
|
|
2604
|
+
const ASK_USAGE = `Usage: bivy ask "<question>" [--option <label>]… [--multi] [--header <label>] [--timeout 10m] [--async] [--json]
|
|
2605
|
+
bivy ask status <id> [--json]
|
|
2606
|
+
bivy ask wait <id> [--timeout 10m] [--json]`;
|
|
2607
|
+
|
|
2608
|
+
async function cmdAsk(args = []) {
|
|
2609
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2610
|
+
console.log(`${ASK_USAGE}
|
|
2611
|
+
|
|
2612
|
+
Ask the user a question in the chat and wait for the answer. Their devices get a
|
|
2613
|
+
"needs your input" push. With --option (2 to 8 of them) they pick one (or
|
|
2614
|
+
several with --multi) or write their own; without options they type an answer.
|
|
2615
|
+
--header is the short label on the card.
|
|
2616
|
+
|
|
2617
|
+
Prints the answer and exits 0. Exits 1 if the user dismissed the question and 5
|
|
2618
|
+
if --timeout (default 10m, up to 24h) passes first; the card closes then.
|
|
2619
|
+
--async returns the question's id at once; 'bivy ask wait <id>' blocks for the
|
|
2620
|
+
answer later and 'bivy ask status <id>' checks without waiting.
|
|
2621
|
+
--json prints {"id","status":"pending"|"answered"|"dismissed"|"expired","answer"}.`);
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
const json = wantsJson(args);
|
|
2625
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2626
|
+
const parsed = parseSessionArgs(args, ["--session", "--option", "--header", "--timeout"]);
|
|
2627
|
+
if (parsed.error) return fail({ code: "usage", message: parsed.error, exit: EXIT.usage });
|
|
2628
|
+
const sessionId = resolveAttachSessionId({ sessionFlag: parsed.one("--session"), env: process.env });
|
|
2629
|
+
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 });
|
|
2630
|
+
const timeout = parsed.one("--timeout") === undefined ? 600 : durationSeconds(parsed.one("--timeout"));
|
|
2631
|
+
if (!Number.isFinite(timeout) || timeout < 1) return fail({ code: "usage", message: "--timeout takes seconds or a duration like 90s, 10m or 2h.", exit: EXIT.usage });
|
|
2632
|
+
const config = loadConfig();
|
|
2633
|
+
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 });
|
|
2634
|
+
let token;
|
|
2635
|
+
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2636
|
+
const base = `${url(config)}/api/session/${encodeURIComponent(sessionId)}/ask`;
|
|
2637
|
+
const call = async (suffix, body) => {
|
|
2638
|
+
const res = await fetch(`${base}${suffix}`, {
|
|
2639
|
+
method: body ? "POST" : "GET",
|
|
2640
|
+
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
2641
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
2642
|
+
}).catch((error) => error);
|
|
2643
|
+
if (res instanceof Error) return fail({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable });
|
|
2644
|
+
const data = await res.json().catch(() => ({}));
|
|
2645
|
+
if (!res.ok) return fail(sessionHttpError("Ask", res.status, data));
|
|
2646
|
+
return data;
|
|
2647
|
+
};
|
|
2648
|
+
const answerText = (result) => Object.values(result.answers ?? {}).join("\n");
|
|
2649
|
+
const finish = (result) => {
|
|
2650
|
+
const out = { id: result.id, status: result.status, ...(result.status === "answered" ? { answer: answerText(result) } : {}) };
|
|
2651
|
+
if (result.status === "answered") { console.log(json ? JSON.stringify(out) : out.answer); return; }
|
|
2652
|
+
if (result.status === "pending") { console.log(json ? JSON.stringify(out) : `Still waiting for an answer. Check again with: bivy ask wait ${result.id}`); process.exit(EXIT.timeout); }
|
|
2653
|
+
if (json) console.log(JSON.stringify(out));
|
|
2654
|
+
else console.error(result.status === "expired" ? "The question timed out without an answer." : "The user dismissed the question without answering.");
|
|
2655
|
+
process.exit(result.status === "expired" ? EXIT.timeout : EXIT.failed);
|
|
2656
|
+
};
|
|
2657
|
+
const waitFor = async (id, seconds) => {
|
|
2658
|
+
const deadline = Date.now() + seconds * 1000;
|
|
2659
|
+
let result = await call(`/${encodeURIComponent(id)}`);
|
|
2660
|
+
while (result.status === "pending" && Date.now() < deadline) {
|
|
2661
|
+
result = await call(`/${encodeURIComponent(id)}/wait`, { timeoutSeconds: Math.max(1, Math.min(240, Math.ceil((deadline - Date.now()) / 1000))) });
|
|
2662
|
+
}
|
|
2663
|
+
return result;
|
|
2664
|
+
};
|
|
2665
|
+
|
|
2666
|
+
const [action, id] = parsed.positional;
|
|
2667
|
+
if ((action === "status" || action === "wait") && id && parsed.positional.length === 2) {
|
|
2668
|
+
const result = action === "status" ? await call(`/${encodeURIComponent(id)}`) : await waitFor(id, timeout);
|
|
2669
|
+
if (action === "status" && result.status === "pending") { console.log(json ? JSON.stringify({ id: result.id, status: "pending" }) : "Still waiting for an answer."); return; }
|
|
2670
|
+
return finish(result);
|
|
2671
|
+
}
|
|
2672
|
+
const question = parsed.positional.join(" ").trim();
|
|
2673
|
+
if (!question) return fail({ code: "usage", message: ASK_USAGE, exit: EXIT.usage });
|
|
2674
|
+
const options = parsed.values["--option"] ?? [];
|
|
2675
|
+
if (options.length === 1) return fail({ code: "usage", message: "Offer at least two --option values, or none for a free-text answer.", exit: EXIT.usage });
|
|
2676
|
+
const started = await call("", {
|
|
2677
|
+
questions: [{ question, header: parsed.one("--header"), options, multiSelect: parsed.has("--multi") }],
|
|
2678
|
+
timeoutSeconds: timeout,
|
|
2679
|
+
});
|
|
2680
|
+
if (parsed.has("--async")) {
|
|
2681
|
+
console.log(json ? JSON.stringify({ id: started.id, status: started.status }) : `Asked. Get the answer with: bivy ask wait ${started.id}`);
|
|
2682
|
+
return;
|
|
2683
|
+
}
|
|
2684
|
+
finish(await waitFor(started.id, timeout + 5));
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
// One JSON request to the local node for the commands below: starts the node if
|
|
2688
|
+
// needed and returns { status, body }, or fails in the shared error shape.
|
|
2689
|
+
async function nodeRequest(method, pathname, body, { json = false } = {}) {
|
|
2690
|
+
const config = loadConfig();
|
|
2691
|
+
if (!(await ensureNodeRunning(config))) return cliError({ code: "node_unreachable", message: `Could not reach the Bivy node at ${url(config)}.`, next: "bivy status", exit: EXIT.unavailable }, { json, paint: c.red });
|
|
2692
|
+
let token;
|
|
2693
|
+
try { token = await localDeviceToken(config); } catch { token = undefined; }
|
|
2694
|
+
const res = await fetch(`${url(config)}${pathname}`, {
|
|
2695
|
+
method,
|
|
2696
|
+
headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
2697
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
2698
|
+
}).catch((error) => error);
|
|
2699
|
+
if (res instanceof Error) return cliError({ code: "node_unreachable", message: `Could not reach the Bivy node: ${res.message}`, next: "bivy status", exit: EXIT.unavailable }, { json, paint: c.red });
|
|
2700
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
// `bivy fork [session-id]` — the app's Fork on this machine: a new session with
|
|
2704
|
+
// the same conversation, agent and uncommitted work, on its own branch. Give it
|
|
2705
|
+
// its next task with `bivy send <new-id> "…"`.
|
|
2706
|
+
async function cmdFork(args = []) {
|
|
2707
|
+
const usage = "Usage: bivy fork [session-id] [--model <provider/id>] [--json]";
|
|
2708
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2709
|
+
console.log(`${usage}\n\nCopy a session (default: this one, $BIVY_SESSION_ID) into a new session on this machine: the same conversation and agent, with uncommitted work carried into a fresh worktree on its own branch. --model switches the fork's model. Prints the new session's id; send it work with 'bivy send <id> "…"'. --json prints {"sessionId","fidelity"}.`);
|
|
2710
|
+
return;
|
|
2711
|
+
}
|
|
2712
|
+
const json = wantsJson(args);
|
|
2713
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2714
|
+
const parsed = parseSessionArgs(args, ["--model", "--session"]);
|
|
2715
|
+
if (parsed.error) return fail({ code: "usage", message: parsed.error, exit: EXIT.usage });
|
|
2716
|
+
const sessionId = parsed.positional[0] ?? resolveAttachSessionId({ sessionFlag: parsed.one("--session"), env: process.env });
|
|
2717
|
+
if (!sessionId) return fail({ code: "no_session", message: "No session to fork.", hint: "Pass a session id, or run inside an agent session ($BIVY_SESSION_ID).", next: "bivy sessions --json", exit: EXIT.usage });
|
|
2718
|
+
// "provider/id" or a bare model id, as the fork command takes it.
|
|
2719
|
+
const modelArg = parsed.one("--model");
|
|
2720
|
+
const slash = modelArg?.indexOf("/") ?? -1;
|
|
2721
|
+
const model = modelArg ? (slash > 0 ? { provider: modelArg.slice(0, slash), id: modelArg.slice(slash + 1) } : { provider: "", id: modelArg }) : undefined;
|
|
2722
|
+
const { status, body } = await nodeRequest("POST", "/api/session/fork", { sessionId, ...(model ? { model } : {}) }, { json });
|
|
2723
|
+
if (status === 404) return fail({ code: "node_outdated", message: "This node is older than 'bivy fork'.", next: "bivy update", exit: EXIT.unavailable });
|
|
2724
|
+
if (status >= 400 || body.error) return fail({ code: /not found/i.test(body.error ?? "") ? "session_not_found" : "failed", message: `Fork failed: ${body.error || `HTTP ${status}`}`, exit: /not found/i.test(body.error ?? "") ? EXIT.notFound : EXIT.failed });
|
|
2725
|
+
if (json) { console.log(JSON.stringify({ sessionId: body.sessionId, fidelity: body.fidelity, ...(body.missing?.length ? { missing: body.missing } : {}) })); return; }
|
|
2726
|
+
console.log(c.green(`Forked into ${body.sessionId}.`) + ` Give it work with: bivy send ${body.sessionId} "…"`);
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
// `bivy approvals` — tool calls waiting for a person, and answering them. For a
|
|
2730
|
+
// supervising agent or script; the app shows the same requests as cards.
|
|
2731
|
+
async function cmdApprovals(args = []) {
|
|
2732
|
+
const usage = "Usage: bivy approvals [list] [--json]\n bivy approvals approve <id> [--json]\n bivy approvals reject <id> [--json]";
|
|
2733
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2734
|
+
console.log(`${usage}\n\nList the tool calls on this node waiting for approval, or answer one. The app shows the same requests as cards; answering here resolves them there too.`);
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
const json = wantsJson(args);
|
|
2738
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2739
|
+
const [action = "list", id] = args.filter((a) => !a.startsWith("-"));
|
|
2740
|
+
if (action === "list") {
|
|
2741
|
+
const { body } = await nodeRequest("GET", "/api/approvals", undefined, { json });
|
|
2742
|
+
const pending = (Array.isArray(body) ? body : []).filter((a) => a.status === "pending");
|
|
2743
|
+
if (json) { console.log(JSON.stringify(pending, null, 2)); return; }
|
|
2744
|
+
if (!pending.length) { console.log("No approvals waiting."); return; }
|
|
2745
|
+
for (const a of pending) console.log(`${c.cyan(a.id)} ${a.toolName}${a.risk ? c.dim(` (${a.risk})`) : ""} session ${a.sessionId}\n ${a.reason}`);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
if ((action === "approve" || action === "reject") && id) {
|
|
2749
|
+
const { status } = await nodeRequest("POST", `/api/approvals/${encodeURIComponent(id)}/${action}`, {}, { json });
|
|
2750
|
+
if (status === 404) return fail({ code: "not_found", message: `No pending approval ${id}.`, next: "bivy approvals --json", exit: EXIT.notFound });
|
|
2751
|
+
if (json) { console.log(JSON.stringify({ ok: true, id, approved: action === "approve" })); return; }
|
|
2752
|
+
console.log(c.green(action === "approve" ? `Approved ${id}.` : `Rejected ${id}.`));
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// `bivy issues` — the node's GitHub issue pickup: open issues labelled for an
|
|
2759
|
+
// agent, and starting a session on one (it claims the issue with a label).
|
|
2760
|
+
async function cmdIssues(args = []) {
|
|
2761
|
+
const usage = "Usage: bivy issues [list] [--json]\n bivy issues pickup <number> [--json]";
|
|
2762
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2763
|
+
console.log(`${usage}\n\nOpen GitHub issues labelled for an agent in this node's repository, and picking one up: pickup claims the issue with its claim label and starts a session on it. Needs GitHub issue pickup configured (see docs/github-work-queue.md).`);
|
|
2764
|
+
return;
|
|
2765
|
+
}
|
|
2766
|
+
const json = wantsJson(args);
|
|
2767
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2768
|
+
const [action = "list", number] = args.filter((a) => !a.startsWith("-"));
|
|
2769
|
+
if (action === "list") {
|
|
2770
|
+
const { status, body } = await nodeRequest("GET", "/api/github/issues", undefined, { json });
|
|
2771
|
+
if (status >= 400) return fail({ code: "not_configured", message: body.error || `HTTP ${status}`, exit: EXIT.failed });
|
|
2772
|
+
if (json) { console.log(JSON.stringify(body, null, 2)); return; }
|
|
2773
|
+
if (!body.issues?.length) { console.log(`No open issues labelled ${body.label} in ${body.repo}.`); return; }
|
|
2774
|
+
for (const issue of body.issues) console.log(`${c.cyan(`#${issue.number}`)} ${issue.title}`);
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
if (action === "pickup" && number) {
|
|
2778
|
+
const { status, body } = await nodeRequest("POST", `/api/github/issues/${encodeURIComponent(number)}/pickup`, {}, { json });
|
|
2779
|
+
if (status >= 400) return fail({ code: status === 404 ? "not_found" : status === 409 ? "conflict" : "failed", message: body.error || `HTTP ${status}`, exit: status === 404 ? EXIT.notFound : status === 409 ? EXIT.conflict : status === 400 ? EXIT.usage : EXIT.failed });
|
|
2780
|
+
if (json) { console.log(JSON.stringify(body)); return; }
|
|
2781
|
+
console.log(c.green(`Picked up #${body.issue?.number} in ${body.repo}; a session is starting on it.`));
|
|
2782
|
+
return;
|
|
2783
|
+
}
|
|
2784
|
+
fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
// `bivy instructions` — the account-wide instructions every agent session gets
|
|
2788
|
+
// (Settings → Agent instructions). `set` refuses to overwrite a newer edit
|
|
2789
|
+
// made elsewhere since the text was read.
|
|
2790
|
+
async function cmdInstructions(args = []) {
|
|
2791
|
+
const usage = "Usage: bivy instructions [show] [--json]\n bivy instructions set <file|-> [--json]\n bivy instructions path";
|
|
2792
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
2793
|
+
console.log(`${usage}\n\nThe account-wide instructions every agent session receives (the same text as Settings → Agent instructions). 'set' replaces them from a file, or from stdin with '-'; it applies to sessions started afterwards. 'path' prints the file on this machine.`);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
const json = wantsJson(args);
|
|
2797
|
+
const fail = (error) => cliError(error, { json, paint: c.red });
|
|
2798
|
+
const [action = "show", source] = args.filter((a) => a === "-" || !a.startsWith("-"));
|
|
2799
|
+
if (action === "path") { console.log(path.join(appDir, "AGENTS.md")); return; }
|
|
2800
|
+
const { status, body } = await nodeRequest("GET", "/api/node/settings", undefined, { json });
|
|
2801
|
+
const current = body.agentInstructions;
|
|
2802
|
+
if (status >= 400 || !current) return fail({ code: "node_outdated", message: "This node does not report agent instructions.", next: "bivy update", exit: EXIT.unavailable });
|
|
2803
|
+
if (action === "show") {
|
|
2804
|
+
if (json) { console.log(JSON.stringify({ text: current.text, updatedAt: current.updatedAt, bivyNote: current.bivyNote })); return; }
|
|
2805
|
+
console.log(current.text || c.dim("(none yet)"));
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
if (action === "set" && source) {
|
|
2809
|
+
let text;
|
|
2810
|
+
try { text = source === "-" ? fs.readFileSync(0, "utf8") : fs.readFileSync(path.resolve(source), "utf8"); }
|
|
2811
|
+
catch (error) { return fail({ code: "file_not_found", message: `Could not read ${source}: ${error?.message || error}`, exit: EXIT.notFound }); }
|
|
2812
|
+
const saved = await nodeRequest("POST", "/api/node/settings", { agentInstructions: text, agentInstructionsBaseUpdatedAt: current.updatedAt }, { json });
|
|
2813
|
+
if (saved.status >= 400) return fail({ code: /another device/.test(saved.body.error ?? "") ? "conflict" : "invalid", message: saved.body.error || `HTTP ${saved.status}`, exit: /another device/.test(saved.body.error ?? "") ? EXIT.conflict : EXIT.usage });
|
|
2814
|
+
if (json) { console.log(JSON.stringify({ ok: true, bytes: Buffer.byteLength(text) })); return; }
|
|
2815
|
+
console.log(c.green("Saved. Sessions started from now on get these instructions."));
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
fail({ code: "usage", message: usage, exit: EXIT.usage });
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2544
2821
|
// Cross-agent / cross-machine delegation (docs/agent-delegation.md): hand a
|
|
2545
2822
|
// self-contained task from this session to another agent, optionally on another
|
|
2546
2823
|
// account machine, and get its answer back. Agent-agnostic — any agent with a
|
|
@@ -5570,6 +5847,57 @@ function cmdHelp(args = []) {
|
|
|
5570
5847
|
console.log(`Usage: ${row.usage}\n\n${row.summary}${row.aliases.length ? `\nAliases: ${row.aliases.join(", ")}` : ""}${row.subcommands.length ? `\nSubcommands: ${row.subcommands.join(", ")}` : ""}\n\nFull options: ${row.help}`);
|
|
5571
5848
|
}
|
|
5572
5849
|
|
|
5850
|
+
// `bivy guide [topic]` — short playbooks for agents, one markdown file per
|
|
5851
|
+
// topic in bin/guides (a "# Title" line, then "Summary: …").
|
|
5852
|
+
function readGuides() {
|
|
5853
|
+
const dir = path.join(__dirname, "guides");
|
|
5854
|
+
return fs.readdirSync(dir).filter((file) => file.endsWith(".md")).sort().map((file) => {
|
|
5855
|
+
const markdown = fs.readFileSync(path.join(dir, file), "utf8");
|
|
5856
|
+
return {
|
|
5857
|
+
topic: file.slice(0, -3),
|
|
5858
|
+
title: markdown.match(/^# (.+)$/m)?.[1] ?? file,
|
|
5859
|
+
summary: markdown.match(/^Summary: (.+)$/m)?.[1] ?? "",
|
|
5860
|
+
markdown: markdown.replace(/^Summary: .+\n\n?/m, ""),
|
|
5861
|
+
};
|
|
5862
|
+
});
|
|
5863
|
+
}
|
|
5864
|
+
|
|
5865
|
+
// `bivy tool <name> '<json>'` — how `bivy mcp-serve` runs a tool: map the input
|
|
5866
|
+
// to the command's words and run it with --json, so a tool and its command can't
|
|
5867
|
+
// behave differently.
|
|
5868
|
+
function cmdTool(args = []) {
|
|
5869
|
+
const [name, raw = "{}"] = args;
|
|
5870
|
+
const tool = describeTools().find((t) => t.name === name);
|
|
5871
|
+
if (!tool) return cliError({ code: "unknown_tool", message: `No tool called "${name}".`, next: "bivy help --json", exit: EXIT.usage }, { json: true });
|
|
5872
|
+
let input;
|
|
5873
|
+
try { input = JSON.parse(raw); } catch { return cliError({ code: "usage", message: "Tool input must be a JSON object.", exit: EXIT.usage }, { json: true }); }
|
|
5874
|
+
const result = spawnSync(process.execPath, [selfScript, ...toolArgv(tool.argv, input ?? {}), "--json"], { stdio: "inherit", env: process.env });
|
|
5875
|
+
process.exit(result.status ?? EXIT.failed);
|
|
5876
|
+
}
|
|
5877
|
+
|
|
5878
|
+
function cmdGuide(args = []) {
|
|
5879
|
+
if (args.includes("-h") || args.includes("--help")) {
|
|
5880
|
+
console.log("Usage: bivy guide [topic] [--json]\n\nShort playbooks for agents working in Bivy. Without a topic, lists them.");
|
|
5881
|
+
return;
|
|
5882
|
+
}
|
|
5883
|
+
const json = wantsJson(args);
|
|
5884
|
+
const guides = readGuides();
|
|
5885
|
+
const topic = args.find((a) => !a.startsWith("-"));
|
|
5886
|
+
if (!topic) {
|
|
5887
|
+
if (json) { console.log(JSON.stringify({ guides: guides.map(({ topic: t, title, summary }) => ({ topic: t, title, summary })) }, null, 2)); return; }
|
|
5888
|
+
console.log(`${c.bold("Guides")} (bivy guide <topic>)\n`);
|
|
5889
|
+
for (const guide of guides) console.log(` ${c.cyan(guide.topic.padEnd(18))} ${guide.summary}`);
|
|
5890
|
+
return;
|
|
5891
|
+
}
|
|
5892
|
+
const guide = guides.find((g) => g.topic === topic);
|
|
5893
|
+
if (!guide) {
|
|
5894
|
+
cliError({ code: "unknown_guide", message: `No guide called "${topic}".`, hint: `Guides: ${guides.map((g) => g.topic).join(", ")}.`, next: "bivy guide", exit: EXIT.notFound }, { json, paint: c.red });
|
|
5895
|
+
return;
|
|
5896
|
+
}
|
|
5897
|
+
if (json) { console.log(JSON.stringify({ topic: guide.topic, title: guide.title, markdown: guide.markdown }, null, 2)); return; }
|
|
5898
|
+
console.log(guide.markdown.trimEnd());
|
|
5899
|
+
}
|
|
5900
|
+
|
|
5573
5901
|
async function main() {
|
|
5574
5902
|
await hydrateCanonicalConfig();
|
|
5575
5903
|
const argv = process.argv.slice(2);
|
|
@@ -5664,6 +5992,24 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
|
|
|
5664
5992
|
case "suggest":
|
|
5665
5993
|
await cmdSuggest(args);
|
|
5666
5994
|
break;
|
|
5995
|
+
case "notify":
|
|
5996
|
+
await cmdNotify(args);
|
|
5997
|
+
break;
|
|
5998
|
+
case "fork":
|
|
5999
|
+
await cmdFork(args);
|
|
6000
|
+
break;
|
|
6001
|
+
case "approvals":
|
|
6002
|
+
await cmdApprovals(args);
|
|
6003
|
+
break;
|
|
6004
|
+
case "issues":
|
|
6005
|
+
await cmdIssues(args);
|
|
6006
|
+
break;
|
|
6007
|
+
case "instructions":
|
|
6008
|
+
await cmdInstructions(args);
|
|
6009
|
+
break;
|
|
6010
|
+
case "ask":
|
|
6011
|
+
await cmdAsk(args);
|
|
6012
|
+
break;
|
|
5667
6013
|
case "delegate":
|
|
5668
6014
|
await cmdDelegate(args);
|
|
5669
6015
|
break;
|
|
@@ -5931,6 +6277,12 @@ Unlike 'bivy run', these commands operate on governed background Runs with check
|
|
|
5931
6277
|
case "context":
|
|
5932
6278
|
await cmdContext(args);
|
|
5933
6279
|
break;
|
|
6280
|
+
case "guide":
|
|
6281
|
+
cmdGuide(args);
|
|
6282
|
+
break;
|
|
6283
|
+
case "tool":
|
|
6284
|
+
cmdTool(args);
|
|
6285
|
+
break;
|
|
5934
6286
|
case "version":
|
|
5935
6287
|
console.log(readSelfVersion());
|
|
5936
6288
|
break;
|
package/bin/cli-commands.mjs
CHANGED
|
@@ -16,6 +16,15 @@
|
|
|
16
16
|
// json true when it accepts --json (or honors BIVY_OUTPUT=json)
|
|
17
17
|
// subcommands first-argument verbs, for completion and discovery
|
|
18
18
|
// hidden plumbing launched by Bivy itself; left out of help and completions
|
|
19
|
+
// tools MCP tools `bivy mcp-serve` offers for this command. Each maps
|
|
20
|
+
// its input to CLI words (`argv`, below) and runs `bivy … --json`,
|
|
21
|
+
// so a tool behaves exactly like the command. Only session
|
|
22
|
+
// commands that are safe to call routinely get one; delegation
|
|
23
|
+
// and Runs stay shell-only on purpose (docs/agent-delegation.md).
|
|
24
|
+
//
|
|
25
|
+
// A tool's `argv` is data: a literal word, {arg} (a positional value),
|
|
26
|
+
// {flag, from} (a flag with a value, repeated for arrays, joined with `join`),
|
|
27
|
+
// or {when, flag} (a bare switch when the input is true).
|
|
19
28
|
|
|
20
29
|
/** Exit codes shared by the commands agents call. 0 and 1 hold everywhere. */
|
|
21
30
|
export const EXIT = Object.freeze({ ok: 0, failed: 1, usage: 2, notFound: 3, denied: 4, timeout: 5, conflict: 6, unavailable: 75 });
|
|
@@ -32,10 +41,22 @@ export const GROUPS = [
|
|
|
32
41
|
|
|
33
42
|
export const COMMANDS = [
|
|
34
43
|
// Inside an agent session
|
|
35
|
-
{ name: "context", group: "session", scope: "session", json: true, usage: "context [--json]", summary: "Where this agent is running: session, workspace, machine, apps, and what to run next"
|
|
44
|
+
{ name: "context", group: "session", scope: "session", json: true, usage: "context [--json]", summary: "Where this agent is running: session, workspace, machine, apps, and what to run next",
|
|
45
|
+
tools: [{ name: "bivy_context", description: "Where you are running inside Bivy: your session, agent, workspace and git branch, the machine, apps you have published, whether the user has the app open, and the commands that reach them.", input: {}, argv: ["context"] }] },
|
|
36
46
|
{ name: "attach", group: "session", scope: "session", json: true, usage: 'attach <file> [--caption "…"] [--artifact]', summary: "Show a local file or image to the user in the chat" },
|
|
37
|
-
{ name: "
|
|
38
|
-
|
|
47
|
+
{ name: "notify", group: "session", scope: "session", json: true, usage: 'notify "<message>" [--urgent]', summary: "Message the user: a card in the chat, and a push when they're away",
|
|
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
|
+
{ 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
|
+
tools: [{ name: "ask_user", description: "Ask the user a question in the chat and wait for their answer (their phone gets a push). Offer 2-8 options, or none for a free-text answer; they can always write their own. Returns {status, answer}: status is answered, dismissed (decide yourself) or expired.", input: { question: { type: "string", description: "The question.", required: true }, options: { type: "array", items: { type: "string" }, description: "Choices to pick from (2-8)." }, multiple: { type: "boolean", description: "Allow several choices." }, header: { type: "string", description: "A short label for the card." }, timeout_seconds: { type: "number", description: "How long to wait (default 600)." } }, argv: ["ask", { arg: "question" }, { flag: "--option", from: "options" }, { when: "multiple", flag: "--multi" }, { flag: "--header", from: "header" }, { flag: "--timeout", from: "timeout_seconds" }] }] },
|
|
51
|
+
{ name: "suggest", group: "session", scope: "session", json: true, usage: 'suggest "<task>" [--title "label"]', summary: "Propose a task the user can start in one tap",
|
|
52
|
+
tools: [{ name: "suggest_task", description: "Propose a next step the user can start in one tap, in this session or in a parallel one with its own copy of the project. Write the task as a complete instruction with paths relative to the project root. Post one per idea instead of listing them.", input: { task: { type: "string", description: "The complete instruction.", required: true }, title: { type: "string", description: "A short label for the card." } }, argv: ["suggest", { arg: "task" }, { flag: "--title", from: "title" }] }] },
|
|
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
|
+
tools: [
|
|
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" }] },
|
|
56
|
+
{ name: "app_screenshot", description: "Screenshot this session's web views (or one app) and return the PNG paths, to check your work before saying a UI change is done.", input: { app: { type: "string", description: "App ID or name (default: all web views)." }, widths: { type: "array", items: { type: "number" }, description: "Viewport widths, e.g. [390, 1280]." }, path: { type: "string", description: "Page path, e.g. /settings." } }, argv: ["app", "shot", { arg: "app" }, { flag: "--widths", from: "widths", join: "," }, { flag: "--path", from: "path" }] },
|
|
57
|
+
{ name: "app_present", description: "Tell the user a visible change is ready: a card in the chat with the app at phone width (before and after), and the preview opens.", input: { view: { type: "string", description: "App or view name or ID (default: the one opened last)." }, note: { type: "string", description: "What changed." }, path: { type: "string", description: "Page path to show." } }, argv: ["app", "present", { arg: "view" }, { flag: "--note", from: "note" }, { flag: "--path", from: "path" }] },
|
|
58
|
+
] },
|
|
59
|
+
{ name: "fork", group: "session", scope: "session", json: true, usage: "fork [session-id] [--model <model>]", summary: "Copy this session (conversation and uncommitted work) into a new one on its own branch" },
|
|
39
60
|
{ name: "delegate", group: "session", scope: "session", json: true, usage: 'delegate "<task>" [--agent <id>] [--machine <name>] [--wait]', summary: "Hand a task to another agent or machine and get its answer back", subcommands: ["machines", "status", "wait"] },
|
|
40
61
|
|
|
41
62
|
// Agents and sessions
|
|
@@ -48,6 +69,8 @@ export const COMMANDS = [
|
|
|
48
69
|
{ name: "takeover", group: "sessions", scope: "node", usage: "takeover <id>", summary: "Stop a run-terminal's native TUI and continue it as a governed chat" },
|
|
49
70
|
{ name: "promote", group: "sessions", scope: "node", usage: "promote <session-id>", summary: "Continue a warm-replicated session on this node" },
|
|
50
71
|
{ name: "prune", aliases: ["clean"], group: "sessions", scope: "node", json: true, usage: "prune [--keep N] [--older-than 7d] [--dry-run]", summary: "Delete old sessions, workspaces and worktrees" },
|
|
72
|
+
{ name: "approvals", group: "sessions", scope: "node", json: true, usage: "approvals [list|approve <id>|reject <id>]", summary: "List pending tool approvals on this node, or answer one", subcommands: ["list", "approve", "reject"] },
|
|
73
|
+
{ name: "issues", group: "sessions", scope: "node", json: true, usage: "issues [list|pickup <number>]", summary: "GitHub issues waiting for an agent; pick one up to start a session on it", subcommands: ["list", "pickup"] },
|
|
51
74
|
{ name: "agents", group: "sessions", scope: "node", json: true, usage: "agents [--json]", summary: "List supported agents and which are installed" },
|
|
52
75
|
{ name: "agent", group: "sessions", scope: "node", json: true, usage: "agent <add|list|remove>", summary: "Connect, list or remove a user-owned agent", subcommands: ["add", "list", "remove"] },
|
|
53
76
|
{ name: "agents:install", aliases: ["runtimes:install"], group: "sessions", scope: "node", usage: "agents:install [--bridges]", summary: "Install known upstream agents" },
|
|
@@ -91,16 +114,20 @@ export const COMMANDS = [
|
|
|
91
114
|
{ name: "github:app-sync", group: "accounts", scope: "account", usage: "github:app-sync [on|off]", summary: "Sync GitHub App keys to this account's other nodes" },
|
|
92
115
|
|
|
93
116
|
// Configuration and extensions
|
|
117
|
+
{ name: "instructions", group: "config", scope: "account", json: true, usage: "instructions [show|set <file|->|path]", summary: "The account-wide instructions every agent session receives", subcommands: ["show", "set", "path"] },
|
|
94
118
|
{ name: "config", group: "config", scope: "node", json: true, usage: "config <show|get|set|unset|explain|validate|init|path>", summary: "Typed node configuration", subcommands: ["init", "validate", "show", "get", "set", "unset", "explain", "path"] },
|
|
95
119
|
{ name: "plugin", aliases: ["plugins"], group: "config", scope: "node", json: true, usage: "plugin <init|validate|doctor|test|install|list|remove>", summary: "Build and install plugins", subcommands: ["init", "validate", "doctor", "test", "install", "list", "remove"] },
|
|
96
120
|
{ name: "voice", aliases: ["stt"], group: "config", scope: "node", usage: "voice <provider|key|remove|status>", summary: "Configure speech-to-text", subcommands: ["provider", "key", "remove", "status"] },
|
|
97
121
|
{ name: "completions", aliases: ["completion"], group: "config", scope: "node", usage: "completions <bash|zsh|fish>", summary: "Print a shell completion script", subcommands: ["bash", "zsh", "fish"] },
|
|
98
122
|
|
|
99
123
|
// Help and discovery
|
|
124
|
+
{ name: "guide", group: "help", scope: "node", json: true, usage: "guide [topic] [--json]", summary: "Short playbooks for agents: show the user, talk to them, long work, more hands, automate",
|
|
125
|
+
tools: [{ name: "bivy_guide", description: "Short playbooks for working in Bivy. Without a topic, lists them.", input: { topic: { type: "string", description: "show-the-user, talk-to-the-user, long-work, more-hands or automate." } }, argv: ["guide", { arg: "topic" }] }] },
|
|
100
126
|
{ name: "help", aliases: ["-h", "--help"], group: "help", scope: "node", json: true, usage: "help [command] [--json]", summary: "Show this help, one command's row, or every command as JSON" },
|
|
101
127
|
{ name: "version", aliases: ["--version", "-v"], group: "help", scope: "node", usage: "version", summary: "Print the installed Bivy version" },
|
|
102
128
|
|
|
103
129
|
// Plumbing
|
|
130
|
+
{ name: "tool", group: "help", scope: "session", hidden: true, json: true, usage: "tool <name> '<json input>'", summary: "Run one MCP tool (from `bivy help --json`) as its bivy command" },
|
|
104
131
|
{ name: "mcp-serve", group: "help", scope: "session", hidden: true, usage: "mcp-serve", summary: "Bivy's MCP server for agents (stdio; injected into agent MCP config)" },
|
|
105
132
|
{ name: "mcp-proxy", group: "help", scope: "session", hidden: true, usage: "mcp-proxy -- <server command>", summary: "MCP proxy in front of an agent's own MCP servers (stdio)" },
|
|
106
133
|
];
|
|
@@ -154,6 +181,41 @@ export function subcommandTable() {
|
|
|
154
181
|
}));
|
|
155
182
|
}
|
|
156
183
|
|
|
184
|
+
/** A tool's input as JSON Schema. */
|
|
185
|
+
function toolSchema(input) {
|
|
186
|
+
const properties = {};
|
|
187
|
+
const required = [];
|
|
188
|
+
for (const [key, { required: isRequired, ...schema }] of Object.entries(input)) {
|
|
189
|
+
properties[key] = schema;
|
|
190
|
+
if (isRequired) required.push(key);
|
|
191
|
+
}
|
|
192
|
+
return { type: "object", properties, ...(required.length ? { required } : {}), additionalProperties: false };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every MCP tool, with its JSON Schema and argv template: what `bivy mcp-serve` offers. */
|
|
196
|
+
export function describeTools() {
|
|
197
|
+
return COMMANDS.flatMap((command) => (command.tools ?? []).map((tool) => ({
|
|
198
|
+
name: tool.name, description: tool.description, inputSchema: toolSchema(tool.input), argv: tool.argv, command: command.name,
|
|
199
|
+
})));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The `bivy` words for a tool call: argv template + the caller's input. */
|
|
203
|
+
export function toolArgv(argv, input = {}) {
|
|
204
|
+
const words = [];
|
|
205
|
+
for (const part of argv) {
|
|
206
|
+
if (typeof part === "string") words.push(part);
|
|
207
|
+
else if (part.arg) { if (input[part.arg] !== undefined && input[part.arg] !== "") words.push(String(input[part.arg])); }
|
|
208
|
+
else if (part.when) { if (input[part.when] === true) words.push(part.flag); }
|
|
209
|
+
else if (part.flag) {
|
|
210
|
+
const value = input[part.from];
|
|
211
|
+
if (value === undefined || value === null || value === "") continue;
|
|
212
|
+
if (Array.isArray(value) && part.join) words.push(part.flag, value.join(part.join));
|
|
213
|
+
else for (const item of Array.isArray(value) ? value : [value]) words.push(part.flag, String(item));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return words;
|
|
217
|
+
}
|
|
218
|
+
|
|
157
219
|
/** One command as plain data: what `bivy help --json` and `bivy help <cmd> --json` print. */
|
|
158
220
|
export function describeCommand(command) {
|
|
159
221
|
const { name, aliases = [], group, usage, summary, scope, json = false, subcommands = [] } = command;
|
|
@@ -166,6 +228,7 @@ export function describeCli() {
|
|
|
166
228
|
output: "Commands marked json accept --json; BIVY_OUTPUT=json turns it on for every such command.",
|
|
167
229
|
errors: "With --json, failures print {\"error\":{\"code\",\"message\",\"hint\"?,\"next\"?}} to stderr.",
|
|
168
230
|
commands: COMMANDS.filter((command) => !command.hidden).map(describeCommand),
|
|
231
|
+
tools: describeTools(),
|
|
169
232
|
};
|
|
170
233
|
}
|
|
171
234
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Automate
|
|
2
|
+
|
|
3
|
+
Summary: Automations as code: write, validate, preview, test, apply and trigger.
|
|
4
|
+
|
|
5
|
+
Automations live in the repository, in `.bivy/automations.yaml`, and apply like
|
|
6
|
+
infrastructure code:
|
|
7
|
+
|
|
8
|
+
bivy automation init # write a safe starter file
|
|
9
|
+
bivy automation validate # check it
|
|
10
|
+
bivy automation plan --json # triggers, routing, effective safety
|
|
11
|
+
bivy automation test --event event.json # which automation an event would start
|
|
12
|
+
bivy automation apply # reconcile the account with the file
|
|
13
|
+
bivy automation list --json
|
|
14
|
+
bivy automation trigger <id-or-key> # run one now
|
|
15
|
+
|
|
16
|
+
To pause one, set `enabled: false` on it and apply. Always `validate` and
|
|
17
|
+
`plan` before `apply`, and show the user the plan when it
|
|
18
|
+
changes something they didn't ask for. `apply --prune` also removes automations
|
|
19
|
+
the file no longer has. `bivy automation --help` has every flag.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Long and unattended work
|
|
2
|
+
|
|
3
|
+
Summary: One-shot answers, governed Runs with checks and receipts, and following them.
|
|
4
|
+
|
|
5
|
+
## One question, one answer
|
|
6
|
+
|
|
7
|
+
bivy exec "Summarize the open TODOs in src/" --json
|
|
8
|
+
|
|
9
|
+
Runs a headless session and prints the answer (`{"sessionId","answer"}` with
|
|
10
|
+
`--json`).
|
|
11
|
+
|
|
12
|
+
## A governed Run
|
|
13
|
+
|
|
14
|
+
bivy runs start "Upgrade the test runner and fix what breaks"
|
|
15
|
+
bivy runs list --json
|
|
16
|
+
bivy runs wait <id> --timeout 3600 --json
|
|
17
|
+
|
|
18
|
+
A Run is unattended work with checks, evidence and a Receipt. `runs wait` exits
|
|
19
|
+
0 when it succeeded, 1 when it failed or was cancelled, 2 on timeout.
|
|
20
|
+
|
|
21
|
+
## While you wait
|
|
22
|
+
|
|
23
|
+
Tell the user when it matters: `bivy notify "…"` (see `bivy guide talk-to-the-user`).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# More hands: other agents and machines
|
|
2
|
+
|
|
3
|
+
Summary: Delegate a task to another agent or machine, compare several, and follow the result.
|
|
4
|
+
|
|
5
|
+
Use your own sub-agents for ordinary work. Delegate only when the user asks for
|
|
6
|
+
another agent or another of their machines.
|
|
7
|
+
|
|
8
|
+
bivy delegate machines # machines and their agents
|
|
9
|
+
bivy delegate "Review this branch for bugs" --agent codex --wait
|
|
10
|
+
bivy delegate "Run the Linux-only build" --machine build-box --agent claude --wait
|
|
11
|
+
bivy delegate "Propose a fix for issue 42" --to codex,claude@laptop --wait
|
|
12
|
+
|
|
13
|
+
Write the task so it stands on its own: the other agent does not see this chat.
|
|
14
|
+
`--wait` prints the child's answer and any branch or PR it pushed. Without it you
|
|
15
|
+
get a run id: `bivy delegate status <id>`, `bivy delegate wait <id>`.
|
|
16
|
+
Code comes back by git: fetch the branch or review the PR.
|
|
17
|
+
|
|
18
|
+
To try another approach from the same point, fork this session and give the
|
|
19
|
+
copy its task (it gets its own branch):
|
|
20
|
+
|
|
21
|
+
id=$(bivy fork --json | jq -r .sessionId)
|
|
22
|
+
bivy send "$id" "Try the same fix with a streaming parser instead."
|
|
23
|
+
|
|
24
|
+
For unattended work with checks and a receipt, see `bivy guide long-work`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Show the user something
|
|
2
|
+
|
|
3
|
+
Summary: Attach files, publish live previews, check them with screenshots, and present a finished change.
|
|
4
|
+
|
|
5
|
+
The user reads your session in a chat app, often on a phone. They cannot see your
|
|
6
|
+
terminal, your files, or anything you only describe. Put things in front of them.
|
|
7
|
+
|
|
8
|
+
## A file or image
|
|
9
|
+
|
|
10
|
+
bivy attach out/chart.png --caption "Revenue by month"
|
|
11
|
+
bivy attach report.pdf --artifact
|
|
12
|
+
|
|
13
|
+
Images render inline; other files download. The path must be inside the session
|
|
14
|
+
workspace. `--artifact` also lists it in the session's Artifacts, for durable
|
|
15
|
+
outputs (reports, coverage, builds). Markdown image syntax with a local path does
|
|
16
|
+
not render; a remote `https://` image does.
|
|
17
|
+
|
|
18
|
+
## Something with a UI
|
|
19
|
+
|
|
20
|
+
Publish it once; the user opens it from the chat:
|
|
21
|
+
|
|
22
|
+
bivy app run -- cargo run # a desktop app, no manifest
|
|
23
|
+
bivy app publish app.json # web server, static build, terminal
|
|
24
|
+
|
|
25
|
+
A `terminal` view is a command the user can watch and type into from the app,
|
|
26
|
+
such as a REPL or a log tail:
|
|
27
|
+
|
|
28
|
+
{"kind":"terminal","name":"Console","command":"bin/rails","args":["console"]}
|
|
29
|
+
|
|
30
|
+
A manifest with a server Bivy starts on first open:
|
|
31
|
+
|
|
32
|
+
{"version":1,"name":"Site","views":[{"kind":"web","name":"Web",
|
|
33
|
+
"source":{"kind":"service","port":5173,"start":{"command":"pnpm","args":["dev"]}}}]}
|
|
34
|
+
|
|
35
|
+
## Check your work before you say it's done
|
|
36
|
+
|
|
37
|
+
bivy app shot --widths 390,1280 --themes light,dark
|
|
38
|
+
|
|
39
|
+
prints PNG paths. Look at them. Screenshots are off until the user turns them
|
|
40
|
+
on (`bivy config set sessions.appScreenshots true`).
|
|
41
|
+
|
|
42
|
+
## Hand it over
|
|
43
|
+
|
|
44
|
+
bivy app present --note "The header now wraps on phones"
|
|
45
|
+
|
|
46
|
+
posts a card with the app at phone width (before and after) and opens the
|
|
47
|
+
preview. Use it when a visible change is ready, not after every edit.
|
|
48
|
+
|
|
49
|
+
More: `bivy app --help`, `bivy attach --help`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Talk to the user
|
|
2
|
+
|
|
3
|
+
Summary: When to answer in chat, when to notify, when to ask and wait, and how to offer next steps.
|
|
4
|
+
|
|
5
|
+
Your chat reply is the default. Reach for these when the chat alone won't do.
|
|
6
|
+
|
|
7
|
+
## They may be away: notify
|
|
8
|
+
|
|
9
|
+
bivy notify "Migration finished: 3 tables rewritten, all tests pass."
|
|
10
|
+
bivy notify --urgent "Production is serving the old build."
|
|
11
|
+
|
|
12
|
+
Posts a card in the chat. When nobody has the app open, their devices get a push
|
|
13
|
+
naming this session (the text stays in the chat). `--urgent` pushes even while
|
|
14
|
+
they are looking. One push a minute per session; don't send one per step.
|
|
15
|
+
Good moments: long work finished, you are blocked, something needs a look.
|
|
16
|
+
|
|
17
|
+
## You need a decision: ask
|
|
18
|
+
|
|
19
|
+
bivy ask "Which database?" --option Postgres --option SQLite
|
|
20
|
+
bivy ask "What should the release be called?"
|
|
21
|
+
|
|
22
|
+
Blocks until they answer and prints the answer (exit 0). Exit 1 means they
|
|
23
|
+
dismissed it (use your judgment), exit 5 that `--timeout` (default 10m) passed.
|
|
24
|
+
For long waits use `--async`, keep working, and later `bivy ask wait <id>`.
|
|
25
|
+
Ask only when you can't reasonably decide yourself.
|
|
26
|
+
|
|
27
|
+
## Offer next steps: suggest
|
|
28
|
+
|
|
29
|
+
bivy suggest "Add a GET /version endpoint that returns the package version." --title "Add /version"
|
|
30
|
+
|
|
31
|
+
Each suggestion is a card the user can start in one tap, here or in a parallel
|
|
32
|
+
session with its own copy of the project. Write it as a complete instruction with
|
|
33
|
+
paths relative to the project root. Post one per idea instead of a bulleted list.
|
|
34
|
+
|
|
35
|
+
## Is anyone there?
|
|
36
|
+
|
|
37
|
+
`bivy context --json` reports `userConnected` (some device has the app open) and
|
|
38
|
+
`lastDriver` (the device that last typed into this session).
|
|
@@ -42,11 +42,15 @@ export const BIVY_AGENT_NOTE = [
|
|
|
42
42
|
'`bivy suggest "<complete instruction>" [--title "short label"]` instead of only listing them. The user can ' +
|
|
43
43
|
"start each in one tap, in this session or in a parallel one that works in its own copy of the project, so " +
|
|
44
44
|
"write it to stand on its own, with paths relative to the project root.",
|
|
45
|
+
"- When you finish long work, get blocked, or need the user to look at something: " +
|
|
46
|
+
'`bivy notify "<message>"` (a chat card, plus a push to their phone when they are away). To ask and wait for ' +
|
|
47
|
+
'an answer: `bivy ask "<question>" [--option A --option B]` prints their answer (or use your own ask-the-user tool).',
|
|
45
48
|
"- Only when the user asks for another agent or another of their machines to take part: " +
|
|
46
49
|
'`bivy delegate "<self-contained task>" --agent <id> [--machine <name>] --wait` runs it there and prints ' +
|
|
47
50
|
"its answer and any branch/PR; `--to codex,grok@<machine>` asks several to compare, `bivy delegate machines` " +
|
|
48
51
|
"lists machines and their agents. Use your own sub-agents for everything else.",
|
|
49
|
-
"- `bivy context` shows your session, workspace and published apps; `bivy
|
|
52
|
+
"- `bivy context` shows your session, workspace and published apps; `bivy guide` has short playbooks; " +
|
|
53
|
+
"`bivy help` lists every command (`--json` for all).",
|
|
50
54
|
].join("\n");
|
|
51
55
|
const PREAMBLE = "The user's global Bivy instructions follow. They apply to every workspace; where they conflict with " +
|
|
52
56
|
"instructions from the current repository (AGENTS.md, CLAUDE.md, …), the repository's instructions win.";
|
package/dist/apps/styles.css
CHANGED
|
@@ -3119,17 +3119,23 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
|
|
|
3119
3119
|
/* Suggested task (SuggestionCard.tsx): a task the agent proposed, one tap to
|
|
3120
3120
|
start in its own session. Shell from .card; layout only here. */
|
|
3121
3121
|
.suggestion-card { max-inline-size: 440px; display: flex; flex-direction: column; gap: var(--space-1); }
|
|
3122
|
-
|
|
3122
|
+
/* The small label above a chat card's content, shared by every agent-posted card. */
|
|
3123
|
+
.suggestion-eyebrow, .delegation-eyebrow, .notice-eyebrow { margin: 0; color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
|
|
3123
3124
|
.suggestion-title { margin: 0; font-size: var(--text-base); font-weight: var(--weight-semibold); overflow-wrap: anywhere; }
|
|
3124
3125
|
.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; }
|
|
3125
3126
|
.suggestion-actions, .suggestion-all { display: flex; flex-wrap: wrap; gap: var(--space-2); margin-top: var(--space-2); }
|
|
3126
3127
|
.suggestion-all { padding-top: var(--space-2); border-top: 1px solid var(--line); }
|
|
3127
3128
|
.suggestion-status { margin: var(--space-2) 0 0; display: flex; align-items: center; gap: var(--space-2); color: var(--ok); font-size: var(--text-sm); font-weight: var(--weight-medium); }
|
|
3129
|
+
/* Notice (NoticeCard.tsx): a message the agent sent with `bivy notify`.
|
|
3130
|
+
Shell and tone from .card, the urgent marker from .badge. */
|
|
3131
|
+
.notice-card { max-inline-size: 440px; display: flex; flex-direction: column; gap: var(--space-1); }
|
|
3132
|
+
.notice-eyebrow { display: flex; align-items: center; gap: var(--space-2); }
|
|
3133
|
+
.notice-text { margin: 0; font-size: var(--text-base); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
3134
|
+
|
|
3128
3135
|
/* Delegation (DelegationCard.tsx): a task handed to another agent/machine.
|
|
3129
3136
|
Shell from .card, status from .badge; layout only here. A fan-out renders as
|
|
3130
3137
|
one group whose members sit side by side where there is room. */
|
|
3131
3138
|
.delegation-card { max-inline-size: 440px; display: flex; flex-direction: column; gap: var(--space-1); }
|
|
3132
|
-
.delegation-eyebrow { margin: 0; color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); }
|
|
3133
3139
|
.delegation-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); }
|
|
3134
3140
|
.delegation-head { align-items: flex-start; }
|
|
3135
3141
|
.delegation-target { display: flex; flex-direction: column; font-weight: var(--weight-semibold); overflow-wrap: anywhere; min-inline-size: 0; }
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
// start (see mcp-inject.ts's injectBivyToolsForSession), it exposes Bivy's chat
|
|
8
8
|
// affordances as first-class tools so ANY agent — codex, gemini, aider, opencode,
|
|
9
9
|
// … — discovers them in its tool list instead of having to be told about a shell
|
|
10
|
-
// command (issue #290).
|
|
10
|
+
// command (issue #290). It serves `attach_to_chat`, plus every tool listed in
|
|
11
|
+
// the `bivy` command table (bin/cli-commands.mjs, read through `bivy help
|
|
12
|
+
// --json`): each runs as `bivy tool <name> <json>`, i.e. the command itself with
|
|
13
|
+
// --json, so tools and commands share one implementation and error contract.
|
|
14
|
+
// The agent guides (`bivy guide`) are served as resources.
|
|
11
15
|
//
|
|
12
16
|
// Claude and Pi already get `attach_to_chat` natively (in-process SDK MCP server /
|
|
13
17
|
// integration ToolProvider); this covers everyone else. The tool just POSTs to the
|
|
@@ -20,12 +24,13 @@
|
|
|
20
24
|
// with an injected fetch; the process/transport wiring is thin. Deliberately do
|
|
21
25
|
// not expose governed Runs here: agents should use their native sub-agent tools,
|
|
22
26
|
// which stay inside the parent Session instead of cluttering the Session list.
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
23
28
|
import fs from "node:fs";
|
|
24
29
|
import path from "node:path";
|
|
25
30
|
import { fileURLToPath } from "node:url";
|
|
26
31
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
27
32
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
28
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
33
|
+
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
29
34
|
const DEFAULT_ENDPOINT = "http://127.0.0.1:4317";
|
|
30
35
|
/** The tools this server advertises, in MCP `tools/list` shape. */
|
|
31
36
|
export const BIVY_MCP_TOOLS = [
|
|
@@ -89,6 +94,30 @@ export async function runAttachToChat(endpoint, sessionId, args, fetchImpl, toke
|
|
|
89
94
|
const name = body?.name || filePath;
|
|
90
95
|
return { isError: false, text: `Attached ${name} to the chat as ${body?.kind === "image" ? "an inline image" : "a downloadable file"}. The user can see it now.` };
|
|
91
96
|
}
|
|
97
|
+
/** Run this install's `bivy` (bin/bivy.mjs, two levels up from src/ or dist/). */
|
|
98
|
+
function defaultRunBivy(sessionId) {
|
|
99
|
+
const cli = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../bin/bivy.mjs");
|
|
100
|
+
return (args) => new Promise((resolve) => {
|
|
101
|
+
const child = spawn(process.execPath, [cli, ...args], { env: { ...process.env, BIVY_SESSION_ID: sessionId, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
102
|
+
let stdout = "";
|
|
103
|
+
let stderr = "";
|
|
104
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
105
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
106
|
+
child.on("error", (error) => resolve({ code: 1, stdout, stderr: stderr || error.message }));
|
|
107
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** The tools the command table offers, or none when the CLI can't be asked. */
|
|
111
|
+
async function cliTools(runBivy) {
|
|
112
|
+
const result = await runBivy(["help", "--json"]);
|
|
113
|
+
try {
|
|
114
|
+
const tools = JSON.parse(result.stdout).tools ?? [];
|
|
115
|
+
return tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
92
121
|
/** The user's instructions to advertise at initialize, or undefined when there are none. */
|
|
93
122
|
function readInstructions(file) {
|
|
94
123
|
if (!file)
|
|
@@ -107,14 +136,47 @@ export function createBivyMcpServer(deps = {}) {
|
|
|
107
136
|
const token = deps.token ?? process.env.BIVY_MCP_TOKEN ?? undefined;
|
|
108
137
|
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
109
138
|
const instructions = readInstructions(deps.instructionsFile ?? process.env.BIVY_AGENT_INSTRUCTIONS_FILE);
|
|
110
|
-
const
|
|
111
|
-
|
|
139
|
+
const runBivy = deps.runBivy ?? defaultRunBivy(sessionId);
|
|
140
|
+
let tools;
|
|
141
|
+
const generated = () => (tools ??= cliTools(runBivy));
|
|
142
|
+
const server = new Server({ name: "bivy", version: "1.0.0" }, { capabilities: { tools: {}, resources: {} }, ...(instructions ? { instructions } : {}) });
|
|
143
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [...BIVY_MCP_TOOLS, ...await generated()] }));
|
|
112
144
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
113
145
|
const args = (req.params.arguments ?? {});
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
146
|
+
if (req.params.name === "attach_to_chat") {
|
|
147
|
+
const result = await runAttachToChat(endpoint, sessionId, args, fetchImpl, token);
|
|
148
|
+
return { isError: result.isError, content: [{ type: "text", text: result.text }] };
|
|
149
|
+
}
|
|
150
|
+
if (!(await generated()).some((tool) => tool.name === req.params.name)) {
|
|
151
|
+
return { isError: true, content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }] };
|
|
152
|
+
}
|
|
153
|
+
const result = await runBivy(["tool", req.params.name, JSON.stringify(args)]);
|
|
154
|
+
const text = [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join("\n") || (result.code === 0 ? "Done." : `bivy exited ${result.code}.`);
|
|
155
|
+
return { isError: result.code !== 0, content: [{ type: "text", text }] };
|
|
156
|
+
});
|
|
157
|
+
// The agent guides, one resource per topic.
|
|
158
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
159
|
+
const result = await runBivy(["guide", "--json"]);
|
|
160
|
+
const guides = (() => { try {
|
|
161
|
+
return JSON.parse(result.stdout).guides;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
} })();
|
|
166
|
+
return { resources: guides.map((g) => ({ uri: `bivy://guide/${g.topic}`, name: g.title, description: g.summary, mimeType: "text/markdown" })) };
|
|
167
|
+
});
|
|
168
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
169
|
+
const topic = /^bivy:\/\/guide\/([a-z0-9-]+)$/.exec(req.params.uri)?.[1];
|
|
170
|
+
const result = topic ? await runBivy(["guide", topic, "--json"]) : undefined;
|
|
171
|
+
const guide = (() => { try {
|
|
172
|
+
return result && result.code === 0 ? JSON.parse(result.stdout) : undefined;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return undefined;
|
|
176
|
+
} })();
|
|
177
|
+
if (!guide)
|
|
178
|
+
throw new Error(`No Bivy resource at ${req.params.uri}`);
|
|
179
|
+
return { contents: [{ uri: req.params.uri, mimeType: "text/markdown", text: guide.markdown }] };
|
|
118
180
|
});
|
|
119
181
|
return server;
|
|
120
182
|
}
|
|
@@ -8,6 +8,7 @@ export const CLIENT_COMMAND_ROUTES = [
|
|
|
8
8
|
{ method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
|
|
9
9
|
{ method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
|
|
10
10
|
{ method: "post", path: "/api/session/pause", kind: "session.pause" },
|
|
11
|
+
{ method: "post", path: "/api/session/fork", kind: "session.fork.local" },
|
|
11
12
|
...["get", "draft"].map((action) => ({ method: "post", path: `/api/session/presence/${action}`, kind: `session.presence.${action}` })),
|
|
12
13
|
{ method: "post", path: "/api/session/resume", kind: "session.resume" },
|
|
13
14
|
{ method: "post", path: "/api/session/question/answer", kind: "session.question.answer" },
|
|
@@ -11,6 +11,7 @@ export const CLIENT_COMMAND_SCHEMAS = {
|
|
|
11
11
|
ping: Type.Object(request),
|
|
12
12
|
"apps.list": Type.Object({ ...request, ...optionalSession }),
|
|
13
13
|
"artifacts.list": Type.Object(request),
|
|
14
|
+
"session.fork.local": Type.Object({ ...request, ...session, model: Type.Optional(Type.Object({ provider: Type.Optional(Type.String({ maxLength: 200 })), id: Type.String({ maxLength: 200 }) })) }),
|
|
14
15
|
"apps.publish": Type.Object({ ...request, ...session, manifest: Type.Unknown() }),
|
|
15
16
|
"apps.offers": Type.Object({ ...request, ...session }),
|
|
16
17
|
"apps.adopt": Type.Object({ ...request, ...session, port: Type.Integer({ minimum: 1024, maximum: 65535 }) }),
|
package/dist/question.js
CHANGED
package/dist/server.js
CHANGED
|
@@ -127,6 +127,8 @@ import { createPresenceCommands } from "./controllers/presence-commands.js";
|
|
|
127
127
|
import { createArtifactCommands } from "./controllers/artifact-commands.js";
|
|
128
128
|
import { PresenceBook, deviceFrom } from "./session/presence.js";
|
|
129
129
|
import { buildAgentContext } from "./session/agent-context.js";
|
|
130
|
+
import { AgentQuestions, askQuestionsFrom } from "./session/agent-questions.js";
|
|
131
|
+
import { MAX_NOTICE_TEXT, isAgentNotice, noticePush } from "./session/notices.js";
|
|
130
132
|
import { createForkCommands } from "./controllers/fork-commands.js";
|
|
131
133
|
import { createGithubCommands } from "./controllers/github-commands.js";
|
|
132
134
|
import { createCredentialCommands } from "./controllers/credential-commands.js";
|
|
@@ -398,6 +400,7 @@ const auditLog = createAuditLog(path.join(appDir, "audit"), { signer: auditKey.s
|
|
|
398
400
|
// clarifying question across sessions; the guardian interceptor below raises
|
|
399
401
|
// them, and session.question(.resolved) is broadcast from its listeners.
|
|
400
402
|
const questionManager = new QuestionManager();
|
|
403
|
+
const agentQuestions = new AgentQuestions(questionManager);
|
|
401
404
|
questionManager.onRequest((request) => {
|
|
402
405
|
scheduleAdvertise();
|
|
403
406
|
broadcast({ type: "session.question", sessionId: request.sessionId, requestId: request.id, questions: request.questions, createdAt: request.createdAt });
|
|
@@ -11527,10 +11530,65 @@ app.get("/api/session/:id/context", (req, res) => {
|
|
|
11527
11530
|
},
|
|
11528
11531
|
machine: { name: identity.name, platform: process.platform, arch: process.arch },
|
|
11529
11532
|
presence: presenceBook.get(record.id),
|
|
11533
|
+
userConnected: clients.size > 0 || (relay?.clientCount ?? 0) > 0,
|
|
11530
11534
|
apps,
|
|
11531
11535
|
previewAvailable,
|
|
11532
11536
|
}));
|
|
11533
11537
|
});
|
|
11538
|
+
// `bivy notify "<message>"`: a card in the chat, and a push naming the session
|
|
11539
|
+
// (never the text) when nobody has the app open, or when it's urgent. At most
|
|
11540
|
+
// one push a minute per session; later notices still post a card.
|
|
11541
|
+
const lastNotifyPush = new Map();
|
|
11542
|
+
app.post("/api/session/:id/notify", (req, res) => {
|
|
11543
|
+
const record = openSessions.get(String(req.params.id));
|
|
11544
|
+
if (!record)
|
|
11545
|
+
return res.status(404).json({ error: "Session not found" });
|
|
11546
|
+
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
|
|
11547
|
+
const notice = { id: `notice-${randomBytes(8).toString("hex")}`, text, ...(req.body?.urgent === true ? { urgent: true } : {}) };
|
|
11548
|
+
if (!isAgentNotice(notice))
|
|
11549
|
+
return res.status(400).json({ error: `A notice needs text (up to ${MAX_NOTICE_TEXT} characters).` });
|
|
11550
|
+
eventLog.appendNotice(record.id, { afterMessageCount: record.session.getMessages().length, notice });
|
|
11551
|
+
broadcast(stampSessionEvent({ type: "session.event", sessionId: record.id, event: { type: "notice", id: notice.id, notice } }));
|
|
11552
|
+
const watching = clients.size > 0 || (relay?.clientCount ?? 0) > 0;
|
|
11553
|
+
const push = noticePush({ urgent: notice.urgent, userWatching: watching, lastPushAt: lastNotifyPush.get(record.id), now: Date.now(), pushConfigured: Boolean(sessionAdvertiseTarget) });
|
|
11554
|
+
if (push === "sent") {
|
|
11555
|
+
lastNotifyPush.set(record.id, Date.now());
|
|
11556
|
+
void sendNotificationHint({
|
|
11557
|
+
kind: "agent_notice",
|
|
11558
|
+
sessionId: record.id,
|
|
11559
|
+
targetSessionId: record.id,
|
|
11560
|
+
title: sessionNotifyLabel(record),
|
|
11561
|
+
body: "Has a message for you — tap to read it.",
|
|
11562
|
+
});
|
|
11563
|
+
}
|
|
11564
|
+
res.json({ ok: true, id: notice.id, push, userWatching: watching });
|
|
11565
|
+
});
|
|
11566
|
+
// `bivy ask`: the question card and "needs your input" push, for any agent.
|
|
11567
|
+
// Returns at once with an id; `/wait` blocks up to 240s per call (under common
|
|
11568
|
+
// HTTP client timeouts), so the CLI loops until the user answers.
|
|
11569
|
+
app.post("/api/session/:id/ask", (req, res) => {
|
|
11570
|
+
const record = openSessions.get(String(req.params.id));
|
|
11571
|
+
if (!record)
|
|
11572
|
+
return res.status(404).json({ error: "Session not found" });
|
|
11573
|
+
const questions = askQuestionsFrom(req.body?.questions);
|
|
11574
|
+
if (typeof questions === "string")
|
|
11575
|
+
return res.status(400).json({ error: questions });
|
|
11576
|
+
const timeoutSeconds = Number(req.body?.timeoutSeconds);
|
|
11577
|
+
res.status(201).json(agentQuestions.ask(record.id, questions, Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds * 1000 : 10 * 60 * 1000));
|
|
11578
|
+
});
|
|
11579
|
+
app.get("/api/session/:id/ask/:askId", (req, res) => {
|
|
11580
|
+
const result = agentQuestions.get(String(req.params.id), String(req.params.askId));
|
|
11581
|
+
if (!result)
|
|
11582
|
+
return res.status(404).json({ error: "Question not found" });
|
|
11583
|
+
res.json(result);
|
|
11584
|
+
});
|
|
11585
|
+
app.post("/api/session/:id/ask/:askId/wait", async (req, res) => {
|
|
11586
|
+
const seconds = Math.min(Math.max(Number(req.body?.timeoutSeconds) || 240, 1), 240);
|
|
11587
|
+
const result = await agentQuestions.wait(String(req.params.id), String(req.params.askId), seconds * 1000);
|
|
11588
|
+
if (!result)
|
|
11589
|
+
return res.status(404).json({ error: "Question not found" });
|
|
11590
|
+
res.json(result);
|
|
11591
|
+
});
|
|
11534
11592
|
// `bivy suggest "<task>"`: the agent proposes a task the user can start in one
|
|
11535
11593
|
// tap, here or in a parallel session (see packages/web SuggestionCard).
|
|
11536
11594
|
app.post("/api/session/:id/suggest", (req, res) => {
|
|
@@ -3,6 +3,7 @@ export function buildAgentContext(input) {
|
|
|
3
3
|
return {
|
|
4
4
|
session: input.session,
|
|
5
5
|
machine: input.machine,
|
|
6
|
+
userConnected: input.userConnected,
|
|
6
7
|
...(driver ? { lastDriver: { label: driver.label, via: driver.via, at: driver.at } } : {}),
|
|
7
8
|
apps: input.apps.map((app) => ({
|
|
8
9
|
id: app.id,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export const MAX_ASK_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
const KEEP_SETTLED_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const KEEP_MAX = 200;
|
|
7
|
+
/** Validates `bivy ask` input: 1–4 questions, each with text, a short header and
|
|
8
|
+
* none (a free-text answer) or 2–8 options. Returns an error message, or the questions. */
|
|
9
|
+
export function askQuestionsFrom(value) {
|
|
10
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 4)
|
|
11
|
+
return "Ask 1 to 4 questions.";
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const raw of value) {
|
|
14
|
+
const question = typeof raw?.question === "string" ? raw.question.trim() : "";
|
|
15
|
+
if (!question || question.length > 1000)
|
|
16
|
+
return "Each question needs text (up to 1000 characters).";
|
|
17
|
+
const header = typeof raw.header === "string" && raw.header.trim() ? raw.header.trim().slice(0, 40) : "Question";
|
|
18
|
+
const options = Array.isArray(raw.options) ? raw.options : [];
|
|
19
|
+
if (options.length === 1 || options.length > 8)
|
|
20
|
+
return "Offer 2 to 8 options, or none for a free-text answer.";
|
|
21
|
+
const labels = options.map((o) => (typeof o === "string" ? o : o?.label)).map((l) => (typeof l === "string" ? l.trim() : ""));
|
|
22
|
+
if (labels.some((l) => !l || l.length > 200))
|
|
23
|
+
return "Each option needs a label (up to 200 characters).";
|
|
24
|
+
if (new Set(labels).size !== labels.length)
|
|
25
|
+
return "Options must be different.";
|
|
26
|
+
out.push({ question, header, options: labels.map((label) => ({ label })), ...(raw.multiSelect === true ? { multiSelect: true } : {}) });
|
|
27
|
+
}
|
|
28
|
+
if (new Set(out.map((q) => q.question)).size !== out.length)
|
|
29
|
+
return "Questions must be different.";
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
export class AgentQuestions {
|
|
33
|
+
questions;
|
|
34
|
+
now;
|
|
35
|
+
results = new Map();
|
|
36
|
+
waiters = new Map();
|
|
37
|
+
/** How each of our questions settled. QuestionManager reports it to onResolved
|
|
38
|
+
* listeners synchronously, before the request's promise callbacks run. */
|
|
39
|
+
settled = new Map();
|
|
40
|
+
constructor(questions, now = Date.now) {
|
|
41
|
+
this.questions = questions;
|
|
42
|
+
this.now = now;
|
|
43
|
+
questions.onResolved((request) => { if (this.results.has(request.id))
|
|
44
|
+
this.settled.set(request.id, request.status); });
|
|
45
|
+
}
|
|
46
|
+
/** Raise the card and return at once; the answer lands in `get`/`wait`. */
|
|
47
|
+
ask(sessionId, items, timeoutMs) {
|
|
48
|
+
this.prune();
|
|
49
|
+
const id = randomUUID();
|
|
50
|
+
const result = { id, sessionId, status: "pending" };
|
|
51
|
+
this.results.set(id, result);
|
|
52
|
+
void this.questions.request({ id, sessionId, questions: items, timeoutMs: Math.min(Math.max(timeoutMs, 1000), MAX_ASK_TIMEOUT_MS) }).then((answer) => {
|
|
53
|
+
const status = this.settled.get(id);
|
|
54
|
+
this.settled.delete(id);
|
|
55
|
+
result.status = answer.behavior === "completed" ? "answered" : status === "expired" ? "expired" : "dismissed";
|
|
56
|
+
if (answer.behavior === "completed")
|
|
57
|
+
result.answers = answer.answers;
|
|
58
|
+
result.settledAt = this.now();
|
|
59
|
+
for (const wake of this.waiters.get(id) ?? [])
|
|
60
|
+
wake();
|
|
61
|
+
this.waiters.delete(id);
|
|
62
|
+
});
|
|
63
|
+
return this.view(result);
|
|
64
|
+
}
|
|
65
|
+
get(sessionId, id) {
|
|
66
|
+
const result = this.results.get(id);
|
|
67
|
+
return result && result.sessionId === sessionId ? this.view(result) : undefined;
|
|
68
|
+
}
|
|
69
|
+
/** Resolves when the question settles or after `ms`, whichever is first. */
|
|
70
|
+
async wait(sessionId, id, ms) {
|
|
71
|
+
const result = this.results.get(id);
|
|
72
|
+
if (!result || result.sessionId !== sessionId)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (result.status === "pending") {
|
|
75
|
+
await new Promise((resolve) => {
|
|
76
|
+
const timer = setTimeout(done, ms);
|
|
77
|
+
function done() { clearTimeout(timer); resolve(); }
|
|
78
|
+
const set = this.waiters.get(id) ?? new Set();
|
|
79
|
+
set.add(done);
|
|
80
|
+
this.waiters.set(id, set);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return this.view(result);
|
|
84
|
+
}
|
|
85
|
+
view(result) {
|
|
86
|
+
return { id: result.id, sessionId: result.sessionId, status: result.status, ...(result.answers ? { answers: { ...result.answers } } : {}) };
|
|
87
|
+
}
|
|
88
|
+
prune() {
|
|
89
|
+
const cutoff = this.now() - KEEP_SETTLED_MS;
|
|
90
|
+
for (const [id, result] of this.results)
|
|
91
|
+
if (result.settledAt !== undefined && (result.settledAt < cutoff || this.results.size > KEEP_MAX))
|
|
92
|
+
this.results.delete(id);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -32,6 +32,7 @@ import { normalizedIntermediateText, thinkingTextFromContent, mergeTranscript }
|
|
|
32
32
|
import { DELEGATION_BLOCK, isDelegationCard } from "./delegations.js";
|
|
33
33
|
import { APP_PIN_BLOCK, APP_PUBLICATION_BLOCK, APP_REVIEW_BLOCK, isAppPin, isAppReference, isAppReview } from "../apps/types.js";
|
|
34
34
|
import { SUGGESTION_BLOCK, isTaskSuggestion } from "./suggestions.js";
|
|
35
|
+
import { NOTICE_BLOCK, isAgentNotice } from "./notices.js";
|
|
35
36
|
/**
|
|
36
37
|
* Decompose a transcript message into serialization-independent "atoms": the
|
|
37
38
|
* agent's own tool ids (unique + identical across serializations) and its text
|
|
@@ -255,6 +256,12 @@ function isSuggestionEntry(value) {
|
|
|
255
256
|
const entry = value;
|
|
256
257
|
return entry.bivyKind === "suggestion" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isTaskSuggestion(entry.suggestion);
|
|
257
258
|
}
|
|
259
|
+
function isNoticeEntry(value) {
|
|
260
|
+
if (!value || typeof value !== "object")
|
|
261
|
+
return false;
|
|
262
|
+
const entry = value;
|
|
263
|
+
return entry.bivyKind === "notice" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAgentNotice(entry.notice);
|
|
264
|
+
}
|
|
258
265
|
function isDelegationEntry(value) {
|
|
259
266
|
if (!value || typeof value !== "object")
|
|
260
267
|
return false;
|
|
@@ -274,7 +281,7 @@ function isAppPinEntry(value) {
|
|
|
274
281
|
return entry.bivyKind === "app-pin" && typeof entry.id === "string" && typeof entry.createdAt === "number" && typeof entry.afterMessageCount === "number" && isAppPin(entry.pin);
|
|
275
282
|
}
|
|
276
283
|
function isRecord(value) {
|
|
277
|
-
return isDelegationEntry(value) || isOverlay(value) || isBase(value) || isForkDisplay(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isAppPinEntry(value) || isSuggestionEntry(value);
|
|
284
|
+
return isDelegationEntry(value) || isOverlay(value) || isBase(value) || isForkDisplay(value) || isAttachment(value) || isOutboundAttachment(value) || isInlineImage(value) || isAppPublication(value) || isAppReviewEntry(value) || isAppPinEntry(value) || isSuggestionEntry(value) || isNoticeEntry(value);
|
|
278
285
|
}
|
|
279
286
|
/**
|
|
280
287
|
* Fold attachment records into a text→refs list: last write wins per text (a
|
|
@@ -421,7 +428,11 @@ export function replayExtras(entries) {
|
|
|
421
428
|
role: "assistant", content: [{ type: SUGGESTION_BLOCK, suggestion: entry.suggestion }],
|
|
422
429
|
id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
|
|
423
430
|
}));
|
|
424
|
-
|
|
431
|
+
const notices = entries.filter((entry) => entry.bivyKind === "notice").map((entry) => ({
|
|
432
|
+
role: "assistant", content: [{ type: NOTICE_BLOCK, notice: entry.notice }],
|
|
433
|
+
id: entry.id, afterMessageCount: entry.afterMessageCount, createdAt: entry.createdAt,
|
|
434
|
+
}));
|
|
435
|
+
return [...foldIntermediate(intermediate), ...foldTool(tool), ...replayOutboundAttachments(entries), ...publications, ...cards, ...pinCards, ...delegationCards, ...suggestions, ...notices];
|
|
425
436
|
}
|
|
426
437
|
/**
|
|
427
438
|
* Fold the outbound (agent-sent) attachment records into time-anchored synthetic
|
|
@@ -742,6 +753,10 @@ export class EventLog {
|
|
|
742
753
|
this.load(id);
|
|
743
754
|
this.enqueue(id, `suggestion:${entry.suggestion.id}`, { bivyKind: "suggestion", createdAt: Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.suggestion.id, suggestion: { ...entry.suggestion } });
|
|
744
755
|
}
|
|
756
|
+
appendNotice(id, entry) {
|
|
757
|
+
this.load(id);
|
|
758
|
+
this.enqueue(id, `notice:${entry.notice.id}`, { bivyKind: "notice", createdAt: Date.now(), afterMessageCount: entry.afterMessageCount, id: entry.notice.id, notice: { ...entry.notice } });
|
|
759
|
+
}
|
|
745
760
|
appendOutboundAttachment(id, entry) {
|
|
746
761
|
this.load(id);
|
|
747
762
|
const record = {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
export const NOTICE_BLOCK = "bivy_notice";
|
|
4
|
+
export const MAX_NOTICE_TEXT = 2000;
|
|
5
|
+
/** At most one push per session in this window; later notices still post a card. */
|
|
6
|
+
export const NOTICE_PUSH_GAP_MS = 60_000;
|
|
7
|
+
/** Whether a notice also buzzes the user's devices. `pushConfigured` is false on
|
|
8
|
+
* a node with no account connection, which has no way to push. */
|
|
9
|
+
export function noticePush(input) {
|
|
10
|
+
if (input.pushConfigured === false)
|
|
11
|
+
return "unavailable";
|
|
12
|
+
if (input.lastPushAt !== undefined && input.now - input.lastPushAt < NOTICE_PUSH_GAP_MS)
|
|
13
|
+
return "rate_limited";
|
|
14
|
+
if (input.userWatching && !input.urgent)
|
|
15
|
+
return "user_watching";
|
|
16
|
+
return "sent";
|
|
17
|
+
}
|
|
18
|
+
export function isAgentNotice(value) {
|
|
19
|
+
if (!value || typeof value !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
const n = value;
|
|
22
|
+
return typeof n.id === "string" && n.id.length > 0
|
|
23
|
+
&& typeof n.text === "string" && n.text.length > 0 && n.text.length <= MAX_NOTICE_TEXT
|
|
24
|
+
&& (n.urgent === undefined || typeof n.urgent === "boolean");
|
|
25
|
+
}
|
package/package.json
CHANGED