@bivy/bivy 0.5.1-staging.60 → 0.5.1-staging.62

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 CHANGED
@@ -145,6 +145,7 @@ const attachEntry = path.join(repoRoot, packaged ? "dist/attach.js" : "src/attac
145
145
  const relayAttachEntry = path.join(repoRoot, packaged ? "dist/relay-attach.js" : "src/relay-attach.ts");
146
146
  const execEntry = path.join(repoRoot, packaged ? "dist/exec.js" : "src/exec.ts");
147
147
  const mcpProxyEntry = path.join(repoRoot, packaged ? "dist/harness/mcp-proxy-cli.js" : "src/harness/mcp-proxy-cli.ts");
148
+ const mcpServeEntry = path.join(repoRoot, packaged ? "dist/harness/mcp-serve-cli.js" : "src/harness/mcp-serve-cli.ts");
148
149
  const qrEntry = path.join(repoRoot, "public", "qr.js");
149
150
  const tsxCli = packaged ? "" : path.join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs");
150
151
  const nodeBin = process.execPath;
@@ -3467,6 +3468,7 @@ async function cmdStatus(args = []) {
3467
3468
  }
3468
3469
  console.log(c.bold("\n Bivy node\n"));
3469
3470
  console.log(` url: ${url(config)} ${reachable ? c.green("● reachable") : c.dim("○ not reachable")}`);
3471
+ if (status?.version) console.log(` version: ${status.version}`);
3470
3472
  console.log(` workspace: ${status?.workspace || config.workspace}`);
3471
3473
  console.log(` ${serviceStatusLine()}`);
3472
3474
  const relay = loadRelayConfig();
@@ -3505,6 +3507,10 @@ async function cmdStatus(args = []) {
3505
3507
  console.log(` devices: ${status.devices?.paired ?? 0} paired remote, ${status.devices?.localTokens ?? 0} local token(s)`);
3506
3508
  console.log(` approvals: ${status.approvals?.pending ?? 0} pending`);
3507
3509
  console.log(` guard: ${status.approvalMode || "autonomous"} (${status.guardrails?.workspaceBoundary ? "workspace boundary on" : "boundary unknown"})`);
3510
+ if (status.updatedAt) {
3511
+ const when = new Date(status.updatedAt);
3512
+ console.log(` updated: ${Number.isNaN(when.getTime()) ? status.updatedAt : when.toLocaleString()}`);
3513
+ }
3508
3514
  }
3509
3515
  console.log("");
3510
3516
  }
@@ -4360,6 +4366,13 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4360
4366
  // agent's cwd so relative server commands resolve.
4361
4367
  await run(nodeBin, [...nodeScriptArgs(mcpProxyEntry), ...args], { cwd: process.cwd(), env: process.env });
4362
4368
  break;
4369
+ case "mcp-serve":
4370
+ // Bivy-owned MCP server (exposes attach_to_chat and future chat tools).
4371
+ // Injected into a non-SDK agent's MCP config so the agent discovers the
4372
+ // tool; like mcp-proxy its stdin/stdout ARE the JSON-RPC stream, so emit
4373
+ // nothing else here and inherit stdio verbatim.
4374
+ await run(nodeBin, [...nodeScriptArgs(mcpServeEntry), ...args], { cwd: process.cwd(), env: process.env });
4375
+ break;
4363
4376
  case "help":
4364
4377
  case "-h":
4365
4378
  case "--help":
@@ -15,6 +15,33 @@
15
15
  // exact original bytes on session end regardless. Pure string→string, unit-tested
16
16
  // in test/harness-mcp-formats.test.ts.
17
17
  import { PROXY_MARKER } from "./mcp-config.js";
