@bivy/bivy 0.5.1-staging.59 → 0.5.1-staging.61

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;
@@ -4360,6 +4361,13 @@ An agent's own --help passes through, e.g. 'bivy run claude --help'.`);
4360
4361
  // agent's cwd so relative server commands resolve.
4361
4362
  await run(nodeBin, [...nodeScriptArgs(mcpProxyEntry), ...args], { cwd: process.cwd(), env: process.env });
4362
4363
  break;
4364
+ case "mcp-serve":
4365
+ // Bivy-owned MCP server (exposes attach_to_chat and future chat tools).
4366
+ // Injected into a non-SDK agent's MCP config so the agent discovers the
4367
+ // tool; like mcp-proxy its stdin/stdout ARE the JSON-RPC stream, so emit
4368
+ // nothing else here and inherit stdio verbatim.
4369
+ await run(nodeBin, [...nodeScriptArgs(mcpServeEntry), ...args], { cwd: process.cwd(), env: process.env });
4370
+ break;
4363
4371
  case "help":
4364
4372
  case "-h":
4365
4373
  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
+ }
@@ -1,4 +1,4 @@
1
1
  // SPDX-License-Identifier: FSL-1.1-ALv2
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  export { IntegrationManager } from "./manager.js";
4
- export { BUILT_IN_INTEGRATIONS } from "./registry.js";
4
+ export { BUILT_IN_INTEGRATIONS, ATTACH_TO_CHAT_TOOL } from "./registry.js";
@@ -2,7 +2,7 @@
2
2
  // Copyright (c) 2026 Petter André Sjulstad
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { IntegrationStore } from "./store.js";
5
- import { BUILT_IN_INTEGRATIONS } from "./registry.js";
5
+ import { ATTACH_TO_CHAT_TOOL, BUILT_IN_INTEGRATIONS } from "./registry.js";
6
6
  import { buildAuthorizeUrl, createPkce, exchangeCode, refreshToken, } from "./oauth.js";
7
7
  import { SecretVault } from "../secrets.js";
8
8
  /**
@@ -10,9 +10,11 @@ import { SecretVault } from "../secrets.js";
10
10
  *
11
11
  * - REST handlers in server.ts call `list / connectApiKey / startOAuth /
12
12
  * completeOAuth / disconnect`.
13
- * - `toolProvider()` exposes the connected integrations' tools as a
14
- * runtime-agnostic ToolProvider handed to each session; the tools execute here
15
- * on the daemon (where credentials live) for in-process AND remote agents alike.
13
+ * - `toolProvider()` exposes the connected integrations' tools — plus the
14
+ * always-on `attach_to_chat` tool (issue #291), when the daemon wired an
15
+ * `attachToChat` callback — as a runtime-agnostic ToolProvider handed to each
16
+ * session; the tools execute here on the daemon (where credentials live) for
17
+ * in-process AND remote agents alike.
16
18
  */
