@bivy/bivy 0.6.0 → 0.7.0

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.
@@ -63,14 +63,15 @@ function tokensFrom(provider, payload, prev) {
63
63
  const rotated = typeof payload.refresh_token === "string" ? payload.refresh_token : "";
64
64
  const refresh = rotated || prev?.refresh || "";
65
65
  const expiresIn = Number(payload.expires_in) || 3600;
66
- const expires = Date.now() + expiresIn * 1000 - provider.refreshSkewMs;
66
+ const now = Date.now();
67
+ const expires = now + expiresIn * 1000 - provider.refreshSkewMs;
67
68
  let accountId = prev?.accountId;
68
69
  if (provider.accountIdClaim) {
69
70
  accountId = jwtClaim(access, provider.accountIdClaim.path, provider.accountIdClaim.field) ?? accountId;
70
71
  if (!accountId)
71
72
  throw new Error(`Could not extract account id for "${provider.id}" from the OAuth token`);
72
73
  }
73
- return { access, refresh, expires, ...(accountId ? { accountId } : {}) };
74
+ return { access, refresh, expires, refreshedAt: now, ...(accountId ? { accountId } : {}) };
74
75
  }
75
76
  // --- Authorization-code flow (browser + callback server + manual paste) ------
76
77
  function buildAuthorizeUrl(provider, opts) {
@@ -282,7 +283,7 @@ export async function loginModelOAuth(credsDir, providerId, interaction) {
282
283
  if (!provider)
283
284
  throw new Error(`Provider "${providerId}" does not support subscription login`);
284
285
  const tokens = provider.flow === "device_code" ? await loginDeviceCode(provider, interaction) : await loginAuthCode(provider, interaction);
285
- const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
286
+ const credential = { type: "oauth", access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, refreshedAt: tokens.refreshedAt, ...(tokens.accountId ? { accountId: tokens.accountId } : {}) };
286
287
  await createCredentialVault(credsDir).modify(providerId, async () => credential);
287
288
  }
288
289
  /** Exchange the refresh token for a fresh credential (network call; throws on failure). */
@@ -318,7 +319,7 @@ export async function refreshModelOAuth(credsDir, providerId) {
318
319
  if (Number(current.expires) > Date.now())
319
320
  return current;
320
321
  const fresh = await refreshTokens(provider, current);
321
- return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
322
+ return { type: "oauth", access: fresh.access, refresh: fresh.refresh, expires: fresh.expires, refreshedAt: fresh.refreshedAt, ...(fresh.accountId ? { accountId: fresh.accountId } : {}) };
322
323
  });
323
324
  return result?.type === "oauth" ? result.access : undefined;
324
325
  }
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
6
6
  import { stripAnsi } from "./ansi.js";
7
7
  import { buildAgentCredentialEnv } from "./credentials.js";
8
- import { egressEnv } from "../harness/egress.js";
8
+ import { egressEnv, sessionEgressEnv } from "../harness/egress.js";
9
9
  import { depCacheEnv } from "../harness/dep-cache.js";
10
10
  import { bivySessionEnv } from "./session-env.js";
