@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.
- package/dist/harness/tool-image-attachments.js +83 -0
- package/dist/runtime/claude-code.js +62 -0
- package/dist/server.js +98 -19
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
1043
|
-
* the
|
|
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
|
|
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(
|
|
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