@bivy/bivy 0.4.0-staging.54 → 0.4.0-staging.55

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,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,6 +23,7 @@ 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";
@@ -239,6 +240,28 @@ function toolResultText(block) {
239
240
  .map((part) => part.text)
240
241
  .join("");
241
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
+ }
242
265
  /** Sums per-model token usage (SDK's ModelUsage) into a single totals object. */
243
266
  export function sumModelUsage(modelUsage) {
244
267
  let input = 0, output = 0, cacheRead = 0, cacheWrite = 0;
@@ -579,6 +602,13 @@ class ClaudeSession {
579
602
  * "interrupted" notice. One-shot; cleared when consumed, on the next result,
580
603
  * and on a real abort() (a user Stop must never be silenced by a stale flag). */
581
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();
582
612
  /** The agent's own slash commands for this session, learned from the SDK's
583
613
  * system/init message (slash_commands + skills). Empty until the first turn's
584
614
  * init arrives; getCommands() exposes them and a `runtime.commands` event lets
@@ -846,6 +876,31 @@ class ClaudeSession {
846
876
  this.startedMessage = true;
847
877
  this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
848
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
+ }
849
904
  handle(message) {
850
905
  switch (message?.type) {
851
906
  case "stream_event": {
@@ -880,6 +935,8 @@ class ClaudeSession {
880
935
  for (const block of content) {
881
936
  if (block?.type === "tool_use") {
882
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);
883
940
  }
884
941
  }
885
942
  const text = extractText(message.message);
@@ -921,6 +978,8 @@ class ClaudeSession {
921
978
  for (const block of toolResults) {
922
979
  this.runningTools.delete(String(block.tool_use_id));
923
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);
924
983
  }
925
984
  this.emit({ type: "user", raw: message });
926
985
  break;
@@ -1020,6 +1079,9 @@ class ClaudeSession {
1020
1079
  const hasImages = Boolean(options?.images?.length);
1021
1080
  if (!prompt && !hasImages)
1022
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();
1023
1085
  // Credential preflight (first turn only): if no Anthropic credential will
1024
1086
  // reach the SDK, surface an actionable message instead of letting it spawn
1025
1087
  // and fail its first request with an opaque `401 Unauthorized`.
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-staging.54",
3
+ "version": "0.4.0-staging.55",
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.",