@bivy/bivy 0.4.0 → 0.5.0

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.
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ // Pure session-id resolution for `bivy attach` (bin/bivy.mjs's cmdAttach),
4
+ // extracted so it's unit-testable without executing the CLI.
5
+ //
6
+ // Every runtime adapter injects BIVY_SESSION_ID into its agent's subprocess env
7
+ // (see src/runtime/session-env.ts) so `bivy attach <path>`, run from the agent's
8
+ // own shell, can resolve its session without being told the id. Pi
9
+ // (src/runtime/pi.ts) is the one exception: its agent loop runs in-process
10
+ // rather than under a subprocess Bivy controls, so it has no hook to inject
11
+ // BIVY_SESSION_ID into its own bash tool's env the way the other adapters do.
12
+ // The pi-coding-agent SDK's bash tool already exposes PI_SESSION_ID to every
13
+ // command it runs by default — and that id IS the Bivy session id for a pi
14
+ // session (PiSession.id reads the exact same SessionManager the SDK reads it
15
+ // from) — so it's accepted here as an equivalent fallback.
16
+ export function resolveAttachSessionId({ sessionFlag, env } = {}) {
17
+ const flag = typeof sessionFlag === "string" ? sessionFlag.trim() : "";
18
+ if (flag) return flag;
19
+ const bivy = env?.BIVY_SESSION_ID?.trim?.();
20
+ if (bivy) return bivy;
21
+ const pi = env?.PI_SESSION_ID?.trim?.();
22
+ if (pi) return pi;
23
+ return undefined;
24
+ }
package/bin/bivy.mjs CHANGED
@@ -38,6 +38,7 @@ import { resolveSessionsLimit, truncateSavedSessions } from "./sessions-list.mjs
38
38
  import { renderManagedBlock, upsertManagedBlock, removeManagedBlock, rcFileForShell } from "./shim-path.mjs";
39
39
  import { removeExcept } from "./uninstall-paths.mjs";
40
40
  import { findAvailablePort, reconcilePort } from "./port-picker.mjs";
41
+ import { resolveAttachSessionId } from "./attach-session-id.mjs";
41
42
 
42
43
  const selfScript = fileURLToPath(import.meta.url);
43
44
  const __dirname = path.dirname(selfScript);