11
11
  /**
@@ -193,6 +193,17 @@ class ProcessSession {
193
193
  setName(name) {
194
194
  this.name = name;
195
195
  }
196
+ /** The agent's on-disk slash commands for this workspace (Codex prompts,
197
+ * opencode commands). Best-effort and display-only: any read failure yields an
198
+ * empty menu, never a throw. */
199
+ getCommands() {
200
+ try {
201
+ return this.runtimeOptions.slashCommands?.list(this.cwd) ?? [];
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }
196
207
  async suggestName() {
197
208
  // The generic CLI "dumb-pipe" runtime has no model of its own to name a
198
209
  // session with. Returning a raw 60-char truncation of the first message here
@@ -216,6 +227,17 @@ class ProcessSession {
216
227
  const prompt = text.trim();
217
228
  if (!prompt)
218
229
  return;
230
+ // A `/name args` line that matches an on-disk command runs the command by
231
+ // sending its expanded body to the agent; the transcript still shows what the
232
+ // user typed. Any non-command line (incl. a leading slash that isn't one)
233
+ // passes through untouched. Best-effort — a read failure sends the raw line.
234
+ let promptToSend;
235
+ try {
236
+ promptToSend = this.runtimeOptions.slashCommands?.expand(this.cwd, prompt) ?? prompt;
237
+ }
238
+ catch {
239
+ promptToSend = prompt;
240
+ }
219
241
  this.messages.push({ role: "user", content: prompt, timestamp: Date.now() });
220
242
  this.streaming = true;
221
243
  this.emit({ type: "agent_start" });
@@ -244,7 +266,7 @@ class ProcessSession {
244
266
  const idx = Math.min(Math.max(at, 0), argsWithFlags.length);
245
267
  argsWithFlags = [...argsWithFlags.slice(0, idx), ...inject, ...argsWithFlags.slice(idx)];
246
268
  }
247
- const args = this.runtimeOptions.promptMode === "argv" ? [...argsWithFlags, prompt] : argsWithFlags;
269
+ const args = this.runtimeOptions.promptMode === "argv" ? [...argsWithFlags, promptToSend] : argsWithFlags;
248
270
  // Resolve credentials per prompt so freshly-refreshed OAuth tokens (and keys
249
271
  // added after this session started) reach the agent. The vault wins over any
250
272
  // ambient key so Bivy's shared sign-in is authoritative.
@@ -278,12 +300,14 @@ class ProcessSession {
278
300
  // src/harness/sandbox.ts). Bivy no longer wraps the process in an OS jail.
279
301
  const child = spawn(this.runtimeOptions.command, args, {
280
302
  cwd: this.cwd,
281
- // egressEnv() routes this agent's outbound traffic through the harness
282
- // network broker when BIVY_EGRESS_PROXY is enabled (else it's {}).
283
- // bivySessionEnv() lets the agent's own shell resolve its session for
284
- // `bivy attach <path>` (see session-env.ts); spread last so it can never
285
- // be shadowed by an operator-configured env var of the same name.
286
- env: { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv, ...egressEnv(), ...bivySessionEnv(this.id) },
303
+ // Route this agent's outbound traffic through an egress proxy: this
304
+ // session's OWN proxy if it has one (a per-session sandbox/workflow network
305
+ // policy — sessionEgressEnv), else the node-global broker when
306
+ // BIVY_EGRESS_PROXY is enabled (else {}). bivySessionEnv() lets the agent's
307
+ // own shell resolve its session for `bivy attach <path>` (see
308
+ // session-env.ts); spread last so it can never be shadowed by an operator-
309
+ // configured env var of the same name.
310
+ env: { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv, ...(sessionEgressEnv(this.id) ?? egressEnv()), ...bivySessionEnv(this.id) },
287
311
  stdio: "pipe",
288
312
  // Detached so the child becomes the leader of its own process group
289
313
  // (POSIX) — see killProcessGroup() / abort() below, which kill that whole
@@ -380,7 +404,7 @@ class ProcessSession {
380
404
  this.emit({ type: "agent_end", code, signal });
381
405
  });
382
406
  if (this.runtimeOptions.promptMode !== "argv") {
383
- child.stdin.end(`${prompt}\n`);
407
+ child.stdin.end(`${promptToSend}\n`);
384
408
  }
385
409
  else {
386
410
  // Prompt is already in argv. Still close stdin so agents that also read it
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { EventEmitter } from "node:events";
6
6
  import { buildAgentCredentialEnv } from "./credentials.js";
7
7
  import { bivySessionEnv } from "./session-env.js";
8
+ import { mergeAgentCommands } from "./slash-commands.js";
8
9
  import { extractTokenUsage } from "./cli-parsers.js";
9
10
  /** A protocol `usage` message → UsageSnapshot (reuses the CLI token-key scan). */
10
11
  function parseProtocolUsage(raw) {
@@ -247,6 +248,42 @@ class ProtocolSession {
247
248
  await this.open();
248
249
  await this.command("command.invoke", { sessionId: this.id, runtimeSessionRef: this.runtimeSessionRef, name, args: args ?? "" });
249
250
  }
251
+ /** The session's slash commands: on-disk custom prompts (Codex/opencode) merged
252
+ * with whatever the shim advertised in its hello, disk winning a collision.
253
+ * Best-effort and display-only — a read failure just drops the on-disk set. */
254
+ getCommands() {
255
+ let disk;
256
+ try {
257
+ disk = this.runtimeOptions.slashCommands?.list(this.cwd);
258
+ }
259
+ catch {
260
+ disk = undefined;
261
+ }
262
+ return mergeAgentCommands(disk, this.capabilitiesRef.commands);
263
+ }
264
+ /**
265
+ * Resume this session in the agent's own interactive TUI (see the runtimeOptions
266
+ * hook). Resolves the same launch env a turn would — `prepare` (e.g. Codex
267
+ * mints CODEX_HOME + auth.json) then credentials — so the TUI opens with the
268
+ * identical auth as chat. Returns null when the runtime has no TUI hook or there
269
+ * is no session ref to resume yet (the daemon then surfaces "no TUI available").
270
+ */
271
+ async interactiveTuiCommand() {
272
+ const hook = this.runtimeOptions.interactiveTui;
273
+ if (!hook)
274
+ return null;
275
+ const credentialEnv = this.runtimeOptions.credentials
276
+ ? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
277
+ : {};
278
+ let prepareEnv = this.prepareEnv;
279
+ if (this.runtimeOptions.prepare) {
280
+ prepareEnv =
281
+ (await Promise.resolve(this.runtimeOptions.prepare({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv })).catch(() => undefined)) ??
282
+ prepareEnv;
283
+ }
284
+ const env = { ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv };
285
+ return hook({ sessionRef: this.runtimeSessionRef ?? this.resumeRef, cwd: this.cwd, env });
286
+ }
250
287
  getName() { return this.name; }
251
288
  setName(name) { this.name = name; }
252
289
  async suggestName(firstPrompt) {
@@ -556,6 +593,17 @@ class ProtocolSession {
556
593
  const images = (options?.images ?? []).map((img) => ({ type: "image", data: img.data, mimeType: img.mimeType }));
557
594
  if (!prompt && !images.length)
558
595
  return;
596
+ // A `/name args` line matching an on-disk custom prompt runs the command by
597
+ // sending its expanded body; the transcript still shows what the user typed.
598
+ // Non-command lines pass through untouched. Best-effort — a read failure sends
599
+ // the raw line.
600
+ let textToSend;
601
+ try {
602
+ textToSend = this.runtimeOptions.slashCommands?.expand(this.cwd, prompt) ?? prompt;
603
+ }
604
+ catch {
605
+ textToSend = prompt;
606
+ }
559
607
  this.messages.push({ role: "user", content: prompt, timestamp: Date.now() });
560
608
  this.streaming = true;
561
609
  this.assistantText = "";
@@ -566,7 +614,7 @@ class ProtocolSession {
566
614
  await this.command("chat.send", {
567
615
  sessionId: this.id,
568
616
  runtimeSessionRef: this.runtimeSessionRef,
569
- text: prompt,
617
+ text: textToSend,
570
618
  // Optional multimodal + streaming hints. Present only when the caller
571
619
  // supplied them, so a text-only turn keeps the exact payload it always had.
572
620
  ...(images.length ? { images } : {}),
@@ -0,0 +1,246 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // Filesystem-sourced slash commands for agents whose "slash commands" are custom
5
+ // prompt/command markdown files on disk — Codex's `$CODEX_HOME/prompts/*.md` and
6
+ // opencode's `command/*.md` dirs — rather than something learned from a live
7
+ // handshake the way Claude (SDK `init`) and Pi (its extension runner) already do.
8
+ // This is what gives Codex and opencode a real slash menu in the composer instead
9
+ // of the empty state Phase 1 added for them. Two responsibilities:
10
+ //
11
+ // 1. Discovery — enumerate those files as `AgentCommand[]` so `getCommands()` can
12
+ // advertise them and the composer offers them in autocomplete.
13
+ // 2. Local expansion — when the user actually invokes `/name args`, read the file
14
+ // and return its expanded body so the command RUNS, instead of the literal
15
+ // text "/name" being sent to the model. Codex and opencode expand custom
16
+ // prompts only in their interactive TUI, not on the non-interactive
17
+ // run/app-server path Bivy drives, so Bivy expands them itself. Substitution
18
+ // mirrors what those agents document: `$ARGUMENTS` → the whole argument
19
+ // string, `$1`..`$9` → positional words; unused trailing args are appended
20
+ // when the body carries no placeholder (matching Codex's own behaviour). We
21
+ // do NOT emulate opencode's `!shell` / `@file` macros — the expanded body is
22
+ // sent verbatim for those, a documented, graceful partial.
23
+ //
24
+ // Everything is best-effort and synchronous: an unreadable dir yields no commands
25
+ // and `expand()` returns undefined, so the caller falls back to the exact prior
26
+ // "forward the raw slash line" behaviour. `expand()` returning undefined for any
27
+ // line that isn't a known command means ordinary prompts — and a leading slash
28
+ // that happens not to be a command — pass through untouched.
29
+ import fs from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ const DEFAULT_LIMIT = 300;
33
+ const DEFAULT_DEPTH = 3;
34
+ /**
35
+ * Parse a markdown command file into its display description and prompt body.
36
+ * A leading `---` YAML frontmatter block contributes `description` (the only key
37
+ * we read); its body is everything after the block. With no frontmatter the body
38
+ * is the whole file and the description falls back to the first non-empty line
39
+ * (stripped of leading markdown heading/list markers, truncated for the menu).
40
+ * Exported for unit tests.
41
+ */
42
+ export function parseCommandMarkdown(content) {
43
+ let description;
44
+ let body = content;
45
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
46
+ if (fm) {
47
+ body = content.slice(fm[0].length);
48
+ for (const line of fm[1].split(/\r?\n/)) {
49
+ const m = /^\s*description\s*:\s*(.+?)\s*$/i.exec(line);
50
+ if (m) {
51
+ description = stripQuotes(m[1]);
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ if (!description) {
57
+ for (const raw of body.split(/\r?\n/)) {
58
+ const line = raw.trim();
59
+ if (!line)
60
+ continue;
61
+ description = line.replace(/^[#>*\-\s]+/, "").trim() || undefined;
62
+ break;
63
+ }
64
+ }
65
+ if (description && description.length > 80)
66
+ description = `${description.slice(0, 79)}…`;
67
+ return { description, body: body.trim() };
68
+ }
69
+ function stripQuotes(value) {
70
+ const v = value.trim();
71
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
72
+ return v.slice(1, -1);
73
+ }
74
+ return v;
75
+ }
76
+ /**
77
+ * Expand a command body against its argument string, mirroring Codex/opencode:
78
+ * `$ARGUMENTS` → the full argument string, `$1`..`$9` → positional words (missing
79
+ * ones → empty). When the body references no placeholder and args were supplied,
80
+ * they're appended (Codex appends unused args rather than dropping them).
81
+ * Exported for unit tests.
82
+ */
83
+ export function expandCommandBody(body, argString) {
84
+ const args = argString.trim();
85
+ const positional = args ? args.split(/\s+/) : [];
86
+ let used = false;
87
+ let out = body.replace(/\$ARGUMENTS\b/g, () => {
88
+ used = true;
89
+ return args;
90
+ });
91
+ out = out.replace(/\$([1-9])/g, (_m, d) => {
92
+ used = true;
93
+ return positional[Number(d) - 1] ?? "";
94
+ });
95
+ if (!used && args)
96
+ out = `${out.replace(/\s+$/, "")}\n\n${args}`;
97
+ return out.trim();
98
+ }
99
+ /** The command name (without leading slash) a "/name args" line invokes, or null. */
100
+ function parseInvocation(line) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed.startsWith("/"))
103
+ return null;
104
+ const ws = trimmed.search(/\s/);
105
+ const name = (ws === -1 ? trimmed.slice(1) : trimmed.slice(1, ws)).trim();
106
+ if (!name)
107
+ return null;
108
+ const args = ws === -1 ? "" : trimmed.slice(ws + 1).trim();
109
+ return { name, args };
110
+ }
111
+ /**
112
+ * Recursively collect `*.md` under `dir` as `relativePath (no .md)` → absolute
113
+ * path, where the relative path is POSIX-joined ("/") from the top `dir` so a
114
+ * subdirectory file namespaces (opencode's `git/commit.md` → `git/commit`).
115
+ */
116
+ function collectMarkdown(dir, prefix, depth, limit, out) {
117
+ let entries;
118
+ try {
119
+ entries = fs.readdirSync(dir, { withFileTypes: true });
120
+ }
121
+ catch {
122
+ return; // missing/unreadable dir — no commands from here
123
+ }
124
+ for (const entry of entries) {
125
+ if (out.size >= limit)
126
+ return;
127
+ if (entry.name.startsWith("."))
128
+ continue;
129
+ const abs = path.join(dir, entry.name);
130
+ if (entry.isDirectory()) {
131
+ if (depth > 0)
132
+ collectMarkdown(abs, prefix ? `${prefix}/${entry.name}` : entry.name, depth - 1, limit, out);
133
+ }
134
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
135
+ const rel = entry.name.slice(0, -3);
136
+ out.set(prefix ? `${prefix}/${rel}` : rel, abs);
137
+ }
138
+ }
139
+ }
140
+ /**
141
+ * Build the `/name` → file map for a session: scan each dir (later dirs win a
142
+ * collision), namespacing subdirectory files with "/" (opencode's convention;
143
+ * Codex prompts are flat). Best-effort — unreadable dirs contribute nothing.
144
+ */
145
+ function indexCommands(dirs, depth, limit) {
146
+ const byName = new Map();
147
+ for (const dir of dirs) {
148
+ const found = new Map();
149
+ collectMarkdown(dir, "", depth, limit, found);
150
+ for (const [rel, abs] of found) {
151
+ byName.set(`/${rel}`, abs);
152
+ }
153
+ }
154
+ return byName;
155
+ }
156
+ function readCommandFile(name, abs) {
157
+ let content;
158
+ try {
159
+ content = fs.readFileSync(abs, "utf8");
160
+ }
161
+ catch {
162
+ return undefined;
163
+ }
164
+ const { description, body } = parseCommandMarkdown(content);
165
+ return { name, description, body };
166
+ }
167
+ /**
168
+ * A `SlashCommandProvider` backed by markdown files in `opts.dirs(cwd)`. Used to
169
+ * give Codex (`$CODEX_HOME/prompts`) and opencode (global + project `command`
170
+ * dirs) their slash menus — see `codexSlashCommands` / `opencodeSlashCommands`.
171
+ */
172
+ export function markdownSlashCommands(opts) {
173
+ const depth = opts.depth ?? DEFAULT_DEPTH;
174
+ const limit = opts.limit ?? DEFAULT_LIMIT;
175
+ return {
176
+ list(cwd) {
177
+ const index = indexCommands(opts.dirs(cwd), depth, limit);
178
+ const out = [];
179
+ for (const [name, abs] of index) {
180
+ const file = readCommandFile(name, abs);
181
+ if (!file)
182
+ continue;
183
+ const command = { name };
184
+ if (file.description)
185
+ command.description = file.description;
186
+ out.push(command);
187
+ }
188
+ out.sort((a, b) => a.name.localeCompare(b.name));
189
+ return out;
190
+ },
191
+ expand(cwd, line) {
192
+ const invocation = parseInvocation(line);
193
+ if (!invocation)
194
+ return undefined;
195
+ const index = indexCommands(opts.dirs(cwd), depth, limit);
196
+ const abs = index.get(`/${invocation.name}`);
197
+ if (!abs)
198
+ return undefined;
199
+ const file = readCommandFile(`/${invocation.name}`, abs);
200
+ if (!file)
201
+ return undefined;
202
+ return expandCommandBody(file.body, invocation.args);
203
+ },
204
+ };
205
+ }
206
+ /** Codex's home dir, exactly as the CLI resolves it (`$CODEX_HOME` or `~/.codex`). */
207
+ function codexHome() {
208
+ return process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex");
209
+ }
210
+ /** The user's global opencode config dir (`$XDG_CONFIG_HOME` or `~/.config`). */
211
+ function opencodeConfigDir() {
212
+ return process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), ".config");
213
+ }
214
+ /** Codex custom prompts (`$CODEX_HOME/prompts/*.md`) → `/name` commands. */
215
+ export function codexSlashCommands() {
216
+ return markdownSlashCommands({ dirs: () => [path.join(codexHome(), "prompts")] });
217
+ }
218
+ /**
219
+ * opencode custom commands — the global `command` dir plus the project-local
220
+ * `.opencode/command` (which shadows the global on a name collision). See
221
+ * opencode.ai/docs/commands.
222
+ */
223
+ export function opencodeSlashCommands() {
224
+ return markdownSlashCommands({
225
+ dirs: (cwd) => [path.join(opencodeConfigDir(), "opencode", "command"), path.join(cwd, ".opencode", "command")],
226
+ });
227
+ }
228
+ /**
229
+ * Merge disk-sourced commands with any the runtime already advertised (e.g. a
230
+ * protocol shim's hello), disk winning a name collision. Used by ProtocolSession
231
+ * so hello-advertised commands and on-disk prompts coexist. First occurrence of
232
+ * each name (disk first) wins; input order is otherwise preserved.
233
+ */
234
+ export function mergeAgentCommands(...groups) {
235
+ const seen = new Set();
236
+ const out = [];
237
+ for (const group of groups) {
238
+ for (const command of group ?? []) {
239
+ if (!command?.name || seen.has(command.name))
240
+ continue;
241
+ seen.add(command.name);
242
+ out.push(command);
243
+ }
244
+ }
245
+ return out;
246
+ }