@bivy/bivy 0.18.8 → 0.18.9-staging.10

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.
@@ -13,7 +13,6 @@
13
13
  // * The SDK is loaded with a dynamic import so it stays an *optional*
14
14
  // dependency: a Bivy install only needs it when this runtime is selected.
15
15
  import { withSessionCredentials, credentialEnvFallback } from "../../credentials/session.js";
16
- import { createRequire } from "node:module";
17
16
  import { randomUUID } from "node:crypto";
18
17
  import { EventEmitter } from "node:events";
19
18
  import { withExactCapabilitySurface } from "../../runtime/types.js";
@@ -30,6 +29,7 @@ import { anthropicCredentialPreflight, describeAnthropicError, isAnthropicAuthEr
30
29
  import { toModelInfo as sharedToModelInfo } from "../../runtime/normalize.js";
31
30
  import { hasLiveProcessForCwd } from "../../runtime/native-process-scan.js";
32
31
  import { bivySessionEnv } from "../../runtime/session-env.js";
32
+ import { bridgeInstalled, enableBridgeResolution } from "../../agent-bridges.mjs";
33
33
  /** Binary names a live Claude Code process could be running under (see
34
34
  * native-process-scan.ts's best-effort cwd match). */
35
35
  const CLAUDE_BIN_NAMES = ["claude"];
@@ -190,20 +190,15 @@ export function claudeRuntimeFromEnv() {
190
190
  }
191
191
  /** True when `@anthropic-ai/claude-agent-sdk` is resolvable in this install. */
192
192
  export function claudeSdkInstalled() {
193
- try {
194
- createRequire(import.meta.url).resolve(SDK_PACKAGE);
195
- return true;
196
- }
197
- catch {
198
- return false;
199
- }
193
+ return bridgeInstalled(SDK_PACKAGE);
200
194
  }
201
195
  async function loadSdk() {
196
+ enableBridgeResolution();
202
197
  try {
203
198
  return await import(SDK_PACKAGE);
204
199
  }
205
200
  catch {
206
- throw new Error(`The claude-code-sdk runtime requires the "${SDK_PACKAGE}" package. Install it with: npm install ${SDK_PACKAGE}`);
201
+ throw new Error(`The claude-code-sdk runtime requires the "${SDK_PACKAGE}" bridge. Install it with: bivy agents:install --bridges`);
207
202
  }
208
203
  }
209
204
  /**
@@ -303,6 +298,9 @@ function extractText(message) {
303
298
  * handled separately (see INTERRUPT_MARKER) because — unlike these — they carry
304
299
  * meaning a human wants to see. */
305
300
  const DROP_META_TEXT = /^\s*<(?:task-notification|system-reminder)[\s>/]/;
301
+ /** Re-drives a self-woken turn after a credential reload (see recoverFromAuthError).
302
+ * Wrapped as a system-reminder so DROP_META_TEXT keeps it out of the chat. */
303
+ const AUTH_RECOVERY_CONTINUE = "<system-reminder>Your last request failed because the sign-in expired; it has been refreshed. Continue exactly where you left off.</system-reminder>";
306
304
  /** The CLI writes this synthetic user-role marker whenever a turn is aborted —
307
305
  * by a real user Stop OR by any teardown of the streaming query (our shutdown,
308
306
  * a mid-flight credential reload, a TUI refresh). We don't drop it: we surface
@@ -885,7 +883,9 @@ class ClaudeSession {
885
883
  // Kept even when the native tool below is also registered: it's a cheap,
886
884
  // harmless fallback for a shell/subprocess the agent spawns that can't
887
885
  // reach the in-process MCP tool directly.
888
- systemPrompt: { type: "preset", preset: "claude_code", append: BIVY_ATTACH_SYSTEM_PROMPT },
886
+ // The user's account-wide instructions follow it; the repo's CLAUDE.md
887
+ // still loads through the preset as usual.
888
+ systemPrompt: { type: "preset", preset: "claude_code", append: [BIVY_ATTACH_SYSTEM_PROMPT, this.runtimeOptions.instructions].filter(Boolean).join("\n\n") },
889
889
  ...(this.runtimeOptions.executablePath ? { pathToClaudeCodeExecutable: this.runtimeOptions.executablePath } : {}),
890
890
  };
891
891
  if (resumeId)
@@ -1076,15 +1076,19 @@ class ClaudeSession {
1076
1076
  /** Recover a provider 401 regardless of whether the SDK throws it, emits it as
1077
1077
  * assistant text, or puts it in an error result. The latter is how revoked
1078
1078
  * OAuth tokens commonly arrive, so only handling consume()'s catch would leave
1079
- * the turn stopped until the user sent another message. */
1079
+ * the turn stopped until the user sent another message.
1080
+ *
1081
+ * A turn the agent woke into on its own (a background task's notification,
1082
+ * with no user prompt in flight) recovers too: its trigger lived in the torn-down
1083
+ * query, so the resumed one is nudged with a hidden continuation instead. */
1080
1084
  async recoverFromAuthError(raw) {
1081
- if (!isAnthropicAuthError(raw) || this.reloadedThisTurn || this.inFlightPrompt === undefined)
1085
+ if (!isAnthropicAuthError(raw) || this.reloadedThisTurn)
1082
1086
  return false;
1083
1087
  this.reloadedThisTurn = true;
1084
1088
  if (!(await this.restartWithFreshCredential(this.spawnedToken)))
1085
1089
  return false;
1086
1090
  this.streaming = true;
1087
- this.input.push({ type: "user", message: { role: "user", content: this.inFlightPrompt }, parent_tool_use_id: null });
1091
+ this.input.push({ type: "user", message: { role: "user", content: this.inFlightPrompt ?? AUTH_RECOVERY_CONTINUE }, parent_tool_use_id: null });
1088
1092
  return true;
1089
1093
  }
1090
1094
  beginMessage() {
@@ -1273,6 +1277,11 @@ class ClaudeSession {
1273
1277
  // Turn completed — the prompt is on disk now, so drop the copy kept for a
1274
1278
  // mid-flight reload re-drive (see inFlightPrompt / consume's catch).
1275
1279
  this.inFlightPrompt = undefined;
1280
+ // Re-arm the one-reload-per-turn guard only after a clean turn, so the
1281
+ // next (possibly self-woken) turn can recover, while a failed turn that
1282
+ // already reloaded surfaces its error instead of looping.
1283
+ if (!resultError)
1284
+ this.reloadedThisTurn = false;
1276
1285
  if (message.subtype && message.subtype !== "success") {
1277
1286
  this.emit({ type: "tool_result", error: message.subtype, message: message.result });
1278
1287
  }
@@ -1516,12 +1525,12 @@ export class ClaudeCodeRuntime {
1516
1525
  return [{ id: "anthropic", name: "Anthropic", oauth: true, models: FALLBACK_MODELS }];
1517
1526
  }
1518
1527
  async createSession(options) {
1519
- const session = new ClaudeSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, options.toolInterceptor, options.toolProvider);
1528
+ const session = new ClaudeSession({ ...(await withSessionCredentials(this.options, options.credentialLabels)), instructions: options.instructions?.text }, options.workspace, options.toolInterceptor, options.toolProvider);
1520
1529
  this.sessions.push(session);
1521
1530
  return { session };
1522
1531
  }
1523
1532
  async openSession(options) {
1524
- const session = new ClaudeSession(await withSessionCredentials(this.options, options.credentialLabels), options.workspace, options.toolInterceptor, options.toolProvider, options.sessionFile);
1533
+ const session = new ClaudeSession({ ...(await withSessionCredentials(this.options, options.credentialLabels)), instructions: options.instructions?.text }, options.workspace, options.toolInterceptor, options.toolProvider, options.sessionFile);
1525
1534
  this.sessions.push(session);
1526
1535
  return {
1527
1536
  session,
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { defineAgentIntegration } from "../definition.js";
7
7
  import { PI_CAPABILITIES } from "./capabilities.js";
8
+ import { bridgeInstalled, enableBridgeResolution } from "../../agent-bridges.mjs";
8
9
  export const PI_TESTED_VERSION = "0.87.1";
9
10
  export function piAgentDir() {
10
11
  return process.env.PI_CODING_AGENT_DIR?.trim() || path.join(os.homedir(), ".pi", "agent");
@@ -18,6 +19,7 @@ export function piCommand() {
18
19
  * PI_CODING_AGENT_DIR (normally ~/.pi/agent). Takeover discovery must inspect
19
20
  * that native store rather than only the governed one. */
20
21
  export async function listNativePiSessions() {
22
+ enableBridgeResolution();
21
23
  const { SessionManager } = await import("@earendil-works/pi-coding-agent");
22
24
  // Do not pass the sessions root here. Pi's default store is split into one
23
25
  // encoded subdirectory per cwd, and SessionManager.listAll(customDir) scans
@@ -40,13 +42,7 @@ export function piCommandAvailable() {
40
42
  return resolveExecutable(piCommand()) !== null;
41
43
  }
42
44
  export function piBridgeInstalled() {
43
- try {
44
- import.meta.resolve("@earendil-works/pi-coding-agent");
45
- return true;
46
- }
47
- catch {
48
- return false;
49
- }
45
+ return bridgeInstalled("@earendil-works/pi-coding-agent");
50
46
  }
51
47
  function unsupportedNodeMessage() {
52
48
  return `Pi requires Node.js 22.19+ (found ${process.version}). Upgrade Node, or select another agent such as Claude Code/Codex/OpenCode.`;
@@ -67,6 +63,7 @@ export class LazyPiRuntime {
67
63
  async runtime() {
68
64
  if (!nodeSupportsPi())
69
65
  throw new Error(unsupportedNodeMessage());
66
+ enableBridgeResolution();
70
67
  this.inner ??= import("./runtime.js").then(({ PiRuntime }) => new PiRuntime(this.options));
71
68
  return this.inner;
72
69
  }
@@ -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: {
@@ -13,13 +13,7 @@ import { displayViewer, DISPLAY_MENU_PATH, DISPLAY_SOCKET_PATH, DISPLAY_STATS_PA
13
13
  import { pressMenu, readMenuPath, readMenus } from "./menu.js";
14
14
  import { inspectorScript } from "./inspector.js";
15
15
  import { readStrokes, readElementScrolls } from "./annotate.js";
16
- const MIME = {
17
- ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
18
- ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
19
- ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon",
20
- ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".wasm": "application/wasm",
21
- ".txt": "text/plain; charset=utf-8", ".pdf": "application/pdf", ".mp4": "video/mp4", ".webm": "video/webm",
22
- };
16
+ import { contentType } from "./mime.js";
23
17
  const COOKIE = "__Host-bivy-preview";
24
18
  const OPEN_PATH = "/__bivy/open";
25
19
  const REDEEM_PATH = "/__bivy/redeem";
@@ -534,7 +528,7 @@ fetch('${REDEEM_PATH}',{method:'POST',headers:{'Content-Type':'text/plain'},body
534
528
  return;
535
529
  }
536
530
  const headers = responseHeaders({}, this.ancestors(id));
537
- headers["content-type"] = MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
531
+ headers["content-type"] = contentType(file);
538
532
  if (inspect && path.extname(file).toLowerCase() === ".html" && data.length <= MAX_INJECT_BYTES)
539
533
  data = withInspector(data);
540
534
  sendBytes(req, res, status, headers, data);
@@ -0,0 +1,16 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import path from "node:path";
4
+ /** Content types for files a static app view serves. Browsers refuse module
5
+ * scripts (and some stylesheets) without the right one, so every server of a
6
+ * static snapshot — the preview gateway and the screenshot loopback — uses this. */
7
+ const MIME = {
8
+ ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
9
+ ".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
10
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon",
11
+ ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".wasm": "application/wasm",
12
+ ".txt": "text/plain; charset=utf-8", ".pdf": "application/pdf", ".mp4": "video/mp4", ".webm": "video/webm",
13
+ };
14
+ export function contentType(file) {
15
+ return MIME[path.extname(file).toLowerCase()] || "application/octet-stream";
16
+ }
@@ -7,6 +7,7 @@ import http from "node:http";
7
7
  import { once } from "node:events";
8
8
  import { spawn, execFileSync } from "node:child_process";
9
9
  import { WebSocket } from "ws";
10
+ import { contentType } from "./mime.js";
10
11
  import { captureFrame, encodePng } from "./rfb.js";
11
12
  /** Browsers tried in order; BIVY_CHROME overrides. Any Chromium works. */
12
13
  const CANDIDATES = [
@@ -45,12 +46,14 @@ async function serveSnapshot(files) {
45
46
  let file = decodeURIComponent((req.url ?? "/").split("?")[0]);
46
47
  if (file.endsWith("/"))
47
48
  file += "index.html";
48
- const data = files.get(file) ?? (path.extname(file) ? undefined : files.get("/index.html"));
49
+ const name = files.has(file) || path.extname(file) ? file : "/index.html";
50
+ const data = files.get(name);
49
51
  if (!data) {
50
52
  res.writeHead(404);
51
53
  res.end();
52
54
  return;
53
55
  }
56
+ res.writeHead(200, { "content-type": contentType(name) });
54
57
  res.end(data);
55
58
  });
56
59
  server.listen(0, "127.0.0.1");
@@ -120,7 +120,7 @@ input, textarea, select { font-family: inherit; font-size: 16px; }
120
120
  .menu-item:hover, .menu-item[aria-checked="true"], .menu-item.active { background: var(--surface-2); }
121
121
  .menu-item.danger { color: var(--danger); }
122
122
  .menu-item-label { flex: 1; min-width: 0; }
123
- .menu-item.separated { margin-top: var(--space-1); border-top: 1px solid var(--line); border-radius: 0 0 var(--radius-sm) var(--radius-sm); }
123
+ .menu-item.separated { margin-top: var(--space-1); border-top: 1px solid var(--line); }
124
124
  /* A section title inside a menu (e.g. "Preview cards" above its choices). */
125
125
  .menu-heading { padding: var(--space-2) var(--space-3) var(--space-1); color: var(--muted); font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: 0.04em; text-transform: uppercase; }
126
126
 
@@ -2214,6 +2214,42 @@ a.queue-item:hover { border-color: var(--line-strong); background: var(--surface
2214
2214
  .settings-nav-item.active .settings-nav-icon { color: var(--accent); }
2215
2215
  .settings-nav-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2216
2216
  .settings-nav-chevron { display: none; flex: none; color: var(--muted); font-size: 18px; }
2217
+ /* Account identity card pinned to the top of the nav (Claude/ChatGPT pattern):
2218
+ avatar + email on one raised surface. */
2219
+ .settings-account-card {
2220
+ display: flex; flex-direction: column; gap: 1px; padding: var(--space-1);
2221
+ background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-md);
2222
+ }
2223
+ .settings-account-avatar {
2224
+ flex: none; display: inline-flex; align-items: center; justify-content: center;
2225
+ width: 36px; height: 36px; border-radius: var(--radius-full);
2226
+ background: var(--surface-3); color: var(--ink); font-size: var(--text-sm); font-weight: var(--weight-semibold);
2227
+ }
2228
+ .settings-account-avatar svg { width: 20px; height: 20px; }
2229
+ .settings-account-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
2230
+ .settings-account-sub { font-size: var(--text-xs); color: var(--muted); font-weight: normal; }
2231
+ .settings-account-usage { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-1) 10px var(--space-2); font-size: var(--text-xs); color: var(--muted); }
2232
+ .settings-account-usage-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); min-height: 20px; }
2233
+ .settings-account-usage-count { color: var(--ink); font-variant-numeric: tabular-nums; }
2234
+ .settings-account-usage-note { font-weight: var(--weight-semibold); }
2235
+ .settings-account-usage[data-state="near"] .settings-account-usage-note { color: var(--warn); }
2236
+ .settings-account-usage[data-state="reached"] .settings-account-usage-note { color: var(--danger); }
2237
+ .settings-account-usage .banner { margin: 0; }
2238
+ .settings-account-usage-status { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
2239
+ .settings-account-usage > .btn.block { margin-top: 0; }
2240
+ /* The same meter on the Account page, aligned with the plan rows. */
2241
+ .settings-section > .settings-account-usage { padding: var(--space-1) 0 0; font-size: var(--text-sm); }
2242
+
2243
+ /* Account page offer (e.g. upgrade): a .card, layout only. */
2244
+ .account-offer { display: flex; flex-direction: column; gap: var(--space-3); padding: var(--space-4); margin-top: var(--space-4); }
2245
+ .account-offer .card-head { margin-bottom: 0; align-items: baseline; }
2246
+ .account-offer .card-sub { margin: calc(-1 * var(--space-2)) 0 0; }
2247
+ .account-offer-price { font-size: var(--text-base); font-weight: var(--weight-semibold); color: var(--ink); white-space: nowrap; }
2248
+ .account-offer-points { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); font-size: var(--text-sm); }
2249
+ .account-offer-points li { display: flex; align-items: flex-start; gap: var(--space-2); }
2250
+ .account-offer-check { flex: none; display: inline-flex; color: var(--accent); margin-top: 1px; }
2251
+ .account-offer > .btn.block { margin-top: var(--space-1); }
2252
+ .account-offer + .settings-section { margin-top: var(--space-5); }
2217
2253
  .settings-nav-version { flex: none; display: flex; flex-direction: column; gap: 1px; padding: 10px 10px 2px; color: var(--muted); font-size: 12px; letter-spacing: 0.02em; }
2218
2254
  .settings-nav-version-updated { font-size: 11px; opacity: 0.8; }
2219
2255
 
@@ -2228,6 +2264,13 @@ a.queue-item:hover { border-color: var(--line-strong); background: var(--surface
2228
2264
  .settings-body { flex: 1; overflow-y: auto; min-height: 0; padding: 14px 18px 28px; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; animation: settings-panel-in var(--dur) var(--ease-standard); }
2229
2265
  .settings-body .settings-form { padding: 0; }
2230
2266
 
2267
+ /* Canonical meter — a thin allowance/usage bar (role="meter"). data-state
2268
+ near/reached tints it warn/danger. Reflects the styleguide (.meter). */
2269
+ .meter { display: block; height: 6px; border-radius: var(--radius-full); background: var(--surface-3); overflow: hidden; }
2270
+ .meter-fill { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width var(--dur) var(--ease-standard); }
2271
+ .meter[data-state="near"] .meter-fill { background: var(--warn); }
2272
+ .meter[data-state="reached"] .meter-fill { background: var(--danger); }
2273
+
2231
2274
  /* Canonical selectable — the shared "active-accent-tint" choice control. Its
2232
2275
  selected state (accent border + soft accent fill) is the one that used to be
2233
2276
  copy-pasted across the theme picker, segmented buttons and ruleset chips; new
@@ -2353,6 +2396,7 @@ a.queue-item:hover { border-color: var(--line-strong); background: var(--surface
2353
2396
  .settings-nav-group-label { font-size: 12px; padding: 10px 6px 4px; }
2354
2397
  .settings-nav-item { padding: 14px 12px; font-size: 16px; }
2355
2398
  .settings-nav-chevron { display: inline-flex; }
2399
+ .settings-account-card { background: var(--surface-2); margin-bottom: var(--space-2); }
2356
2400
  .settings-content { padding-top: max(0px, env(safe-area-inset-top)); }
2357
2401
  .settings-back { display: inline-flex; }
2358
2402
  .settings-body { padding: 14px 16px max(28px, env(safe-area-inset-bottom)); }
@@ -2489,10 +2533,6 @@ a.queue-item:hover { border-color: var(--line-strong); background: var(--surface
2489
2533
  .fork-progress-dialog { color: var(--ink); margin: auto; max-width: calc(100% - var(--space-8)); }
2490
2534
  .fork-progress-dialog::backdrop { background: var(--overlay); }
2491
2535
  .app-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
2492
- .stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
2493
- .stat { border: 1px solid var(--line); border-radius: var(--radius-md); padding: 10px 12px; background: var(--surface-2); }
2494
- .stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
2495
- .stat-value { font-size: 16px; font-weight: 700; margin-top: 2px; }
2496
2536
  textarea.picker-search { resize: vertical; font-family: ui-monospace, monospace; }
2497
2537
 
2498
2538
  /* ---- Terminal ---- */
@@ -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
  });
@@ -13,6 +13,7 @@
13
13
  // inference SDK, kept under src/runtime/ so the core daemon (src/server.ts) stays
14
14
  // free of any @earendil-works import.
15
15
  import { createPiModelRuntime } from "./pi-oauth.js";
16
+ import { enableBridgeResolution } from "../agent-bridges.mjs";
16
17
  function cleanSessionName(value) {
17
18
  return value
18
19
  .replace(/[\r\n"'`]/g, " ")
@@ -54,6 +55,7 @@ export async function suggestNameFromSelectedModel(opts) {
54
55
  if (!prompt)
55
56
  return undefined;
56
57
  try {
58
+ enableBridgeResolution();
57
59
  const [{ completeSimple }, { ModelRegistry }] = await Promise.all([
58
60
  import("@earendil-works/pi-ai/compat"),
59
61
  import("@earendil-works/pi-coding-agent"),
@@ -12,6 +12,7 @@ import path from "node:path";
12
12
  import { createCredentialVault } from "./credential-store.js";
13
13
  import { isNativeOAuthProvider } from "./oauth/model-oauth-providers.js";
14
14
  import { selectedCredentialStore } from "../credentials/selected-store.js";
15
+ import { enableBridgeResolution } from "../agent-bridges.mjs";
15
16
  /** Adapt Bivy's store to pi-ai's structurally-identical CredentialStore for injection. */
16
17
  export function piCredentialStore(store) {
17
18
  return store;
@@ -25,6 +26,7 @@ export function piCredentialStore(store) {
25
26
  */
26
27
  export async function createPiModelRuntime(opts) {
27
28
  const store = opts.store ?? createCredentialVault(opts.credsDir);
29
+ enableBridgeResolution();
28
30
  const { ModelRuntime } = await import("@earendil-works/pi-coding-agent");
29
31
  const selected = selectedCredentialStore(store, opts.credsDir, { workspace: opts.workspace, credentialLabels: opts.credentialLabels });
30
32
  for (const provider of Object.keys(opts.credentialLabels ?? {}))
@@ -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
+ }