@@ -1977,16 +1978,19 @@ async function cmdSend(args = []) {
1977
1978
  // `bivy attach <file> [--caption "…"] [--session <id>]` — surface a file the
1978
1979
  // agent produced into the chat as an image/file attachment (the reverse of the
1979
1980
  // composer paperclip). The universal path: any agent that can run a shell command
1980
- // can call this. The session id defaults to $BIVY_SESSION_ID, which the daemon
1981
- // injects into the agent's subprocess env. The file is resolved to an absolute
1982
- // path here (the CLI's cwd is the agent's workdir) and confined to the session
1983
- // workspace server-side.
1981
+ // can call this. The session id defaults to $BIVY_SESSION_ID, which every
1982
+ // runtime adapter injects into the agent's subprocess env (see
1983
+ // src/runtime/session-env.ts) except pi, whose SDK exposes its own
1984
+ // $PI_SESSION_ID instead (same id, different var name; see
1985
+ // resolveAttachSessionId). The file is resolved to an absolute path here (the
1986
+ // CLI's cwd is the agent's workdir) and confined to the session workspace
1987
+ // server-side.
1984
1988
  async function cmdAttach(args = []) {
1985
1989
  const flag = (name) => {
1986
1990
  const i = args.indexOf(name);
1987
1991
  return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
1988
1992
  };
1989
- const sessionId = flag("--session") || process.env.BIVY_SESSION_ID;
1993
+ const sessionId = resolveAttachSessionId({ sessionFlag: flag("--session"), env: process.env });
1990
1994
  const caption = flag("--caption");
1991
1995
  const name = flag("--name");
1992
1996
  const mimeType = flag("--mime") || flag("--mimeType");
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Config gate + noise guard for PASSIVELY surfacing tool-produced images (e.g. a
5
+ // screenshot MCP tool's output riding home on a tool_result) into the chat as
6
+ // attachments — see issue #292. Today a runtime adapter that notices an image
7
+ // inside a tool_result (e.g. src/runtime/claude-code.ts's toolResultText, which
8
+ // deliberately keeps only the text parts) can route the image through here to
9
+ // decide whether it's allowed at all, and if so, whether this turn's budget has
10
+ // room for it.
11
+ //
12
+ // Kept independent of both src/runtime and src/session/server so nothing new
13
+ // crosses that layering: a runtime adapter only needs the pure functions below;
14
+ // src/server.ts wires the config setter to settings.json/env — mirroring
15
+ // src/harness/sandbox.ts's live-config pattern — and owns the actual
16
+ // store+persist+broadcast (see handlePassiveToolImage in src/server.ts), the
17
+ // same way it already does for an explicit `bivy attach`.
18
+ // ---- Opt-in gate ---------------------------------------------------------
19
+ //
20
+ // Off by default: an image silently appearing in the chat with no explicit
21
+ // attach call is new, surprising behavior, and a badly-behaved tool could turn
22
+ // it into transcript noise (the per-turn budget below bounds that too, but the
23
+ // gate is the first line of defense — no existing installation should see any
24
+ // change unless it opts in).
25
+ let configuredEnabled;
26
+ /** Set from settings.json at boot and whenever node settings change (mirrors
27
+ * setConfiguredSandboxTier in src/harness/sandbox.ts). */
28
+ export function setConfiguredAutoAttachToolImages(value) {
29
+ configuredEnabled = value === true;
30
+ }
31
+ /** Whether a tool_result image should be captured as a passive attachment.
32
+ * Precedence: `BIVY_AUTO_ATTACH_TOOL_IMAGES` env (plain truthiness — any
33
+ * non-empty value enables, matching BIVY_MCP_PROXY's convention) > the node's
34
+ * persisted `settings.json` setting > off. */
35
+ export function autoAttachToolImagesEnabled() {
36
+ if (process.env.BIVY_AUTO_ATTACH_TOOL_IMAGES)
37
+ return true;
38
+ return configuredEnabled === true;
39
+ }
40
+ // ---- Per-turn noise guard -------------------------------------------------
41
+ //
42
+ // A chatty tool (one that screenshots after every step, say) must never be
43
+ // able to flood the transcript just because the feature is on. Both caps are
44
+ // deliberately small relative to the explicit-attach ceiling
45
+ // (MAX_AGENT_ATTACHMENT_BYTES in src/session/attach-to-chat.ts) — passive
46
+ // images are unreviewed by a human before they land in the chat.
47
+ /** Max images passively surfaced from a single turn's tool results. */
48
+ export const MAX_PASSIVE_IMAGES_PER_TURN = 4;
49
+ /** Max total decoded bytes passively surfaced from a single turn. */
50
+ export const MAX_PASSIVE_IMAGE_BYTES_PER_TURN = 12 * 1024 * 1024;
51
+ /**
52
+ * Tracks how much of one turn's passive-image budget has been spent so a
53
+ * runtime can decide, per image, whether to surface it or drop it. Reset at
54
+ * the start of every turn (a fresh instance is the simplest reset).
55
+ */
56
+ export class PassiveImageBudget {
57
+ count = 0;
58
+ bytes = 0;
59
+ droppedCount = 0;
60
+ droppedBytes = 0;
61
+ /** Reserves the budget and returns true if `byteLength` fits under both caps;
62
+ * otherwise records the drop (for droppedSummary) and returns false. */
63
+ admit(byteLength) {
64
+ if (this.count >= MAX_PASSIVE_IMAGES_PER_TURN || this.bytes + byteLength > MAX_PASSIVE_IMAGE_BYTES_PER_TURN) {
65
+ this.droppedCount += 1;
66
+ this.droppedBytes += byteLength;
67
+ return false;
68
+ }
69
+ this.count += 1;
70
+ this.bytes += byteLength;
71
+ return true;
72
+ }
73
+ /** True once anything has been dropped this turn (for logging at the call site). */
74
+ get hasDropped() {
75
+ return this.droppedCount > 0;
76
+ }
77
+ /** Human-readable summary of what this turn dropped, or "" if nothing was. */
78
+ droppedSummary() {
79
+ if (!this.droppedCount)
80
+ return "";
81
+ return `dropped ${this.droppedCount} tool-produced image(s) totaling ${this.droppedBytes} bytes (per-turn cap: ${MAX_PASSIVE_IMAGES_PER_TURN} images / ${MAX_PASSIVE_IMAGE_BYTES_PER_TURN} bytes)`;
82
+ }
83
+ }
@@ -23,9 +23,11 @@ import { depCacheEnv } from "../harness/dep-cache.js";
23
23
  import os from "node:os";
24
24
  import path from "node:path";
25
25
  import { sandboxTier, claudePermissionModeFor } from "../harness/sandbox.js";
26
+ import { autoAttachToolImagesEnabled, PassiveImageBudget } from "../harness/tool-image-attachments.js";
26
27
  import { anthropicCredentialPreflight, describeAnthropicError, isAnthropicAuthError } from "./anthropic-preflight.js";
27
28
  import { toModelInfo as sharedToModelInfo } from "./normalize.js";
28
29
  import { hasLiveProcessForCwd } from "./native-process-scan.js";
30
+ import { bivySessionEnv } from "./session-env.js";
29
31
  /** Binary names a live Claude Code process could be running under (see
30
32
  * native-process-scan.ts's best-effort cwd match). */
31
33
  const CLAUDE_BIN_NAMES = ["claude"];
@@ -56,6 +58,21 @@ const FALLBACK_MODELS = [
56
58
  { provider: "anthropic", id: "claude-sonnet-5", name: "Claude Sonnet 5", reasoning: true },
57
59
  { provider: "anthropic", id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", reasoning: true },
58
60
  ];
61
+ /**
62
+ * Appended to the Claude Code system prompt so the agent DISCOVERS the outbound
63
+ * attachment capability. `bivy attach` is just a shell command — without this the
64
+ * agent has no way to know it exists and, when asked to "send a file", concludes
65
+ * it can't (it looks for a tool, finds none). BIVY_SESSION_ID is injected into the
66
+ * subprocess env (see spawnQuery), so the bare command resolves the session. Keep
67
+ * this short: it rides on every turn's system prompt.
68
+ */
69
+ export const BIVY_ATTACH_SYSTEM_PROMPT = "Sending files and images to the user: the person you're talking to is in a chat UI. They cannot see files you only " +
70
+ "write to disk, and the chat cannot load remote image URLs or workspace file paths. " +
71
+ "To show them a file or image — a report, screenshot, chart, or a file they asked for — run " +
72
+ '`bivy attach <path> [--caption "short note"]` in your shell. ' +
73
+ "An image renders inline in the chat; any other file shows as a downloadable chip. The path must be inside the session " +
74
+ "workspace. Do NOT use markdown image syntax like ![](path) to show a local file or a URL — it will not render; always " +
75
+ "use `bivy attach`. Prefer this over pasting large file contents or describing where a file lives on disk.";
59
76
  export function claudeRuntimeFromEnv() {
60
77
  return {
61
78
  defaultModel: process.env.BIVY_CLAUDE_MODEL?.trim() || undefined,
@@ -223,6 +240,28 @@ function toolResultText(block) {
223
240
  .map((part) => part.text)
224
241
  .join("");
225
242
  }
243
+ /** Sibling of toolResultText that keeps what that one discards: the `image`
244
+ * content parts of a tool_result (e.g. a Playwright/screenshot MCP tool's
245
+ * output), so they can be passively surfaced as chat attachments (issue #292)
246
+ * instead of silently vanishing. Only base64-sourced images are collected — a
247
+ * `url`-sourced image block (rare for a local tool) is skipped rather than
248
+ * fetched, since this passive path must never make its own network call. */
249
+ function toolResultImages(block) {
250
+ const content = block?.content;
251
+ if (!Array.isArray(content))
252
+ return [];
253
+ const out = [];
254
+ for (const part of content) {
255
+ if (part?.type !== "image")
256
+ continue;
257
+ const source = part.source;
258
+ if (!source || source.type !== "base64" || typeof source.data !== "string" || !source.data)
259
+ continue;
260
+ const mimeType = typeof source.media_type === "string" && source.media_type ? source.media_type : "image/png";
261
+ out.push({ mimeType, data: source.data });
262
+ }
263
+ return out;
264
+ }
226
265
  /** Sums per-model token usage (SDK's ModelUsage) into a single totals object. */
227
266
  export function sumModelUsage(modelUsage) {
228
267
  let input = 0, output = 0, cacheRead = 0, cacheWrite = 0;
@@ -563,6 +602,13 @@ class ClaudeSession {
563
602
  * "interrupted" notice. One-shot; cleared when consumed, on the next result,
564
603
  * and on a real abort() (a user Stop must never be silenced by a stale flag). */
565
604
  suppressNextInterrupt = false;
605
+ /** tool_use id → tool name, learned as "assistant" turns emit tool_use blocks.
606
+ * Used only to label a passively-surfaced tool_image (see #292); never
607
+ * cleared mid-session since a tool_use_id is unique for the session's life. */
608
+ toolNamesByUseId = new Map();
609
+ /** This turn's passive-image noise guard (see PassiveImageBudget); replaced
610
+ * with a fresh budget at the start of every prompt(). */
611
+ passiveImageBudget = new PassiveImageBudget();
566
612
  /** The agent's own slash commands for this session, learned from the SDK's
567
613
  * system/init message (slash_commands + skills). Empty until the first turn's
568
614
  * init arrives; getCommands() exposes them and a `runtime.commands` event lets
@@ -673,9 +719,9 @@ class ClaudeSession {
673
719
  Object.assign(env, credEnv);
674
720
  // Let the agent's own shell surface a file into the chat via `bivy attach`
675
721
  // (POST /api/session/:id/attach). The session id is otherwise invisible to
676
- // the subprocess. Other runtimes should set this the same way to enable the
677
- // universal attach path for their agents.
678
- env.BIVY_SESSION_ID = this.id;
722
+ // the subprocess. Shared with process.ts and protocol.ts via bivySessionEnv
723
+ // (see session-env.ts) so every CLI-spawning adapter injects it the same way.
724
+ Object.assign(env, bivySessionEnv(this.id));
679
725
  this.spawnedToken = authTokenFromEnv(credEnv);
680
726
  const options = {
681
727
  cwd: this.cwd,
@@ -688,6 +734,10 @@ class ClaudeSession {
688
734
  permissionMode,
689
735
  canUseTool,
690
736
  env,
737
+ // Keep the default Claude Code prompt, appending the note that teaches the
738
+ // agent how to send a file to the user (`bivy attach`) — otherwise the
739
+ // capability is undiscoverable and "send me X as an attachment" fails.
740
+ systemPrompt: { type: "preset", preset: "claude_code", append: BIVY_ATTACH_SYSTEM_PROMPT },
691
741
  };
692
742
  if (resumeId)
693
743
  options.resume = resumeId;
@@ -826,6 +876,31 @@ class ClaudeSession {
826
876
  this.startedMessage = true;
827
877
  this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
828
878
  }
879
+ /**
880
+ * Passively surface any images riding home on a tool_result (issue #292) —
881
+ * e.g. a Playwright/screenshot MCP tool's output, which toolResultText above
882
+ * deliberately drops. Gated by autoAttachToolImagesEnabled() at the call site
883
+ * and bounded here by this turn's PassiveImageBudget so a chatty tool can't
884
+ * flood the transcript; a drop is logged (with the responsible tool's name)
885
+ * rather than silently discarded. Emits one `tool_image` RuntimeEvent per
886
+ * admitted image; src/server.ts's session listener does the actual
887
+ * store+persist+broadcast, the same way an explicit `bivy attach` does.
888
+ */
889
+ emitPassiveToolImages(block) {
890
+ const images = toolResultImages(block);
891
+ if (!images.length)
892
+ return;
893
+ const toolUseId = String(block.tool_use_id ?? "");
894
+ const toolName = this.toolNamesByUseId.get(toolUseId) ?? "tool";
895
+ for (const image of images) {
896
+ const byteLength = Buffer.byteLength(image.data, "base64");
897
+ if (!this.passiveImageBudget.admit(byteLength)) {
898
+ console.warn(`[claude-code] dropped a passively-surfaced tool image from "${toolName}" (tool_use_id=${toolUseId}, ~${byteLength} bytes): ${this.passiveImageBudget.droppedSummary()}`);
899
+ continue;
900
+ }
901
+ this.emit({ type: "tool_image", toolUseId, toolName, mimeType: image.mimeType, data: image.data });
902
+ }
903
+ }
829
904
  handle(message) {
830
905
  switch (message?.type) {
831
906
  case "stream_event": {
@@ -860,6 +935,8 @@ class ClaudeSession {
860
935
  for (const block of content) {
861
936
  if (block?.type === "tool_use") {
862
937
  this.emit({ type: "tool_call", toolName: block.name, input: block.input, toolUseId: block.id });
938
+ if (typeof block.id === "string" && typeof block.name === "string")
939
+ this.toolNamesByUseId.set(block.id, block.name);
863
940
  }
864
941
  }
865
942
  const text = extractText(message.message);
@@ -901,6 +978,8 @@ class ClaudeSession {
901
978
  for (const block of toolResults) {
902
979
  this.runningTools.delete(String(block.tool_use_id));
903
980
  this.emit({ type: "tool_result", toolUseId: block.tool_use_id, result: toolResultText(block), isError: Boolean(block.is_error) });
981
+ if (autoAttachToolImagesEnabled())
982
+ this.emitPassiveToolImages(block);
904
983
  }
905
984
  this.emit({ type: "user", raw: message });
906
985
  break;
@@ -1000,6 +1079,9 @@ class ClaudeSession {
1000
1079
  const hasImages = Boolean(options?.images?.length);
1001
1080
  if (!prompt && !hasImages)
1002
1081
  return;
1082
+ // Fresh per-turn noise-guard budget (see PassiveImageBudget) — a prior
1083
+ // turn's usage must never carry over and eat into this one's allowance.
1084
+ this.passiveImageBudget = new PassiveImageBudget();
1003
1085
  // Credential preflight (first turn only): if no Anthropic credential will
1004
1086
  // reach the SDK, surface an actionable message instead of letting it spawn
1005
1087
  // and fail its first request with an opaque `401 Unauthorized`.
@@ -17,6 +17,7 @@ import { createPiModelRuntime } from "./pi-oauth.js";
17
17
  import { toModelInfo as sharedToModelInfo } from "./normalize.js";
18
18
  import { provisionPiAuthJson } from "./credential-provisioning.js";
19
19
  import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
20
+ import { bivySessionEnv } from "./session-env.js";
20
21
  /**
21
22
  * Extract Pi's own slash commands from a live AgentSession: extension commands
22
23
  * (`pi.registerCommand`, exposed via `extensionRunner.getRegisteredCommands()`),
@@ -175,6 +176,17 @@ class PiSession {
175
176
  * daemon's own agent dir and session store (same files the SDK reads) and
176
177
  * resumes by session file, so the TUI shows the live conversation. Returns
177
178
  * null for an unsaved session (nothing to resume yet).
179
+ *
180
+ * This is also the one place PiSession spawns a subprocess Bivy itself
181
+ * configures, so it's where BIVY_SESSION_ID (see session-env.ts) is injected
182
+ * for this adapter. It does NOT cover the live-chat case (an agent turn
183
+ * running pi's own bash tool): pi's SDK runs its agent loop in-process and
184
+ * builds that tool's subprocess env internally, with no hook for a host to
185
+ * inject its own vars. That gap is closed differently — the SDK's bash tool
186
+ * already exposes PI_SESSION_ID to every command it runs, and that id IS the
187
+ * Bivy session id for a pi session (this.id reads the exact same
188
+ * SessionManager the SDK reads it from) — so `bivy attach` accepts
189
+ * PI_SESSION_ID as an equivalent fallback (see bin/attach-session-id.mjs).
178
190
  */
179
191
  async interactiveTuiCommand() {
180
192
  const file = this.sessionFile;
@@ -187,7 +199,7 @@ class PiSession {
187
199
  return {
188
200
  command: process.execPath,
189
201
  args: [this.tui.piCli, "--session", file, "--session-dir", this.tui.sessionsDir],
190
- env: { PI_CODING_AGENT_DIR: this.tui.piDir },
202
+ env: { PI_CODING_AGENT_DIR: this.tui.piDir, ...bivySessionEnv(this.id) },
191
203
  };
192
204
  }
193
205
  prompt(text, options) {
@@ -7,6 +7,7 @@ import { stripAnsi } from "./ansi.js";
7
7
  import { buildAgentCredentialEnv } from "./credentials.js";
8
8
  import { egressEnv } from "../harness/egress.js";
9
9
  import { depCacheEnv } from "../harness/dep-cache.js";
10
+ import { bivySessionEnv } from "./session-env.js";
10
11
  /**
11
12
  * Send `signal` to `child`'s whole process group when possible, so a forking CLI
12
13
  * agent's grandchildren (it shells out to git/npm/build tools, or forks its own
@@ -279,7 +280,10 @@ class ProcessSession {
279
280
  cwd: this.cwd,
280
281
  // egressEnv() routes this agent's outbound traffic through the harness
281
282
  // network broker when BIVY_EGRESS_PROXY is enabled (else it's {}).
282
- env: { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv, ...egressEnv() },
283
+ // bivySessionEnv() lets the agent's own shell resolve its session for
284
+ // `bivy attach <path>` (see session-env.ts); spread last so it can never
285
+ // be shadowed by an operator-configured env var of the same name.
286
+ env: { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv, ...egressEnv(), ...bivySessionEnv(this.id) },
283
287
  stdio: "pipe",
284
288
  // Detached so the child becomes the leader of its own process group
285
289
  // (POSIX) — see killProcessGroup() / abort() below, which kill that whole
@@ -4,6 +4,7 @@ import { spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
6
6
  import { buildAgentCredentialEnv } from "./credentials.js";
7
+ import { bivySessionEnv } from "./session-env.js";
7
8
  import { extractTokenUsage } from "./cli-parsers.js";
8
9
  /** A protocol `usage` message → UsageSnapshot (reuses the CLI token-key scan). */
9
10
  function parseProtocolUsage(raw) {
@@ -258,7 +259,10 @@ class ProtocolSession {
258
259
  : {};
259
260
  const child = spawn(this.runtimeOptions.command, this.runtimeOptions.args ?? [], {
260
261
  cwd: this.cwd,
261
- env: { ...process.env, ...this.runtimeOptions.env, ...credentialEnv },
262
+ // bivySessionEnv() lets the agent's own shell resolve its session for
263
+ // `bivy attach <path>` (see session-env.ts); spread last so it can never
264
+ // be shadowed by an operator-configured env var of the same name.
265
+ env: { ...process.env, ...this.runtimeOptions.env, ...credentialEnv, ...bivySessionEnv(this.id) },
262
266
  stdio: "pipe",
263
267
  });
264
268
  this.child = child;
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Shared across every runtime adapter that spawns (or configures the spawn of) a
5
+ // subprocess for its agent: fold this into that subprocess's env so the agent's
6
+ // own shell can resolve its chat session without being told the id.
7
+ // `bivy attach <path>` (bin/bivy.mjs's cmdAttach) reads $BIVY_SESSION_ID to know
8
+ // which session to post an outbound attachment to — see
9
+ // claude-code.ts's BIVY_ATTACH_SYSTEM_PROMPT for the discoverability half of this
10
+ // feature (issue #288 shipped attach; issue #290 is making it universal). One
11
+ // helper, one env-var name, so a new adapter can't independently invent — or
12
+ // simply forget — its own convention.
13
+ //
14
+ // Used by claude-code.ts (spawnQuery), process.ts (ProcessSession.prompt), and
15
+ // protocol.ts (ProtocolSession.start). pi.ts is the one exception: Pi runs its
16
+ // agent loop in-process rather than spawning a subprocess Bivy controls, so it
17
+ // has no hook to inject this into its bash tool's env the same way — see the
18
+ // comment on PiSession.interactiveTuiCommand and bin/attach-session-id.mjs for
19
+ // how that gap is closed instead.
20
+ export function bivySessionEnv(sessionId) {
21
+ return { BIVY_SESSION_ID: sessionId };
22
+ }
package/dist/server.js CHANGED
@@ -54,6 +54,7 @@ import { initSharedDepCache, sharedDepCacheRoot } from "./harness/dep-cache.js";
54
54
  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
+ import { setConfiguredAutoAttachToolImages } from "./harness/tool-image-attachments.js";
57
58
  import { injectMcpProxyForSession } from "./harness/mcp-inject.js";
58
59
  import { parseRepo, inferGitHubRepoFromWorkspace, isSharedCloneRoot, resolveGitHubToken, cloneOrUpdateRepo, resolveDefaultBaseRef, resolveBranchBaseRef, fetchOrigin } from "./repo-workspace.js";
59
60
  import { configureGitAuth, writeGitCredentialEndpoint } from "./git-auth.js";
@@ -71,7 +72,7 @@ import { normalizeMessages } from "./session/transcript-normal.js";
71
72
  import { buildNativeImportSeedPrompt } from "./session/native-import.js";
72
73
  import { EventLog } from "./session/event-log.js";
73
74
  import { AttachmentStore, isValidAttachmentHash } from "./session/attachment-store.js";
74
- import { planAttachment, isAttachPlanError } from "./session/attach-to-chat.js";
75
+ import { planAttachment, isAttachPlanError, MAX_AGENT_ATTACHMENT_BYTES } from "./session/attach-to-chat.js";
75
76
  import { ReplicationService } from "./session/replication-service.js";
76
77
  import { createSessionNewDedupe } from "./session/session-new-dedupe.js";
77
78
  import { evaluateForkPrereqs, blockingForkPrereqs, missingForkPrereqs } from "./session/fork-prereqs.js";
@@ -1034,30 +1035,25 @@ function materializeAttachments(record, files) {
1034
1035
  return { note: notes.join("\n"), refs };
1035
1036
  }
1036
1037
  /**
1037
- * Surface an AGENT-produced file into the chat as an attachment (image or file)
1038
- * the reverse of the composer paperclip. Confines to the session workspace,
1039
- * stores the bytes in the content-addressed AttachmentStore, persists a durable
1040
- * outbound reference anchored at the current transcript position (so a reload or
1041
- * another device shows it), and emits the live `attachment` event so attached
1042
- * devices render the chip/thumbnail immediately. Shared by the HTTP endpoint and
1043
- * the `bivy attach` CLI. Returns the stored ref, or a human-readable error.
1038
+ * Store attachment bytes, persist a durable outbound reference anchored at the
1039
+ * current transcript position (so a reload or another device shows it), and
1040
+ * emit the live `attachment` event so attached devices render the chip/
1041
+ * thumbnail immediately. The common tail of both `attachToChat` (an explicit
1042
+ * `bivy attach`) and `handlePassiveToolImage` (an image a tool produced,
1043
+ * surfaced with no explicit attach call see issue #292); the only difference
1044
+ * between the two callers is how the bytes were obtained. Records the stored
1045
+ * hash onto `record.seenAttachmentHashes` so a later passive image with
1046
+ * identical bytes de-dupes against this one for free.
1044
1047
  */
1045
- function attachToChat(record, opts) {
1046
- const plan = planAttachment({
1047
- workspaceDir: harnessDirFor(record),
1048
- filePath: opts.filePath,
1049
- mimeType: opts.mimeType,
1050
- name: opts.name,
1051
- });
1052
- if (isAttachPlanError(plan))
1053
- return { error: plan.error };
1048
+ function recordAttachment(record, bytes, opts) {
1054
1049
  let ref;
1055
1050
  try {
1056
- ref = attachmentStore.put(plan.bytes, { name: plan.name, mimeType: plan.mimeType, kind: plan.kind });
1051
+ ref = attachmentStore.put(bytes, { name: opts.name, mimeType: opts.mimeType, kind: opts.kind });
1057
1052
  }
1058
1053
  catch (error) {
1059
1054
  return { error: `Could not store the attachment: ${error instanceof Error ? error.message : String(error)}` };
1060
1055
  }
1056
+ (record.seenAttachmentHashes ??= new Set()).add(ref.hash);
1061
1057
  const entryId = `att-${randomBytes(8).toString("hex")}`;
1062
1058
  const caption = opts.caption ? String(opts.caption).slice(0, 2000) : undefined;
1063
1059
  // Anchor at the current base length so history replay interleaves the
@@ -1067,6 +1063,72 @@ function attachToChat(record, opts) {
1067
1063
  broadcast({ type: "session.event", sessionId: record.id, event: { type: "attachment", id: entryId, ref, caption } });
1068
1064
  return { ref };
1069
1065
  }
1066
+ /**
1067
+ * Surface an AGENT-produced file into the chat as an attachment (image or file)
1068
+ * — the reverse of the composer paperclip. Confines to the session workspace,
1069
+ * then hands off to recordAttachment for the store+persist+broadcast. Shared by
1070
+ * the HTTP endpoint and the `bivy attach` CLI. Returns the stored ref, or a
1071
+ * human-readable error.
1072
+ */
1073
+ function attachToChat(record, opts) {
1074
+ const plan = planAttachment({
1075
+ workspaceDir: harnessDirFor(record),
1076
+ filePath: opts.filePath,
1077
+ mimeType: opts.mimeType,
1078
+ name: opts.name,
1079
+ });
1080
+ if (isAttachPlanError(plan))
1081
+ return { error: plan.error };
1082
+ return recordAttachment(record, plan.bytes, { name: plan.name, mimeType: plan.mimeType, kind: plan.kind, caption: opts.caption });
1083
+ }
1084
+ /** Extension guess for a passively-surfaced tool image, from its mime type. */
1085
+ function extFromImageMime(mimeType) {
1086
+ if (mimeType === "image/jpeg")
1087
+ return "jpg";
1088
+ if (mimeType === "image/gif")
1089
+ return "gif";
1090
+ if (mimeType === "image/webp")
1091
+ return "webp";
1092
+ if (mimeType === "image/svg+xml")
1093
+ return "svg";
1094
+ return "png";
1095
+ }
1096
+ /**
1097
+ * Handle a `tool_image` RuntimeEvent — a runtime adapter (see
1098
+ * src/runtime/claude-code.ts's emitPassiveToolImages) noticed an image inside a
1099
+ * tool_result and, gated on autoAttachToolImagesEnabled() and bounded by its own
1100
+ * per-turn budget, forwarded the raw bytes here. Stores it exactly like an
1101
+ * explicit `bivy attach` (see recordAttachment), except de-duplicated against
1102
+ * anything already surfaced in this session — explicit or passive — by content
1103
+ * hash, so identical bytes (a tool that returns the same screenshot twice, or a
1104
+ * tool result that duplicates bytes the agent already attached) never produce a
1105
+ * second chip. Best-effort: a malformed or oversized payload is dropped with a
1106
+ * warning rather than erroring the turn.
1107
+ */
1108
+ function handlePassiveToolImage(record, event) {
1109
+ const dataB64 = typeof event.data === "string" ? event.data : "";
1110
+ if (!dataB64)
1111
+ return;
1112
+ let bytes;
1113
+ try {
1114
+ bytes = Buffer.from(dataB64, "base64");
1115
+ }
1116
+ catch {
1117
+ return;
1118
+ }
1119
+ if (!bytes.length || bytes.length > MAX_AGENT_ATTACHMENT_BYTES)
1120
+ return;
1121
+ const hash = createHash("sha256").update(bytes).digest("hex");
1122
+ if (record.seenAttachmentHashes?.has(hash))
1123
+ return;
1124
+ const mimeType = typeof event.mimeType === "string" && event.mimeType ? event.mimeType : "image/png";
1125
+ const toolName = typeof event.toolName === "string" && event.toolName.trim() ? event.toolName.trim() : "tool";
1126
+ const name = sanitizeAttachmentFilename(`${toolName}-${hash.slice(0, 8)}.${extFromImageMime(mimeType)}`);
1127
+ const result = recordAttachment(record, bytes, { name, mimeType, kind: "image", caption: `From ${toolName}` });
1128
+ if ("error" in result) {
1129
+ console.warn("[attachments] failed to store a passively-surfaced tool image:", result.error);
1130
+ }
1131
+ }
1070
1132
  function approvalModeFrom(value) {
1071
1133
  return value === "never" || value === "risky" || value === "always" || value === "autonomous" ? value : undefined;
1072
1134
  }
@@ -1234,6 +1296,7 @@ function nodeSettingsSnapshot() {
1234
1296
  return typeof v === "string" && v.trim() ? v.trim() : undefined;
1235
1297
  })(),
1236
1298
  sessionResumeMode: nodeSessionResumeMode(),
1299
+ autoAttachToolImages: readSettings().autoAttachToolImages === true,
1237
1300
  };
1238
1301
  }
1239
1302
  async function applyNodeSettings(patch) {
@@ -1290,14 +1353,21 @@ async function applyNodeSettings(patch) {
1290
1353
  if ("sessionResumeMode" in patch) {
1291
1354
  settings.sessionResumeMode = patch.sessionResumeMode === "manual" ? "manual" : "auto";
1292
1355
  }
1356
+ if ("autoAttachToolImages" in patch) {
1357
+ settings.autoAttachToolImages = patch.autoAttachToolImages === true;
1358
+ setConfiguredAutoAttachToolImages(settings.autoAttachToolImages);
1359
+ }
1293
1360
  writeSettings(settings);
1294
1361
  const snapshot = nodeSettingsSnapshot();
1295
1362
  broadcast({ type: "node.settings", settings: snapshot });
1296
1363
  return snapshot;
1297
1364
  }
1298
1365
  // Apply persisted node settings at boot: seed the effective sandbox tier and the
1299
- // default runtime from settings.json (env still wins for the sandbox).
1366
+ // default runtime from settings.json (env still wins for the sandbox), plus the
1367
+ // passive tool-image-attachment gate (issue #292; BIVY_AUTO_ATTACH_TOOL_IMAGES
1368
+ // still wins — see src/harness/tool-image-attachments.ts).
1300
1369
  setConfiguredSandboxTier(readSettings().defaultSandbox);
1370
+ setConfiguredAutoAttachToolImages(readSettings().autoAttachToolImages);
1301
1371
  {
1302
1372
  const savedAgent = readSettings().defaultAgent;
1303
1373
  if (typeof savedAgent === "string" && savedAgent.trim()) {
@@ -6599,6 +6669,15 @@ function attachSessionListeners(record) {
6599
6669
  // text the user is watching stream in.
6600
6670
  if (event.type !== "message_update")
6601
6671
  sessionEvents.flush(record.id);
6672
+ if (event.type === "tool_image") {
6673
+ // A runtime adapter (e.g. Claude Code) noticed an image inside a
6674
+ // tool_result and forwarded the raw bytes — store/persist/broadcast it as
6675
+ // a chat attachment (see handlePassiveToolImage) instead of the generic
6676
+ // session.event wrap below, which would otherwise ship the raw base64
6677
+ // payload to every client.
6678
+ handlePassiveToolImage(record, event);
6679
+ return;
6680
+ }
6602
6681
  const currentSessionFile = record.session.sessionFile;
6603
6682
  if (currentSessionFile && currentSessionFile !== record.sessionFile) {
6604
6683
  record.sessionFile = currentSessionFile;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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.",