@bivy/bivy 0.4.0 → 0.5.0-staging.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/attach-session-id.mjs +24 -0
- package/bin/bivy.mjs +9 -5
- package/dist/harness/tool-image-attachments.js +83 -0
- package/dist/runtime/claude-code.js +85 -3
- package/dist/runtime/pi.js +13 -1
- package/dist/runtime/process.js +5 -1
- package/dist/runtime/protocol.js +5 -1
- package/dist/runtime/session-env.js +22 -0
- package/dist/server.js +98 -19
- package/package.json +1 -1
|
@@ -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
|
|
1981
|
-
// injects into the agent's subprocess env
|
|
1982
|
-
//
|
|
1983
|
-
//
|
|
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")
|
|
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  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.
|
|
677
|
-
//
|
|
678
|
-
env
|
|
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`.
|
package/dist/runtime/pi.js
CHANGED
|
@@ -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) {
|
package/dist/runtime/process.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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;
|