17
19
  export class IntegrationManager {
18
20
  store;
@@ -21,11 +23,15 @@ export class IntegrationManager {
21
23
  pending = new Map();
22
24
  /** Names of tools flagged risky (approval-gated), regardless of connection. */
23
25
  riskyTools;
24
- constructor(appDir, registry = BUILT_IN_INTEGRATIONS) {
26
+ /** Backs the native `attach_to_chat` tool (see toolProvider); undefined = the
27
+ * tool isn't offered (e.g. a test harness that never wired one). */
28
+ attachToChat;
29
+ constructor(appDir, registry = BUILT_IN_INTEGRATIONS, attachToChat) {
25
30
  this.store = new IntegrationStore(appDir);
26
31
  this.secrets = new SecretVault(appDir);
27
32
  this.registry = registry;
28
33
  this.riskyTools = new Set(registry.flatMap((d) => d.tools.filter((t) => t.risky).map((t) => t.name)));
34
+ this.attachToChat = attachToChat;
29
35
  }
30
36
  // --- helpers ------------------------------------------------------------
31
37
  def(id) {
@@ -236,15 +242,23 @@ export class IntegrationManager {
236
242
  }
237
243
  // --- agent-agnostic tool provider ---------------------------------------
238
244
  /**
239
- * A runtime-agnostic ToolProvider exposing every connected integration's tools.
240
- * This is the seam the daemon hands to a session (in-process OR remote) so any
241
- * agent can use the tools without the IntegrationManager knowing which agent it
242
- * is — the tools execute HERE, on the daemon, where the credentials/HTTP clients
243
- * live. A snapshot of the connected set is taken per call (at session start),
244
- * mirroring the previous per-session behavior; disconnected integrations
245
- * contribute nothing, so the tool surface and system prompt stay clean.
245
+ * A runtime-agnostic ToolProvider exposing every connected integration's tools,
246
+ * plus the always-on `attach_to_chat` tool (issue #291) when this manager was
247
+ * built with an `attachToChat` callback. This is the seam the daemon hands to a
248
+ * session (in-process OR remote) so any agent can use the tools without the
249
+ * IntegrationManager knowing which agent it is — the tools execute HERE, on the
250
+ * daemon, where the credentials/HTTP clients live. A snapshot of the connected
251
+ * set is taken per call (at session start), mirroring the previous per-session
252
+ * behavior; disconnected integrations contribute nothing, so the tool surface
253
+ * and system prompt stay clean.
254
+ *
255
+ * `sessionIdRef` resolves the calling session for `attach_to_chat`: the
256
+ * provider is built before the session it will serve exists (see
257
+ * AttachToChatFn's doc), so the caller passes a box and fills `.current` in
258
+ * once the id is known rather than a plain string. Ignored (and the tool
259
+ * omitted) when either it or the attachToChat callback is absent.
246
260
  */
247
- toolProvider() {
261
+ toolProvider(sessionIdRef) {
248
262
  const specs = [];
249
263
  const executors = new Map();
250
264
  for (const def of this.registry) {
@@ -266,6 +280,30 @@ export class IntegrationManager {
266
280
  });
267
281
  }
268
282
  }
283
+ if (this.attachToChat && sessionIdRef) {
284
+ const attachToChat = this.attachToChat;
285
+ specs.push({
286
+ name: ATTACH_TO_CHAT_TOOL.name,
287
+ label: ATTACH_TO_CHAT_TOOL.label,
288
+ description: ATTACH_TO_CHAT_TOOL.description,
289
+ promptSnippet: ATTACH_TO_CHAT_TOOL.description,
290
+ parameters: ATTACH_TO_CHAT_TOOL.parameters,
291
+ });
292
+ executors.set(ATTACH_TO_CHAT_TOOL.name, async (params) => {
293
+ const sessionId = sessionIdRef.current;
294
+ if (!sessionId)
295
+ return { content: [{ type: "text", text: "Session is not ready yet — try again in a moment." }], details: {}, isError: true };
296
+ const p = (params ?? {});
297
+ const filePath = typeof p.filePath === "string" ? p.filePath.trim() : "";
298
+ if (!filePath)
299
+ return { content: [{ type: "text", text: "filePath is required" }], details: {}, isError: true };
300
+ const caption = typeof p.caption === "string" ? p.caption : undefined;
301
+ const result = attachToChat(sessionId, { filePath, caption });
302
+ if ("error" in result)
303
+ return { content: [{ type: "text", text: result.error }], details: {}, isError: true };
304
+ return { content: [{ type: "text", text: `Attached ${result.ref.name} (${result.ref.kind}, ${result.ref.mimeType}) to the chat.` }], details: { ref: result.ref } };
305
+ });
306
+ }
269
307
  return {
270
308
  list: () => specs,
271
309
  invoke: async (toolName, _toolCallId, params, signal) => {
@@ -16,6 +16,29 @@ function toRfc822({ to, subject, body }) {
16
16
  const lines = [`To: ${to}`, `Subject: ${subject}`, "Content-Type: text/plain; charset=UTF-8", "", body];
17
17
  return Buffer.from(lines.join("\r\n")).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
18
18
  }
19
+ // ---------------------------------------------------------------------------
20
+ // attach_to_chat (issue #291): the native, tool-based sibling of the CLI
21
+ // `bivy attach <path>` / POST /api/session/:id/attach path #288 added. Unlike
22
+ // the integrations below it needs no auth and isn't gated on a "connected"
23
+ // account — every agent should always have it — so it's declared here as data
24
+ // (name/description/schema, matching every other tool) but wired up by
25
+ // IntegrationManager.toolProvider instead of BUILT_IN_INTEGRATIONS: its
26
+ // executor needs the calling session's id (to push the attachment into the
27
+ // right chat), which the `http`-only IntegrationToolDef.execute shape below
28
+ // has no room for.
29
+ // ---------------------------------------------------------------------------
30
+ export const ATTACH_TO_CHAT_TOOL = {
31
+ name: "attach_to_chat",
32
+ label: "Attach to chat",
33
+ description: "Push a file or image from the session workspace into the chat as an attachment, exactly like the CLI `bivy attach` " +
34
+ "or the composer's paperclip upload but as a direct tool call. Use this — not markdown image syntax, not describing " +
35
+ "where a file lives — whenever the user should see a report, screenshot, chart, or a file they asked for; they " +
36
+ "cannot see files you only write to disk. The path must be inside the session workspace.",
37
+ parameters: Type.Object({
38
+ filePath: Type.String({ description: "Path to the file, absolute or relative to the session workspace." }),
39
+ caption: Type.Optional(Type.String({ description: "Short caption shown next to the attachment in the chat." })),
40
+ }),
41
+ };
19
42
  export const BUILT_IN_INTEGRATIONS = [
20
43
  {
21
44
  id: "notion",
@@ -22,6 +22,7 @@ import fs from "node:fs";
22
22
  import { depCacheEnv } from "../harness/dep-cache.js";
23
23
  import os from "node:os";
24
24
  import path from "node:path";
25
+ import { z } from "zod";
25
26
  import { sandboxTier, claudePermissionModeFor } from "../harness/sandbox.js";
26
27
  import { autoAttachToolImagesEnabled, PassiveImageBudget } from "../harness/tool-image-attachments.js";
27
28
  import { anthropicCredentialPreflight, describeAnthropicError, isAnthropicAuthError } from "./anthropic-preflight.js";
@@ -73,6 +74,46 @@ export const BIVY_ATTACH_SYSTEM_PROMPT = "Sending files and images to the user:
73
74
  "An image renders inline in the chat; any other file shows as a downloadable chip. The path must be inside the session " +
74
75
  "workspace. Do NOT use markdown image syntax like ![](path) to show a local file or a URL — it will not render; always " +
75
76
  "use `bivy attach`. Prefer this over pasting large file contents or describing where a file lives on disk.";
77
+ /** Name of the in-process MCP server the native attach tool is registered
78
+ * under (see buildAttachMcpServer) — the SDK namespaces the tool the agent
79
+ * sees as `mcp__<server>__<tool>`. */
80
+ export const BIVY_ATTACH_MCP_SERVER_NAME = "bivy";
81
+ /** The tool's own name, unnamespaced (see BIVY_ATTACH_MCP_SERVER_NAME). */
82
+ export const BIVY_ATTACH_TOOL_NAME = "attach_to_chat";
83
+ /**
84
+ * Build the in-process MCP server that exposes `attach_to_chat` as a native
85
+ * tool call (issue #291) — the stronger sibling of BIVY_ATTACH_SYSTEM_PROMPT's
86
+ * shell-out hint: the agent sees this in its actual tool list instead of having
87
+ * to discover a shell command from prose. Bound to one session's id so the
88
+ * handler always attaches into the conversation that called it, regardless of
89
+ * how many Claude sessions this node is running concurrently.
90
+ *
91
+ * `sdk` is the already-loaded SDK module (see loadSdk) — `tool`/
92
+ * createSdkMcpServer are read off it dynamically for the same reason the rest
93
+ * of this adapter never imports SDK values statically: the package is
94
+ * optional, and a static import would force every Bivy install to have it.
95
+ * Returns undefined if this SDK build doesn't export the MCP builder helpers
96
+ * (older/trimmed installs) — the caller degrades to prompt-only discoverability.
97
+ */
98
+ function buildAttachMcpServer(sdk, sessionId, attachToChat) {
99
+ if (typeof sdk?.tool !== "function" || typeof sdk?.createSdkMcpServer !== "function")
100
+ return undefined;
101
+ const attachTool = sdk.tool(BIVY_ATTACH_TOOL_NAME, "Push a file or image from the session workspace into the chat as an attachment, exactly like the CLI `bivy attach` " +
102
+ "or the composer's paperclip upload but as a direct tool call. Use this — not markdown image syntax, not describing " +
103
+ "where a file lives — whenever the user should see a report, screenshot, chart, or a file they asked for; they " +
104
+ "cannot see files you only write to disk. The path must be inside the session workspace.", {
105
+ filePath: z.string().describe("Path to the file, absolute or relative to the session workspace."),
106
+ caption: z.string().optional().describe("Short caption shown next to the attachment in the chat."),
107
+ }, async (args) => {
108
+ const result = attachToChat(sessionId, { filePath: args.filePath, caption: args.caption });
109
+ if ("error" in result)
110
+ return { content: [{ type: "text", text: result.error }], isError: true };
111
+ return {
112
+ content: [{ type: "text", text: `Attached ${result.ref.name} (${result.ref.kind}, ${result.ref.mimeType}) to the chat.` }],
113
+ };
114
+ });
115
+ return sdk.createSdkMcpServer({ name: BIVY_ATTACH_MCP_SERVER_NAME, tools: [attachTool] });
116
+ }
76
117
  export function claudeRuntimeFromEnv() {
77
118
  return {
78
119
  defaultModel: process.env.BIVY_CLAUDE_MODEL?.trim() || undefined,
@@ -737,6 +778,9 @@ class ClaudeSession {
737
778
  // Keep the default Claude Code prompt, appending the note that teaches the
738
779
  // agent how to send a file to the user (`bivy attach`) — otherwise the
739
780
  // capability is undiscoverable and "send me X as an attachment" fails.
781
+ // Kept even when the native tool below is also registered: it's a cheap,
782
+ // harmless fallback for a shell/subprocess the agent spawns that can't
783
+ // reach the in-process MCP tool directly.
740
784
  systemPrompt: { type: "preset", preset: "claude_code", append: BIVY_ATTACH_SYSTEM_PROMPT },
741
785
  };
742
786
  if (resumeId)
@@ -745,6 +789,16 @@ class ClaudeSession {
745
789
  options.sessionId = this.id;
746
790
  if (this.desiredModel)
747
791
  options.model = this.desiredModel;
792
+ // Native attach_to_chat tool (issue #291) — the stronger, tool-based sibling
793
+ // of the system-prompt hint above. Wired only when the daemon handed us a
794
+ // callback (see ClaudeCodeRuntimeOptions.attachToChat); absent in a few
795
+ // deliberately minimal test harnesses, and gracefully degrades to the prompt
796
+ // hint alone if this SDK build lacks the MCP builder helpers.
797
+ if (this.runtimeOptions.attachToChat) {
798
+ const attachServer = buildAttachMcpServer(sdk, this.id, this.runtimeOptions.attachToChat);
799
+ if (attachServer)
800
+ options.mcpServers = { [BIVY_ATTACH_MCP_SERVER_NAME]: attachServer };
801
+ }
748
802
  const q = sdk.query({ prompt: this.input, options });
749
803
  this.query = q;
750
804
  void this.consume(q);
@@ -1457,7 +1457,7 @@ export function makeRuntime(options) {
1457
1457
  case "claude-code-sdk":
1458
1458
  // Share the node's provider logins (the shared vault) so the user doesn't
1459
1459
  // re-auth Anthropic for this agent.
1460
- return new ClaudeCodeRuntime({ ...claudeRuntimeFromEnv(), credentials: createCredentialStore(options.credsDir), sandbox: options.sandbox });
1460
+ return new ClaudeCodeRuntime({ ...claudeRuntimeFromEnv(), credentials: createCredentialStore(options.credsDir), sandbox: options.sandbox, attachToChat: options.attachToChat });
1461
1461
  default:
1462
1462
  // Every CLI agent in CLI_AGENT_SPECS is dispatched here as data — no per-id
1463
1463
  // case to maintain. Anything that isn't a known CLI agent throws below.
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";
@@ -484,7 +484,7 @@ function resolveApproval(id, approved) {
484
484
  }
485
485
  return ok;
486
486
  }
487
- const integrations = new IntegrationManager(appDir);
487
+ const integrations = new IntegrationManager(appDir, undefined, attachToChatForSession);
488
488
  const terminals = new TerminalManager();
489
489
  // Per-session agents: a node holds one AgentRuntime instance *per agent id*,
490
490
  // built lazily and cached, instead of a single global runtime. `defaultRuntimeId`
@@ -492,7 +492,7 @@ const terminals = new TerminalManager();
492
492
  // be swapped under a live conversation, so the agent is chosen at session creation
493
493
  // and fixed for that session's life; switching agents in the UI starts a new one.
494
494
  let defaultRuntimeId = (process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
495
- const runtimeHost = new RuntimeHost({ credsDir, piDir, sessionsDir });
495
+ const runtimeHost = new RuntimeHost({ credsDir, piDir, sessionsDir, attachToChat: attachToChatForSession });
496
496
  // In-session model reroute (docs/rulesets.md). Opt-in: set
497
497
  // BIVY_SESSION_MODEL_FALLBACK to a comma-separated model list and a session that
498
498
  // hits an exhausted-credits / rate-limit turn error swaps down the list (via the
@@ -1129,6 +1129,22 @@ function handlePassiveToolImage(record, event) {
1129
1129
  console.warn("[attachments] failed to store a passively-surfaced tool image:", result.error);
1130
1130
  }
1131
1131
  }
1132
+ /**
1133
+ * Session-id-keyed wrapper around attachToChat, handed to runtime adapters as
1134
+ * the `attachToChat` callback that backs each agent's native "attach to chat"
1135
+ * tool surface (Claude's SDK tool, Pi's ToolProvider tool — issue #291). Those
1136
+ * tools are wired at runtime/tool-provider construction time, before the
1137
+ * specific session that will run them exists — a per-session circular
1138
+ * dependency (build the tools -> need the session -> need the tools) — so the
1139
+ * callback takes a session id and resolves the live record from openSessions
1140
+ * when it actually fires, exactly like the HTTP endpoint below does by path.
1141
+ */
1142
+ function attachToChatForSession(sessionId, opts) {
1143
+ const record = openSessions.get(sessionId);
1144
+ if (!record)
1145
+ return { error: "Session not found" };
1146
+ return attachToChat(record, opts);
1147
+ }
1132
1148
  function approvalModeFrom(value) {
1133
1149
  return value === "never" || value === "risky" || value === "always" || value === "autonomous" ? value : undefined;
1134
1150
  }
@@ -6825,7 +6841,9 @@ async function refreshRecordAfterTui(record) {
6825
6841
  record.unsubscribe = undefined;
6826
6842
  const rt = await ensureRuntimeAvailable(record.runtimeId);
6827
6843
  const workspace = record.worktree?.path || oldSession.cwd || record.workspace;
6828
- const runtimeSessionOptions = { workspace, toolProvider: integrations.toolProvider(), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
6844
+ // Refreshing an EXISTING record: its id is already known, so attach_to_chat
6845
+ // (see toolProvider's SessionIdRef doc) can be wired live, not deferred.
6846
+ const runtimeSessionOptions = { workspace, toolProvider: integrations.toolProvider({ current: record.id }), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
6829
6847
  const { session, warning } = await runtimeHost.openSession(rt, { ...runtimeSessionOptions, sessionFile: record.sessionFile });
6830
6848
  record.session = session;
6831
6849
  record.sessionFile = session.sessionFile ?? record.sessionFile;
@@ -7340,7 +7358,12 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7340
7358
  throw new Error(`Refusing to start a session in the shared clone root ${runtimeWorkspace} without an isolated worktree — ` +
7341
7359
  `this would collide with concurrent sessions on the same repo. Retry; if it persists the checkout may be busy.`);
7342
7360
  }
7343
- const runtimeSessionOptions = { workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
7361
+ // A brand-new session's id isn't known until runtimeHost.{create,open}Session
7362
+ // resolves below, but the ToolProvider (and its attach_to_chat tool) must be
7363
+ // built now, up front — so hand it this box instead of a session id and fill
7364
+ // `.current` in the moment `sessionId` is (see toolProvider's SessionIdRef doc).
7365
+ const attachSessionIdRef = {};
7366
+ const runtimeSessionOptions = { workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(attachSessionIdRef), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
7344
7367
  // Stage 2/3: prefer re-attaching to a still-live remote session — routed to its
7345
7368
  // OWN agent service — over re-opening a fresh copy from disk. Falls back to
7346
7369
  // open/create when nothing live is there.
@@ -7358,6 +7381,10 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7358
7381
  ? await runtimeHost.openSession(rt, { ...runtimeSessionOptions, sessionFile: requestedSessionFile })
7359
7382
  : await runtimeHost.createSession(rt, runtimeSessionOptions));
7360
7383
  const sessionId = session.id;
7384
+ // Now that it's known, unblock any attach_to_chat call this session's agent
7385
+ // makes (see attachSessionIdRef above) — set synchronously, well before any
7386
+ // prompt (and so any tool call) can reach this session.
7387
+ attachSessionIdRef.current = sessionId;
7361
7388
  // Resuming an existing session: restore Bivy's canonical name onto the runtime
7362
7389
  // session when the runtime didn't itself (the Claude Code adapter resumes by id
7363
7390
  // and starts nameless). Without this getName() is undefined, so opening a
@@ -7411,6 +7438,36 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
7411
7438
  // Injection is best-effort; never block session creation.
7412
7439
  }
7413
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
+ }
7414
7471
  rememberSession(record);
7415
7472
  rememberWorkspace(sessionWorkspace);
7416
7473
  attachSessionListeners(record);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.5.1-staging.59",
3
+ "version": "0.5.1-staging.61",
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.",
@@ -37,7 +37,8 @@
37
37
  "express": "^5.2.1",
38
38
  "node-pty": "^1.1.0",
39
39
  "typebox": "^1.3.6",
40
- "ws": "^8.21.1"
40
+ "ws": "^8.21.1",
41
+ "zod": "^4.0.0"
41
42
  },
42
43
  "overrides": {
43
44
  "@hono/node-server": "2.0.12",