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

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.
@@ -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
@@ -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
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.60",
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",