@bivy/bivy 0.18.9-staging.4 → 0.18.9-staging.6
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/codex-app-server-shim.mjs +3 -0
- package/dist/agent-instructions.js +103 -0
- package/dist/agents/claude-code/runtime.js +5 -3
- package/dist/agents/pi/runtime.js +3 -0
- package/dist/agents/profiles.js +3 -0
- package/dist/harness/mcp-config.js +4 -0
- package/dist/harness/mcp-inject.js +1 -1
- package/dist/harness/mcp-serve-cli.js +14 -1
- package/dist/runtime/agent-service.js +2 -2
- package/dist/runtime/index.js +3 -0
- package/dist/runtime/process.js +3 -3
- package/dist/runtime/protocol.js +14 -6
- package/dist/runtime/remote.js +2 -2
- package/dist/runtime/session-env.js +18 -2
- package/dist/server.js +34 -6
- package/package.json +2 -2
|
@@ -478,6 +478,9 @@ function threadParams(msg, extra = {}) {
|
|
|
478
478
|
approvalPolicy: APPROVAL_POLICY,
|
|
479
479
|
sandbox: SANDBOX_MODE,
|
|
480
480
|
...(selectedModel ? { model: selectedModel } : {}),
|
|
481
|
+
// The user's account-wide Bivy instructions ride as developer instructions,
|
|
482
|
+
// alongside (not instead of) the AGENTS.md files Codex loads itself.
|
|
483
|
+
...(typeof msg.instructions === "string" && msg.instructions ? { developerInstructions: msg.instructions } : {}),
|
|
481
484
|
...extra,
|
|
482
485
|
};
|
|
483
486
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Account-wide agent instructions: one Markdown file of the user's global
|
|
7
|
+
* preferences, handed to every agent session IN ADDITION to the repo's own
|
|
8
|
+
* AGENTS.md/CLAUDE.md (which the agent keeps loading itself).
|
|
9
|
+
*
|
|
10
|
+
* The source of truth on a machine is `<appDir>/AGENTS.md` — plain Markdown the
|
|
11
|
+
* user can also edit by hand. Its mtime is the last-writer-wins clock: the
|
|
12
|
+
* account-wide copy rides in the E2E model-auth vault envelope (see server.ts),
|
|
13
|
+
* and a synced import stamps the file with the writer's timestamp so every
|
|
14
|
+
* machine compares like with like. Clearing is an empty file, not a delete, so
|
|
15
|
+
* the empty state carries a timestamp and propagates too.
|
|
16
|
+
*
|
|
17
|
+
* Delivery is the caller's business; this module only produces the composed
|
|
18
|
+
* text (and a file holding it, for agents that take a path).
|
|
19
|
+
*/
|
|
20
|
+
/** Cap on the editable text. It rides on every turn of every session. */
|
|
21
|
+
export const MAX_AGENT_INSTRUCTIONS_BYTES = 16 * 1024;
|
|
22
|
+
const PREAMBLE = "The user's global Bivy instructions follow. They apply to every workspace; where they conflict with " +
|
|
23
|
+
"instructions from the current repository (AGENTS.md, CLAUDE.md, …), the repository's instructions win.";
|
|
24
|
+
export function agentInstructionsPath(appDir) {
|
|
25
|
+
return path.join(appDir, "AGENTS.md");
|
|
26
|
+
}
|
|
27
|
+
function composedPath(appDir) {
|
|
28
|
+
return path.join(appDir, "agent-instructions.composed.md");
|
|
29
|
+
}
|
|
30
|
+
export function readAgentInstructions(appDir) {
|
|
31
|
+
try {
|
|
32
|
+
const file = agentInstructionsPath(appDir);
|
|
33
|
+
return { text: fs.readFileSync(file, "utf8"), updatedAt: Math.trunc(fs.statSync(file).mtimeMs) };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { text: "", updatedAt: 0 };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function writeAgentInstructions(appDir, text, updatedAt = Date.now()) {
|
|
40
|
+
if (Buffer.byteLength(text, "utf8") > MAX_AGENT_INSTRUCTIONS_BYTES) {
|
|
41
|
+
throw new Error(`Agent instructions are limited to ${MAX_AGENT_INSTRUCTIONS_BYTES / 1024} KB.`);
|
|
42
|
+
}
|
|
43
|
+
const file = agentInstructionsPath(appDir);
|
|
44
|
+
fs.mkdirSync(appDir, { recursive: true });
|
|
45
|
+
fs.writeFileSync(file, text);
|
|
46
|
+
fs.utimesSync(file, new Date(updatedAt), new Date(updatedAt));
|
|
47
|
+
return readAgentInstructions(appDir);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Reconcile a synced copy with the local file (last writer wins).
|
|
51
|
+
* - "imported": the synced copy was newer and is now the local file.
|
|
52
|
+
* - "local-newer": this machine holds a newer copy the account should get.
|
|
53
|
+
* - "unchanged": nothing to do.
|
|
54
|
+
*/
|
|
55
|
+
export function mergeSyncedAgentInstructions(appDir, incoming) {
|
|
56
|
+
const local = readAgentInstructions(appDir);
|
|
57
|
+
const synced = incoming && typeof incoming === "object" ? incoming : undefined;
|
|
58
|
+
if (typeof synced?.text !== "string" || typeof synced.updatedAt !== "number") {
|
|
59
|
+
return local.updatedAt > 0 ? "local-newer" : "unchanged";
|
|
60
|
+
}
|
|
61
|
+
if (synced.text === local.text)
|
|
62
|
+
return "unchanged";
|
|
63
|
+
if (synced.updatedAt > local.updatedAt) {
|
|
64
|
+
writeAgentInstructions(appDir, synced.text, synced.updatedAt);
|
|
65
|
+
return "imported";
|
|
66
|
+
}
|
|
67
|
+
return "local-newer";
|
|
68
|
+
}
|
|
69
|
+
/** Wrap the user's text in the precedence preamble; "" when there is nothing to send. */
|
|
70
|
+
export function composeAgentInstructions(text) {
|
|
71
|
+
const body = text.trim();
|
|
72
|
+
return body ? `${PREAMBLE}\n\n${body}\n` : "";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The instructions for a session starting now, or undefined when the user has
|
|
76
|
+
* none. Keeps a composed copy on disk (rewritten only when it changes) for
|
|
77
|
+
* agents that take a file path rather than text.
|
|
78
|
+
*/
|
|
79
|
+
export function sessionInstructions(appDir) {
|
|
80
|
+
const text = composeAgentInstructions(readAgentInstructions(appDir).text);
|
|
81
|
+
const file = composedPath(appDir);
|
|
82
|
+
try {
|
|
83
|
+
if (!text) {
|
|
84
|
+
// An MCP server spec injected earlier may still point here; don't let it
|
|
85
|
+
// serve instructions the user has since cleared.
|
|
86
|
+
fs.rmSync(file, { force: true });
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
let current;
|
|
90
|
+
try {
|
|
91
|
+
current = fs.readFileSync(file, "utf8");
|
|
92
|
+
}
|
|
93
|
+
catch { /* not written yet */ }
|
|
94
|
+
if (current !== text)
|
|
95
|
+
fs.writeFileSync(file, text);
|
|
96
|
+
return { text, file };
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Never block a session on this; it just starts without them.
|
|
100
|
+
console.warn("[agent-instructions] could not prepare instructions:", error.message);
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -880,7 +880,9 @@ class ClaudeSession {
|
|
|
880
880
|
// Kept even when the native tool below is also registered: it's a cheap,
|
|
881
881
|
// harmless fallback for a shell/subprocess the agent spawns that can't
|
|
882
882
|
// reach the in-process MCP tool directly.
|
|
883
|
-
|
|
883
|
+
// The user's account-wide instructions follow it; the repo's CLAUDE.md
|
|
884
|
+
// still loads through the preset as usual.
|
|
885
|
+
systemPrompt: { type: "preset", preset: "claude_code", append: [BIVY_ATTACH_SYSTEM_PROMPT, this.runtimeOptions.instructions].filter(Boolean).join("\n\n") },
|
|
884
886
|
...(this.runtimeOptions.executablePath ? { pathToClaudeCodeExecutable: this.runtimeOptions.executablePath } : {}),
|
|
885
887
|
};
|
|
886
888
|
if (resumeId)
|
|
@@ -1511,12 +1513,12 @@ export class ClaudeCodeRuntime {
|
|
|
1511
1513
|
return [{ id: "anthropic", name: "Anthropic", oauth: true, models: FALLBACK_MODELS }];
|
|
1512
1514
|
}
|
|
1513
1515
|
async createSession(options) {
|
|
1514
|
-
const session = new ClaudeSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, options.toolInterceptor, options.toolProvider);
|
|
1516
|
+
const session = new ClaudeSession({ ...(await withSessionCredentials(this.options, options.credentialLabels)), instructions: options.instructions?.text }, options.workspace, options.toolInterceptor, options.toolProvider);
|
|
1515
1517
|
this.sessions.push(session);
|
|
1516
1518
|
return { session };
|
|
1517
1519
|
}
|
|
1518
1520
|
async openSession(options) {
|
|
1519
|
-
const session = new ClaudeSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, options.toolInterceptor, options.toolProvider, options.sessionFile);
|
|
1521
|
+
const session = new ClaudeSession({ ...(await withSessionCredentials(this.options, options.credentialLabels)), instructions: options.instructions?.text }, options.workspace, options.toolInterceptor, options.toolProvider, options.sessionFile);
|
|
1520
1522
|
this.sessions.push(session);
|
|
1521
1523
|
return {
|
|
1522
1524
|
session,
|
|
@@ -459,6 +459,9 @@ export class PiRuntime {
|
|
|
459
459
|
modelRuntime,
|
|
460
460
|
resourceLoaderOptions: {
|
|
461
461
|
extensionFactories: [guardianFactory(sessionId, options.toolInterceptor), ...providedTools],
|
|
462
|
+
// Additive: keep whatever Pi discovered itself, then the user's
|
|
463
|
+
// account-wide instructions (see OpenSessionOptions.instructions).
|
|
464
|
+
...(options.instructions ? { appendSystemPromptOverride: (base) => [...base, options.instructions.text] } : {}),
|
|
462
465
|
},
|
|
463
466
|
});
|
|
464
467
|
// Override only the built-in bash definition. Pi still owns rendering,
|
package/dist/agents/profiles.js
CHANGED
|
@@ -48,6 +48,9 @@ export const AGENT_PROFILES = {
|
|
|
48
48
|
// `opencode run -s <id> "<prompt>"` continues a prior session by its own id
|
|
49
49
|
// (`-s, --session session id to continue`, per `opencode run --help`).
|
|
50
50
|
resume: { template: ["run", "-s", "{id}"] },
|
|
51
|
+
// OPENCODE_CONFIG_CONTENT is merged over OpenCode's own config; its
|
|
52
|
+
// `instructions` entries are loaded alongside the repo's AGENTS.md.
|
|
53
|
+
instructions: { env: { OPENCODE_CONFIG_CONTENT: '{"instructions":["{fileJson}"]}' } },
|
|
51
54
|
// `opencode run --model <provider/model> "<prompt>"` — the flag follows the
|
|
52
55
|
// `run` subcommand (insertAt: 1). Models are `provider/model` ids.
|
|
53
56
|
model: {
|
|
@@ -97,6 +97,10 @@ export function bivyToolsServerSpec(opts) {
|
|
|
97
97
|
const env = { BIVY_SESSION_ID: opts.sessionId };
|
|
98
98
|
if (opts.endpoint)
|
|
99
99
|
env.BIVY_MCP_ENDPOINT = opts.endpoint;
|
|
100
|
+
// The user's account-wide instructions, advertised as the server's MCP
|
|
101
|
+
// `instructions` — the best-effort channel for agents with no native one.
|
|
102
|
+
if (opts.instructionsFile)
|
|
103
|
+
env.BIVY_AGENT_INSTRUCTIONS_FILE = opts.instructionsFile;
|
|
100
104
|
return { command: opts.bivyCommand ?? "bivy", args: ["mcp-serve"], env };
|
|
101
105
|
}
|
|
102
106
|
/**
|
|
@@ -136,7 +136,7 @@ export function injectBivyToolsForSession(agentId, ctx, bivyCommand = "bivy") {
|
|
|
136
136
|
});
|
|
137
137
|
if (!target)
|
|
138
138
|
return { injected: [], restore: () => { } };
|
|
139
|
-
const spec = bivyToolsServerSpec({ sessionId: ctx.sessionId, endpoint: ctx.endpoint, bivyCommand });
|
|
139
|
+
const spec = bivyToolsServerSpec({ sessionId: ctx.sessionId, endpoint: ctx.endpoint, bivyCommand, instructionsFile: ctx.instructionsFile });
|
|
140
140
|
const ext = path.extname(target).toLowerCase();
|
|
141
141
|
const openCode = agentId === "opencode" || isOpenCodeConfigFile(target);
|
|
142
142
|
const existed = fs.existsSync(target);
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// with an injected fetch; the process/transport wiring is thin. Deliberately do
|
|
21
21
|
// not expose governed Runs here: agents should use their native sub-agent tools,
|
|
22
22
|
// which stay inside the parent Session instead of cluttering the Session list.
|
|
23
|
+
import fs from "node:fs";
|
|
23
24
|
import path from "node:path";
|
|
24
25
|
import { fileURLToPath } from "node:url";
|
|
25
26
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
@@ -88,13 +89,25 @@ export async function runAttachToChat(endpoint, sessionId, args, fetchImpl, toke
|
|
|
88
89
|
const name = body?.name || filePath;
|
|
89
90
|
return { isError: false, text: `Attached ${name} to the chat as ${body?.kind === "image" ? "an inline image" : "a downloadable file"}. The user can see it now.` };
|
|
90
91
|
}
|
|
92
|
+
/** The user's instructions to advertise at initialize, or undefined when there are none. */
|
|
93
|
+
function readInstructions(file) {
|
|
94
|
+
if (!file)
|
|
95
|
+
return undefined;
|
|
96
|
+
try {
|
|
97
|
+
return fs.readFileSync(file, "utf8").trim() || undefined;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
91
103
|
/** Build the Bivy MCP `Server` with tools/list + tools/call handlers wired. */
|
|
92
104
|
export function createBivyMcpServer(deps = {}) {
|
|
93
105
|
const endpoint = deps.endpoint ?? process.env.BIVY_MCP_ENDPOINT ?? DEFAULT_ENDPOINT;
|
|
94
106
|
const sessionId = deps.sessionId ?? process.env.BIVY_SESSION_ID ?? process.env.BIVY_MCP_SESSION ?? "";
|
|
95
107
|
const token = deps.token ?? process.env.BIVY_MCP_TOKEN ?? undefined;
|
|
96
108
|
const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
|
|
97
|
-
const
|
|
109
|
+
const instructions = readInstructions(deps.instructionsFile ?? process.env.BIVY_AGENT_INSTRUCTIONS_FILE);
|
|
110
|
+
const server = new Server({ name: "bivy", version: "1.0.0" }, { capabilities: { tools: {} }, ...(instructions ? { instructions } : {}) });
|
|
98
111
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: BIVY_MCP_TOOLS }));
|
|
99
112
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
100
113
|
const args = (req.params.arguments ?? {});
|
|
@@ -135,8 +135,8 @@ export class AgentService {
|
|
|
135
135
|
const toolProvider = message.options.toolSpecs?.length ? this.makeToolProvider(svc, message.options.toolSpecs) : undefined;
|
|
136
136
|
const workspace = message.options.workspace ?? process.cwd();
|
|
137
137
|
const result = message.op === "open" && message.options.sessionFile
|
|
138
|
-
? await runtime.openSession({ workspace, credentialLabels: message.options.credentialLabels, sessionFile: message.options.sessionFile, toolInterceptor, toolProvider })
|
|
139
|
-
: await runtime.createSession({ workspace, credentialLabels: message.options.credentialLabels, toolInterceptor, toolProvider });
|
|
138
|
+
? await runtime.openSession({ workspace, credentialLabels: message.options.credentialLabels, sessionFile: message.options.sessionFile, toolInterceptor, toolProvider, instructions: message.options.instructions })
|
|
139
|
+
: await runtime.createSession({ workspace, credentialLabels: message.options.credentialLabels, toolInterceptor, toolProvider, instructions: message.options.instructions });
|
|
140
140
|
svc.session = result.session;
|
|
141
141
|
svc.id = result.session.id;
|
|
142
142
|
svc.lastSent = this.mirror(result.session);
|
package/dist/runtime/index.js
CHANGED
|
@@ -643,6 +643,7 @@ function acpRuntimeOptions(opts) {
|
|
|
643
643
|
// An ACP-promoted opencode still surfaces/expands its on-disk commands (the
|
|
644
644
|
// ACP handshake doesn't carry them); a bare ACP agent has none.
|
|
645
645
|
...(slashCommands ? { slashCommands } : {}),
|
|
646
|
+
...(opts.instructionsEnv ? { instructionsEnv: opts.instructionsEnv } : {}),
|
|
646
647
|
...(opts.credsDir ? { credentials: createCredentialStore(opts.credsDir) } : {}),
|
|
647
648
|
// Declared credential behaviors apply to the governed path exactly as they do
|
|
648
649
|
// to the pipe path: the preflight surfaces a clear no-credential error before
|
|
@@ -1147,6 +1148,7 @@ function makeCliRuntime(id, options, spec) {
|
|
|
1147
1148
|
...(spec.authOwner && spec.authOwner !== "agent" ? { credsDir: options.credsDir } : {}),
|
|
1148
1149
|
sandbox: options.sandbox,
|
|
1149
1150
|
mcpConfig: options.mcpConfig,
|
|
1151
|
+
instructionsEnv: spec.instructions?.env,
|
|
1150
1152
|
}));
|
|
1151
1153
|
}
|
|
1152
1154
|
const structured = executionMode === "structured-pipe";
|
|
@@ -1264,6 +1266,7 @@ function makeCliRuntime(id, options, spec) {
|
|
|
1264
1266
|
thinking: cliThinkingConfig(id, spec),
|
|
1265
1267
|
usageReporting: cliUsageReporting(spec),
|
|
1266
1268
|
slashCommands: cliSlashCommands(spec),
|
|
1269
|
+
...(spec.instructions ? { instructionsEnv: spec.instructions.env } : {}),
|
|
1267
1270
|
...resumeOpts,
|
|
1268
1271
|
...nativeSessionOpts,
|
|
1269
1272
|
});
|
package/dist/runtime/process.js
CHANGED
|
@@ -8,7 +8,7 @@ import { buildAgentCredentialEnv } from "./credentials.js";
|
|
|
8
8
|
import { withSessionCredentials, credentialEnvFallback } from "../credentials/session.js";
|
|
9
9
|
import { egressEnv, sessionEgressEnv } from "../harness/egress.js";
|
|
10
10
|
import { depCacheEnv } from "../harness/dep-cache.js";
|
|
11
|
-
import { bivySessionEnv } from "./session-env.js";
|
|
11
|
+
import { bivySessionEnv, withSessionInstructions } from "./session-env.js";
|
|
12
12
|
import { withExactCapabilitySurface } from "./types.js";
|
|
13
13
|
/**
|
|
14
14
|
* Send `signal` to `child`'s whole process group when possible, so a forking CLI
|
|
@@ -539,7 +539,7 @@ export class ProcessRuntime {
|
|
|
539
539
|
return [...byProvider.values()];
|
|
540
540
|
}
|
|
541
541
|
async createSession(options) {
|
|
542
|
-
const session = new ProcessSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace);
|
|
542
|
+
const session = new ProcessSession(withSessionInstructions(await withSessionCredentials(this.options, options.credentialLabels), options.instructions), options.workspace);
|
|
543
543
|
this.sessions.push(session);
|
|
544
544
|
return { session, warning: "Generic CLI runtime streams stdout/stderr only; approvals, model picker, and resume depend on the underlying agent protocol." };
|
|
545
545
|
}
|
|
@@ -547,7 +547,7 @@ export class ProcessRuntime {
|
|
|
547
547
|
// Resumable runtimes bind the agent's session id so each prompt continues it
|
|
548
548
|
// (see resumeArgs); non-resumable ones ignore the ref and start fresh.
|
|
549
549
|
if (this.options.resumable) {
|
|
550
|
-
const session = new ProcessSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, options.sessionFile);
|
|
550
|
+
const session = new ProcessSession(withSessionInstructions(await withSessionCredentials(this.options, options.credentialLabels), options.instructions), options.workspace, options.sessionFile);
|
|
551
551
|
this.sessions.push(session);
|
|
552
552
|
return { session };
|
|
553
553
|
}
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { EventEmitter } from "node:events";
|
|
6
6
|
import { buildAgentCredentialEnv } from "./credentials.js";
|
|
7
7
|
import { withSessionCredentials, credentialEnvFallback } from "../credentials/session.js";
|
|
8
|
-
import { bivySessionEnv } from "./session-env.js";
|
|
8
|
+
import { bivySessionEnv, withSessionInstructions } from "./session-env.js";
|
|
9
9
|
import { mergeAgentCommands } from "./slash-commands.js";
|
|
10
10
|
import { withExactCapabilitySurface } from "./types.js";
|
|
11
11
|
import { extractTokenUsage } from "./cli-parsers.js";
|
|
@@ -157,6 +157,7 @@ class ProtocolSession {
|
|
|
157
157
|
cwd;
|
|
158
158
|
capabilitiesRef;
|
|
159
159
|
toolInterceptor;
|
|
160
|
+
instructions;
|
|
160
161
|
id;
|
|
161
162
|
child;
|
|
162
163
|
buffer = "";
|
|
@@ -225,11 +226,15 @@ class ProtocolSession {
|
|
|
225
226
|
// taking the ref as its id — otherwise the daemon persists a SECOND metadata
|
|
226
227
|
// row keyed by the ref, duplicating the conversation. Falls back to the ref,
|
|
227
228
|
// then a fresh UUID, preserving the prior behaviour when no id is supplied.
|
|
228
|
-
canonicalId
|
|
229
|
+
canonicalId,
|
|
230
|
+
// The user's account-wide instructions (OpenSessionOptions.instructions),
|
|
231
|
+
// sent on session.create/session.resume for the shim to hand its agent.
|
|
232
|
+
instructions) {
|
|
229
233
|
this.runtimeOptions = runtimeOptions;
|
|
230
234
|
this.cwd = cwd;
|
|
231
235
|
this.capabilitiesRef = capabilitiesRef;
|
|
232
236
|
this.toolInterceptor = toolInterceptor;
|
|
237
|
+
this.instructions = instructions;
|
|
233
238
|
this.id = canonicalId || resumeRef || randomUUID();
|
|
234
239
|
this.resumeRef = resumeRef;
|
|
235
240
|
if (resumeRef) {
|
|
@@ -891,12 +896,15 @@ class ProtocolSession {
|
|
|
891
896
|
// resume.
|
|
892
897
|
const resumeRef = this.resumeRef ?? this.runtimeSessionRef;
|
|
893
898
|
const created = resumeRef && this.capabilitiesRef.resume
|
|
894
|
-
? await this.command("session.resume", { workspace: this.cwd, sessionId: this.id, runtimeSessionRef: resumeRef, resumeRef })
|
|
895
|
-
: await this.command("session.create", { workspace: this.cwd, sessionId: this.id });
|
|
899
|
+
? await this.command("session.resume", { workspace: this.cwd, sessionId: this.id, runtimeSessionRef: resumeRef, resumeRef, ...this.instructionsField() })
|
|
900
|
+
: await this.command("session.create", { workspace: this.cwd, sessionId: this.id, ...this.instructionsField() });
|
|
896
901
|
if (typeof created.runtimeSessionRef === "string")
|
|
897
902
|
this.runtimeSessionRef = created.runtimeSessionRef;
|
|
898
903
|
this.started = true;
|
|
899
904
|
}
|
|
905
|
+
instructionsField() {
|
|
906
|
+
return this.instructions ? { instructions: this.instructions } : {};
|
|
907
|
+
}
|
|
900
908
|
async prompt(text, options) {
|
|
901
909
|
const wasStarted = this.started;
|
|
902
910
|
await this.open();
|
|
@@ -1039,7 +1047,7 @@ export class ProtocolRuntime {
|
|
|
1039
1047
|
return this.options.catalog ?? [];
|
|
1040
1048
|
}
|
|
1041
1049
|
async createSession(options) {
|
|
1042
|
-
const session = new ProtocolSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, this.capabilities, options.toolInterceptor);
|
|
1050
|
+
const session = new ProtocolSession(withSessionInstructions(await withSessionCredentials(this.options, options.credentialLabels), options.instructions), options.workspace, this.capabilities, options.toolInterceptor, undefined, undefined, options.instructions?.text);
|
|
1043
1051
|
try {
|
|
1044
1052
|
await session.start();
|
|
1045
1053
|
this.sessions.push(session);
|
|
@@ -1056,7 +1064,7 @@ export class ProtocolRuntime {
|
|
|
1056
1064
|
// Adopt the caller's canonical id (a reopen of a known session) so the
|
|
1057
1065
|
// resumed session keeps its original id instead of taking `sessionFile` (the
|
|
1058
1066
|
// agent's own ref) as its id — see OpenSessionOptions.canonicalId.
|
|
1059
|
-
const session = new ProtocolSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, this.capabilities, options.toolInterceptor, options.sessionFile, options.canonicalId);
|
|
1067
|
+
const session = new ProtocolSession(withSessionInstructions(await withSessionCredentials(this.options, options.credentialLabels), options.instructions), options.workspace, this.capabilities, options.toolInterceptor, options.sessionFile, options.canonicalId, options.instructions?.text);
|
|
1060
1068
|
try {
|
|
1061
1069
|
await session.start();
|
|
1062
1070
|
if (!this.options.resumable && !this.capabilities.resume) {
|
package/dist/runtime/remote.js
CHANGED
|
@@ -543,12 +543,12 @@ export class RemoteRuntime {
|
|
|
543
543
|
async createSession(options) {
|
|
544
544
|
const transport = await this.config.connect();
|
|
545
545
|
const toolSpecs = options.toolProvider?.list();
|
|
546
|
-
return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "create", options: { credentialLabels: options.credentialLabels, workspace: options.workspace, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
|
|
546
|
+
return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "create", options: { credentialLabels: options.credentialLabels, workspace: options.workspace, ...(options.instructions ? { instructions: options.instructions } : {}), hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
|
|
547
547
|
}
|
|
548
548
|
async openSession(options) {
|
|
549
549
|
const transport = await this.config.connect();
|
|
550
550
|
const toolSpecs = options.toolProvider?.list();
|
|
551
|
-
return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "open", options: { credentialLabels: options.credentialLabels, workspace: options.workspace, sessionFile: options.sessionFile, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
|
|
551
|
+
return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "open", options: { credentialLabels: options.credentialLabels, workspace: options.workspace, sessionFile: options.sessionFile, ...(options.instructions ? { instructions: options.instructions } : {}), hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
|
|
552
552
|
}
|
|
553
553
|
/**
|
|
554
554
|
* Re-attach to a session already live on the agent service (Stage 2 routing).
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
-
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
1
|
//
|
|
4
2
|
// Shared across every runtime adapter that spawns (or configures the spawn of) a
|
|
5
3
|
// subprocess for its agent: fold this into that subprocess's env so the agent's
|
|
@@ -20,3 +18,21 @@
|
|
|
20
18
|
export function bivySessionEnv(sessionId) {
|
|
21
19
|
return { BIVY_SESSION_ID: sessionId };
|
|
22
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Apply a profile's instructions env template (AgentProfile.instructions) for one
|
|
23
|
+
* session: `{file}` → the composed instructions file, `{fileJson}` → the same
|
|
24
|
+
* path escaped for use inside a JSON string. A variable the operator already set
|
|
25
|
+
* is left alone rather than clobbered. No template or no instructions = options
|
|
26
|
+
* returned unchanged.
|
|
27
|
+
*/
|
|
28
|
+
export function withSessionInstructions(options, instructions) {
|
|
29
|
+
if (!options.instructionsEnv || !instructions)
|
|
30
|
+
return options;
|
|
31
|
+
const patch = {};
|
|
32
|
+
for (const [name, template] of Object.entries(options.instructionsEnv)) {
|
|
33
|
+
if (process.env[name] !== undefined || options.env?.[name] !== undefined)
|
|
34
|
+
continue;
|
|
35
|
+
patch[name] = template.replace(/\{fileJson\}/g, JSON.stringify(instructions.file).slice(1, -1)).replace(/\{file\}/g, instructions.file);
|
|
36
|
+
}
|
|
37
|
+
return { ...options, env: { ...options.env, ...patch } };
|
|
38
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -72,6 +72,7 @@ import { createSessionEngine } from "./session/engine.js";
|
|
|
72
72
|
import { exportProviderAuth, exportAccountApiKeys, exportAccountOAuthCredentials, importAccountOAuthCredentials, exportSyncableProviderAuth, exportProviderAuthTombstones, importProviderAuth, removeProvider, setProviderApiKey, listCredentialRecords, setProviderApiKeyLabeled, setProviderReferenceLabeled, removeProviderCredential, setCredentialSync, setCredentialUnattended, exportUnattendedRecords, unattendedCredentialRevision, getCredentialPresets, setActiveCredentialPreset, setCredentialPresetMapping, exportSyncableRecords, exportRecordTombstones, importCredentialRecords, reconcileHostedCredentialRecords } from "./credentials/api.js";
|
|
73
73
|
import { listProviders } from "./runtime/provider-catalog.js";
|
|
74
74
|
import { exportLocalModels, importLocalModels } from "./runtime/local-model-store.js";
|
|
75
|
+
import { mergeSyncedAgentInstructions, readAgentInstructions, sessionInstructions, writeAgentInstructions, MAX_AGENT_INSTRUCTIONS_BYTES } from "./agent-instructions.js";
|
|
75
76
|
import { execEphemeralRequest } from "./ephemeral-exec.js";
|
|
76
77
|
import { ApprovalManager } from "./approval.js";
|
|
77
78
|
import { QuestionManager, validQuestions, isAskUserQuestionTool, formatQuestionResult } from "./question.js";
|
|
@@ -1373,9 +1374,21 @@ function nodeSettingsSnapshot() {
|
|
|
1373
1374
|
autoAttachToolImages: readSettings().autoAttachToolImages === true,
|
|
1374
1375
|
appScreenshots: appScreenshotsEnabled(),
|
|
1375
1376
|
forkWorkspaceMaxBytes: Number.isInteger(readSettings().forkWorkspaceMaxBytes) ? Number(readSettings().forkWorkspaceMaxBytes) : 50 * 1024 * 1024,
|
|
1377
|
+
agentInstructions: { ...readAgentInstructions(appDir), maxBytes: MAX_AGENT_INSTRUCTIONS_BYTES },
|
|
1376
1378
|
};
|
|
1377
1379
|
}
|
|
1378
1380
|
async function applyNodeSettings(patch) {
|
|
1381
|
+
// Account-wide agent instructions live in their own file (not config.yaml) and
|
|
1382
|
+
// sync through the E2E vault. Applied first so a rejected save changes nothing.
|
|
1383
|
+
if (typeof patch.agentInstructions === "string") {
|
|
1384
|
+
const current = readAgentInstructions(appDir);
|
|
1385
|
+
const base = Number(patch.agentInstructionsBaseUpdatedAt);
|
|
1386
|
+
if (Number.isFinite(base) && current.updatedAt > base && current.text !== patch.agentInstructions) {
|
|
1387
|
+
throw new Error("These instructions were changed on another device. Reload them before saving.");
|
|
1388
|
+
}
|
|
1389
|
+
writeAgentInstructions(appDir, patch.agentInstructions);
|
|
1390
|
+
void pushModelAuthToControlPlane();
|
|
1391
|
+
}
|
|
1379
1392
|
const settings = readSettings();
|
|
1380
1393
|
if (typeof patch.name === "string" && patch.name.trim()) {
|
|
1381
1394
|
const prev = identity.name;
|
|
@@ -3289,8 +3302,8 @@ function ensureLocalModelAuthVaultKey() {
|
|
|
3289
3302
|
// silently exchange an incompatible structure. `decrypt` tolerates a bare map
|
|
3290
3303
|
// for forward-safety.
|
|
3291
3304
|
const MODEL_AUTH_ENVELOPE_VERSION = 3;
|
|
3292
|
-
function encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records = {}, recordsDeletedAt = {}) {
|
|
3293
|
-
const envelope = { v: MODEL_AUTH_ENVELOPE_VERSION, providers, deletedAt, localModels, records, recordsDeletedAt };
|
|
3305
|
+
function encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records = {}, recordsDeletedAt = {}, agentInstructions) {
|
|
3306
|
+
const envelope = { v: MODEL_AUTH_ENVELOPE_VERSION, providers, deletedAt, localModels, records, recordsDeletedAt, ...(agentInstructions ? { agentInstructions } : {}) };
|
|
3294
3307
|
return seal(Buffer.from(vaultKeyB64, "base64"), JSON.stringify(envelope));
|
|
3295
3308
|
}
|
|
3296
3309
|
function decryptModelAuthEnvelope(ciphertext, vaultKeyB64) {
|
|
@@ -3309,6 +3322,7 @@ function decryptModelAuthEnvelope(ciphertext, vaultKeyB64) {
|
|
|
3309
3322
|
localModels: obj(envelope.localModels),
|
|
3310
3323
|
records: obj(envelope.records),
|
|
3311
3324
|
recordsDeletedAt: obj(envelope.recordsDeletedAt),
|
|
3325
|
+
agentInstructions: envelope.agentInstructions,
|
|
3312
3326
|
};
|
|
3313
3327
|
}
|
|
3314
3328
|
// Back-compat: a bare `{ [id]: Credential }` map (pre-envelope / other sender).
|
|
@@ -3486,6 +3500,12 @@ async function syncModelAuthFromControlPlane() {
|
|
|
3486
3500
|
await importProviderAuth(credsDir, providers, deletedAt);
|
|
3487
3501
|
}
|
|
3488
3502
|
importLocalModels(localModelsDir, localModels);
|
|
3503
|
+
// Account-wide agent instructions reconcile last-writer-wins. Hosted
|
|
3504
|
+
// runners hold only the filtered credential snapshot, which never carries
|
|
3505
|
+
// them, so they neither import nor republish.
|
|
3506
|
+
const instructionsSync = hostedCustody ? "unchanged" : mergeSyncedAgentInstructions(appDir, decrypted.agentInstructions);
|
|
3507
|
+
if (instructionsSync === "imported")
|
|
3508
|
+
broadcast({ type: "node.settings", settings: nodeSettingsSnapshot() });
|
|
3489
3509
|
// A synced key or config change can both alter the projection, so always
|
|
3490
3510
|
// regenerate it (and refresh the panel) after importing the vault.
|
|
3491
3511
|
await writePiModelsProjection();
|
|
@@ -3500,6 +3520,8 @@ async function syncModelAuthFromControlPlane() {
|
|
|
3500
3520
|
broadcast({ type: "providers.list", providers: await listProvidersUnified() });
|
|
3501
3521
|
if (!hostedCustody && data.vault?.needsRotation)
|
|
3502
3522
|
await pushModelAuthToControlPlane(true);
|
|
3523
|
+
else if (instructionsSync === "local-newer")
|
|
3524
|
+
await pushModelAuthToControlPlane();
|
|
3503
3525
|
}
|
|
3504
3526
|
else if (targetVault?.ciphertext && !vaultKeyB64 && !hostedCustody) {
|
|
3505
3527
|
await modelAuthFetch("/node/model-auth-key/request", { method: "POST", body: JSON.stringify({ publicKey: pairingStore.nodePublicKeyB64() }) });
|
|
@@ -3509,7 +3531,8 @@ async function syncModelAuthFromControlPlane() {
|
|
|
3509
3531
|
ensureModelAuthColdStart();
|
|
3510
3532
|
}
|
|
3511
3533
|
else if (Object.keys(await exportProviderAuth(credsDir)).length > 0 ||
|
|
3512
|
-
Object.keys(exportLocalModels(localModelsDir)).length > 0
|
|
3534
|
+
Object.keys(exportLocalModels(localModelsDir)).length > 0 ||
|
|
3535
|
+
readAgentInstructions(appDir).updatedAt > 0) {
|
|
3513
3536
|
await pushModelAuthToControlPlane();
|
|
3514
3537
|
}
|
|
3515
3538
|
await processModelAuthKeyRequests(data.requests ?? []);
|
|
@@ -3617,7 +3640,8 @@ async function pushModelAuthToControlPlane(rotateKey = false, throwOnFailure = f
|
|
|
3617
3640
|
const vaultKeyB64 = rotateKey ? randomBytes(32).toString("base64") : ensureLocalModelAuthVaultKey();
|
|
3618
3641
|
if (rotateKey)
|
|
3619
3642
|
writeLocalModelAuthVaultKey(vaultKeyB64);
|
|
3620
|
-
const
|
|
3643
|
+
const instructions = readAgentInstructions(appDir);
|
|
3644
|
+
const ciphertext = encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records, recordsDeletedAt, instructions.updatedAt > 0 ? instructions : undefined);
|
|
3621
3645
|
if (!rotateKey && ciphertext === lastPushedModelAuthCiphertext) {
|
|
3622
3646
|
await pushHostedModelAuthToControlPlane(allowEmptyHostedEscrow);
|
|
3623
3647
|
return;
|
|
@@ -7843,7 +7867,7 @@ async function refreshRecordAfterTui(record) {
|
|
|
7843
7867
|
const workspace = record.worktree?.path || oldSession.cwd || record.workspace;
|
|
7844
7868
|
// Refreshing an EXISTING record: its id is already known, so attach_to_chat
|
|
7845
7869
|
// (see toolProvider's SessionIdRef doc) can be wired live, not deferred.
|
|
7846
|
-
const runtimeSessionOptions = { credentialLabels: record.credentialLabels, workspace, toolProvider: integrations.toolProvider({ current: record.id }), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
|
|
7870
|
+
const runtimeSessionOptions = { credentialLabels: record.credentialLabels, workspace, toolProvider: integrations.toolProvider({ current: record.id }), instructions: sessionInstructions(appDir), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
|
|
7847
7871
|
const { session, warning } = await runtimeHost.openSession(rt, { ...runtimeSessionOptions, sessionFile: record.sessionFile });
|
|
7848
7872
|
record.session = session;
|
|
7849
7873
|
record.sessionFile = session.sessionFile ?? record.sessionFile;
|
|
@@ -8080,6 +8104,7 @@ async function recoverRecordAfterAbort(record) {
|
|
|
8080
8104
|
workspace,
|
|
8081
8105
|
credentialLabels: record.credentialLabels,
|
|
8082
8106
|
toolProvider: integrations.toolProvider({ current: record.id }),
|
|
8107
|
+
instructions: sessionInstructions(appDir),
|
|
8083
8108
|
...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}),
|
|
8084
8109
|
};
|
|
8085
8110
|
const { session, warning } = await runtimeHost.openSession(rt, {
|
|
@@ -8269,7 +8294,9 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
8269
8294
|
// `.current` in the moment `sessionId` is (see toolProvider's SessionIdRef doc).
|
|
8270
8295
|
const attachSessionIdRef = {};
|
|
8271
8296
|
const credentialLabels = opts.credentialLabels ?? storedMeta?.credentialLabels;
|
|
8272
|
-
|
|
8297
|
+
// The user's account-wide instructions, as of this session's start.
|
|
8298
|
+
const instructions = sessionInstructions(appDir);
|
|
8299
|
+
const runtimeSessionOptions = { credentialLabels, workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(attachSessionIdRef), instructions, ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
|
|
8273
8300
|
// Stage 2/3: prefer re-attaching to a still-live remote session — routed to its
|
|
8274
8301
|
// OWN agent service — over re-opening a fresh copy from disk. Falls back to
|
|
8275
8302
|
// open/create when nothing live is there.
|
|
@@ -8387,6 +8414,7 @@ async function createSession(workspace = defaultWorkspace, sessionFile, opts = {
|
|
|
8387
8414
|
home: os.homedir(),
|
|
8388
8415
|
sessionId,
|
|
8389
8416
|
endpoint: process.env.BIVY_MCP_ENDPOINT,
|
|
8417
|
+
instructionsFile: instructions?.file,
|
|
8390
8418
|
});
|
|
8391
8419
|
if (res.injected.length) {
|
|
8392
8420
|
const prev = record.mcpRestore;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bivy/bivy",
|
|
3
|
-
"version": "0.18.9-staging.
|
|
3
|
+
"version": "0.18.9-staging.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"zod": "^4.6.5"
|
|
52
52
|
},
|
|
53
53
|
"overrides": {
|
|
54
|
-
"fast-uri": "3.1.
|
|
54
|
+
"fast-uri": "3.1.8",
|
|
55
55
|
"hono": "4.13.5",
|
|
56
56
|
"qs": "6.16.0",
|
|
57
57
|
"@hono/node-server": "2.0.12",
|