18
+ function escapeRegExp(s) {
19
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ }
21
+ /** True when a TOML config already declares `[mcp_servers.<name>]`. */
22
+ export function hasTomlServer(content, name) {
23
+ return new RegExp(`^\\s*\\[mcp_servers\\.${escapeRegExp(name)}\\]\\s*$`, "m").test(content);
24
+ }
25
+ /**
26
+ * Append a `[mcp_servers.<name>]` table to a Codex TOML config (the reverse of
27
+ * routeThroughProxy's rewrite — this ADDS Bivy's own server rather than wrapping
28
+ * the agent's). Idempotent: returns `inserted: false` untouched when a table of
29
+ * that name already exists. Env is written as an inline table; string values are
30
+ * TOML-escaped via JSON.stringify (safe for the ASCII paths/ids we stamp).
31
+ */
32
+ export function insertTomlServer(content, name, spec) {
33
+ if (hasTomlServer(content, name))
34
+ return { content, inserted: false };
35
+ const block = [`[mcp_servers.${name}]`, `command = ${JSON.stringify(spec.command)}`, `args = ${tomlStringArray(spec.args ?? [])}`];
36
+ if (spec.env && Object.keys(spec.env).length) {
37
+ const pairs = Object.entries(spec.env).map(([k, v]) => `${k} = ${JSON.stringify(v)}`);
38
+ block.push(`env = { ${pairs.join(", ")} }`);
39
+ }
40
+ // Separate from prior content with a blank line; keep a trailing newline.
41
+ const base = content.length === 0 || content.endsWith("\n") ? content : `${content}\n`;
42
+ const sep = base.length > 0 && !base.endsWith("\n\n") ? "\n" : "";
43
+ return { content: `${base}${sep}${block.join("\n")}\n`, inserted: true };
44
+ }
18
45
  function proxyArgs(launcher, server, command, origArgs) {
19
46
  return [...(launcher.argsPrefix ?? []), PROXY_MARKER, "--server", server, "--", command, ...origArgs];
20
47
  }
@@ -85,6 +85,30 @@ export function parseProxiedArgs(args) {
85
85
  const tail = args.slice(sep + 1);
86
86
  return { server, command: tail[0], args: tail.slice(1) };
87
87
  }
88
+ // ---------------------------------------------------------------------------
89
+ // Bivy-owned tools server — the mirror of the proxy above. routeThroughProxy
90
+ // wraps the agent's OWN servers; this ADDS a `bivy` server (run via `bivy
91
+ // mcp-serve`) so the agent discovers Bivy's chat tools (attach_to_chat, …) in
92
+ // its own tool list. Session id + node URL ride in its env so the tool can post
93
+ // back to the right session.
94
+ /** The server spec that launches `bivy mcp-serve` for a session. */
95
+ export function bivyToolsServerSpec(opts) {
96
+ const env = { BIVY_SESSION_ID: opts.sessionId };
97
+ if (opts.endpoint)
98
+ env.BIVY_MCP_ENDPOINT = opts.endpoint;
99
+ return { command: opts.bivyCommand ?? "bivy", args: ["mcp-serve"], env };
100
+ }
101
+ /**
102
+ * Insert the Bivy tools server under `mcpServers.<name>` (default "bivy").
103
+ * Idempotent: an existing entry of that name is left untouched (returns
104
+ * `added: false`), so a re-inject or a user's own `bivy` server never doubles up.
105
+ */
106
+ export function withBivyToolsServer(config, spec, name = "bivy") {
107
+ const servers = config.mcpServers ?? {};
108
+ if (servers[name])
109
+ return { config, added: false };
110
+ return { config: { ...config, mcpServers: { ...servers, [name]: spec } }, added: true };
111
+ }
88
112
  /** JSON MCP-config file candidates for an agent, most-specific (safest) first. */
89
113
  export function agentMcpConfigTargets(agentId, ctx) {
90
114
  const ws = (...parts) => nodePath.join(ctx.workspace, ...parts);
@@ -14,8 +14,8 @@
14
14
  // FS + network channels. Unit-tested in test/harness-mcp-inject.test.ts.
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
- import { agentMcpConfigTargets, routeThroughProxy, } from "./mcp-config.js";
18
- import { injectTomlMcp, injectYamlMcp } from "./mcp-config-formats.js";
17
+ import { agentMcpConfigTargets, bivyToolsServerSpec, routeThroughProxy, withBivyToolsServer, } from "./mcp-config.js";
18
+ import { injectTomlMcp, injectYamlMcp, insertTomlServer } from "./mcp-config-formats.js";
19
19
  /** The proxy launcher Bivy injects — `bivy mcp-proxy …`. */
20
20
  export function bivyProxyLauncher(bivyCommand = "bivy") {
21
21
  return { command: bivyCommand, argsPrefix: ["mcp-proxy"] };
@@ -110,6 +110,82 @@ export function injectMcpConfigFile(filePath, launcher) {
110
110
  },
111
111
  };
112
112
  }
