@bivy/bivy 0.18.9-staging.4 → 0.18.9-staging.5

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.
@@ -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
- systemPrompt: { type: "preset", preset: "claude_code", append: BIVY_ATTACH_SYSTEM_PROMPT },
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,
@@ -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 server = new Server({ name: "bivy", version: "1.0.0" }, { capabilities: { tools: {} } });
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);
@@ -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
  });
@@ -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
  }
@@ -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) {
@@ -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 ciphertext = encryptModelAuthProviders(providers, deletedAt, localModels, vaultKeyB64, records, recordsDeletedAt);
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
- const runtimeSessionOptions = { credentialLabels, workspace: runtimeWorkspace, toolProvider: integrations.toolProvider(attachSessionIdRef), ...(rt.capabilities.toolInterception ? { toolInterceptor: guardianInterceptor } : {}) };
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.4",
3
+ "version": "0.18.9-staging.5",
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.",