113
+ /**
114
+ * Add the `bivy` tools server (attach_to_chat …) to an agent's MCP config so the
115
+ * agent DISCOVERS it as a tool. Unlike the proxy inject (which only rewrites
116
+ * servers a file already has), this CREATES the config when absent so an agent
117
+ * that ships no MCP config still gets the tool. Handles the most-specific JSON
118
+ * config (session-local for claude/gemini/opencode/generic) and Codex's TOML
119
+ * (`~/.codex/config.toml` — Codex has no project-local option). restore() deletes
120
+ * a file it created and rewrites the exact original bytes of one it modified.
121
+ * Idempotent (a `bivy` server already present is a no-op, so concurrent sessions
122
+ * sharing a global config don't double up). Best-effort; never throws. Goose YAML
123
+ * is a follow-up.
124
+ */
125
+ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
126
+ const target = agentMcpConfigTargets(agentId, ctx).find((t) => {
127
+ const ext = path.extname(t).toLowerCase();
128
+ return ext === ".json" || ext === ".toml";
129
+ });
130
+ if (!target)
131
+ return { injected: [], restore: () => { } };
132
+ const spec = bivyToolsServerSpec({ sessionId: ctx.sessionId, endpoint: ctx.endpoint, bivyCommand });
133
+ const ext = path.extname(target).toLowerCase();
134
+ const existed = fs.existsSync(target);
135
+ let original;
136
+ if (existed) {
137
+ try {
138
+ original = fs.readFileSync(target, "utf8");
139
+ }
140
+ catch {
141
+ return { injected: [], restore: () => { } };
142
+ }
143
+ }
144
+ let nextContent;
145
+ if (ext === ".json") {
146
+ let parsed = {};
147
+ if (original !== undefined) {
148
+ try {
149
+ parsed = JSON.parse(original);
150
+ }
151
+ catch {
152
+ // A config we can't parse is not ours to rewrite — leave it be.
153
+ return { injected: [], restore: () => { } };
154
+ }
155
+ }
156
+ const { config, added } = withBivyToolsServer(parsed, spec);
157
+ if (!added)
158
+ return { injected: [], restore: () => { } };
159
+ nextContent = `${JSON.stringify(config, null, 2)}\n`;
160
+ }
161
+ else {
162
+ const res = insertTomlServer(original ?? "", "bivy", { command: spec.command ?? bivyCommand, args: spec.args, env: spec.env });
163
+ if (!res.inserted)
164
+ return { injected: [], restore: () => { } };
165
+ nextContent = res.content;
166
+ }
167
+ try {
168
+ fs.mkdirSync(path.dirname(target), { recursive: true });
169
+ fs.writeFileSync(target, nextContent);
170
+ }
171
+ catch {
172
+ return { injected: [], restore: () => { } };
173
+ }
174
+ return {
175
+ injected: [target],
176
+ restore: () => {
177
+ try {
178
+ if (existed && original !== undefined)
179
+ fs.writeFileSync(target, original);
180
+ else
181
+ fs.rmSync(target, { force: true });
182
+ }
183
+ catch {
184
+ // Restore is best-effort.
185
+ }
186
+ },
187
+ };
188
+ }
113
189
  /**
114
190
  * Inject the proxy into every MCP config an agent reads for this session.
115
191
  * Returns the injected files and a single restore() covering all of them.
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Universal Agent Harness — `bivy mcp-serve` entry point.
4
+ //
5
+ // A Bivy-OWNED stdio MCP server (the mirror of `bivy mcp-proxy`, which wraps the
6
+ // agent's OWN servers). Injected into every non-SDK agent's MCP config at session
7
+ // start (see mcp-inject.ts's injectBivyToolsForSession), it exposes Bivy's chat
8
+ // affordances as first-class tools so ANY agent — codex, gemini, aider, opencode,
9
+ // … — discovers them in its tool list instead of having to be told about a shell
10
+ // command (issue #290). Today it serves one tool, `attach_to_chat`.
11
+ //
12
+ // Claude and Pi already get `attach_to_chat` natively (in-process SDK MCP server /
13
+ // integration ToolProvider); this covers everyone else. The tool just POSTs to the
14
+ // node's existing `POST /api/session/:id/attach` endpoint — the exact plumbing
15
+ // `bivy attach` uses — so it reuses the same workspace confinement, storage, and
16
+ // live broadcast. The session id and node URL arrive via env (BIVY_SESSION_ID /
17
+ // BIVY_MCP_ENDPOINT), stamped into the injected server spec.
18
+ //
19
+ // The attach client is factored out and unit-tested (test/harness-mcp-serve.test.ts)
20
+ // with an injected fetch; the process/transport wiring is thin.
21
+ import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
24
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
26
+ const DEFAULT_ENDPOINT = "http://127.0.0.1:4317";
27
+ /** The tools this server advertises, in MCP `tools/list` shape. */
28
+ export const BIVY_MCP_TOOLS = [
29
+ {
30
+ name: "attach_to_chat",
31
+ description: "Send a file or image from the session workspace into the chat the user is reading. The person you're " +
32
+ "talking to is in a chat UI: they cannot see files you only write to disk, and the chat cannot load workspace " +
33
+ "paths or remote image URLs. Use this to show them a report, screenshot, chart, or any file they asked for. An " +
34
+ "image renders inline; any other file shows as a downloadable chip. The path must be inside the session " +
35
+ "workspace. Prefer this over pasting large file contents, describing where a file lives, or markdown image " +
36
+ "syntax (which will not render).",
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ path: { type: "string", description: "Path to the file to send, inside the session workspace (absolute, or relative to it)." },
41
+ caption: { type: "string", description: "Optional short note shown with the attachment." },
42
+ },
43
+ required: ["path"],
44
+ additionalProperties: false,
45
+ },
46
+ },
47
+ ];
48
+ /**
49
+ * Perform an `attach_to_chat` call by POSTing to the node's attach endpoint,
50
+ * which confines the path to the workspace, stores the bytes, and broadcasts the
51
+ * chip. Never throws — every failure is returned as `{ isError: true, text }` so
52
+ * the agent gets an actionable message instead of a broken tool.
53
+ */
54
+ export async function runAttachToChat(endpoint, sessionId, args, fetchImpl, token) {
55
+ if (!sessionId)
56
+ return { isError: true, text: "No active Bivy session to attach to (BIVY_SESSION_ID is not set)." };
57
+ const filePath = typeof args.path === "string" ? args.path.trim() : "";
58
+ if (!filePath)
59
+ return { isError: true, text: "Provide a `path` to a file inside the session workspace." };
60
+ const caption = typeof args.caption === "string" ? args.caption : undefined;
61
+ const base = endpoint.replace(/\/+$/, "");
62
+ const headers = { "content-type": "application/json" };
63
+ if (token)
64
+ headers.authorization = `Bearer ${token}`;
65
+ let res;
66
+ try {
67
+ res = await fetchImpl(`${base}/api/session/${encodeURIComponent(sessionId)}/attach`, {
68
+ method: "POST",
69
+ headers,
70
+ body: JSON.stringify({ path: filePath, caption }),
71
+ });
72
+ }
73
+ catch (error) {
74
+ return { isError: true, text: `Could not reach the Bivy node to attach the file: ${error instanceof Error ? error.message : String(error)}` };
75
+ }
76
+ const body = (await res.json().catch(() => ({})));
77
+ if (!res.ok || body?.ok === false) {
78
+ return { isError: true, text: `Attach failed: ${body?.error || `node returned ${res.status}`}` };
79
+ }
80
+ const name = body?.name || filePath;
81
+ 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.` };
82
+ }
83
+ /** Build the Bivy MCP `Server` with tools/list + tools/call handlers wired. */
84
+ export function createBivyMcpServer(deps = {}) {
85
+ const endpoint = deps.endpoint ?? process.env.BIVY_MCP_ENDPOINT ?? DEFAULT_ENDPOINT;
86
+ const sessionId = deps.sessionId ?? process.env.BIVY_SESSION_ID ?? process.env.BIVY_MCP_SESSION ?? "";
87
+ const token = deps.token ?? process.env.BIVY_MCP_TOKEN ?? undefined;
88
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
89
+ const server = new Server({ name: "bivy", version: "1.0.0" }, { capabilities: { tools: {} } });
90
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: BIVY_MCP_TOOLS }));
91
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
92
+ if (req.params.name !== "attach_to_chat") {
93
+ return { isError: true, content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }] };
94
+ }
95
+ const result = await runAttachToChat(endpoint, sessionId, (req.params.arguments ?? {}), fetchImpl, token);
96
+ return { isError: result.isError, content: [{ type: "text", text: result.text }] };
97
+ });
98
+ return server;
99
+ }
100
+ /** Connect the Bivy MCP server to stdio and serve until the stream closes. */
101
+ export async function runMcpServeCli(deps = {}) {
102
+ const server = createBivyMcpServer(deps);
103
+ const transport = new StdioServerTransport();
104
+ await server.connect(transport);
105
+ // Resolves when the transport closes (agent disconnects / process is killed).
106
+ await new Promise((resolve) => transport.onclose = resolve);
107
+ }
108
+ // Run when invoked directly (via `bivy mcp-serve` → tsx this file). Emit nothing
109
+ // to stdout except JSON-RPC — the transport owns stdin/stdout.
110
+ const invokedDirectly = (() => {
111
+ try {
112
+ return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ })();
118
+ if (invokedDirectly) {
119
+ void runMcpServeCli().catch((error) => {
120
+ process.stderr.write(`bivy mcp-serve: ${error instanceof Error ? error.message : String(error)}\n`);
121
+ process.exit(1);
122
+ });
123
+ }
package/dist/server.js CHANGED
@@ -55,7 +55,7 @@ import { evictToCap, dirSizeBytes } from "./harness/cache-evict.js";
55
55
  import { checkDiskAdmission } from "./harness/disk-admission.js";
56
56
  import { sandboxTier, setConfiguredSandboxTier, normalizeSandboxTier } from "./harness/sandbox.js";
57
57
  import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
58
- import { injectMcpProxyForSession } from "./harness/mcp-inject.js";
58
+ import { injectMcpProxyForSession, injectBivyToolsForSession } from "./harness/mcp-inject.js";
59
59
  import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
60
60
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
61
61
  import { GitHubTaskPoller, resolveGitHubTaskConfig, buildTaskPrompt, buildResumePrompt, buildInteractiveResumePrompt, DEFAULT_ISSUE_INSTRUCTIONS, parseBivyDirectives, commitAll, pushBranch, mergeBaseIntoBranch, completeMerge, abortMerge, findOpenPullRequestForBranch, findPullRequestsForBranch, findMergedPullRequestForBranch, issueBranchName, getPullRequest, commentIssue, listOpenLabelledIssues, selectActionableIssues, getIssue, getIssueCommentBody, addLabel, removeLabel, announcePickup, } from "./github-tasks.js";
@@ -7438,6 +7438,36 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7438
7438
  // Injection is best-effort; never block session creation.
7439
7439
  }
7440
7440
  }
7441
+ // Bivy-owned tools (attach_to_chat, …): make them discoverable to non-SDK
7442
+ // agents by adding a `bivy` server (run via `bivy mcp-serve`) to the agent's
7443
+ // config. Default-on (not gated on BIVY_MCP_PROXY) — it only ADDS a safe,
7444
+ // session-scoped, restored capability the agent already has via env, so the
7445
+ // chat can receive files. Claude/Pi expose these natively (in-process SDK MCP
7446
+ // server / integration ToolProvider), so the tool-interception runtimes are
7447
+ // skipped to avoid a duplicate registration.
7448
+ if (!rt.capabilities.toolInterception) {
7449
+ try {
7450
+ const res = injectBivyToolsForSession(rt.id, {
7451
+ workspace: sessionWorkspace,
7452
+ home: os.homedir(),
7453
+ sessionId,
7454
+ endpoint: process.env.BIVY_MCP_ENDPOINT,
7455
+ });
7456
+ if (res.injected.length) {
7457
+ const prev = record.mcpRestore;
7458
+ record.mcpRestore = () => { try {
7459
+ res.restore();
7460
+ }
7461
+ finally {
7462
+ prev?.();
7463
+ } };
7464
+ console.log(`MCP tools: added bivy server to ${res.injected.length} config(s) for session ${sessionId}`);
7465
+ }
7466
+ }
7467
+ catch {
7468
+ // Best-effort; never block session creation.
7469
+ }
7470
+ }
7441
7471
  rememberSession(record);
7442
7472
  rememberWorkspace(sessionWorkspace);
7443
7473
  attachSessionListeners(record);
@@ -8189,6 +8219,9 @@ app.get("/api/status", (_req, res) => {
8189
8219
  ok: true,
8190
8220
  nodeId: identity.nodeId,
8191
8221
  name: identity.name,
8222
+ // The running daemon's package version, so `bivy status` (and any client)
8223
+ // can report which build the node is on.
8224
+ version: currentVersion() ?? null,
8192
8225
  port,
8193
8226
  workspace: defaultWorkspace,
8194
8227
  appDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.5.1-staging.60",
3
+ "version": "0.5.1-staging.62",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",