@bivy/bivy 0.6.0-staging.84 → 0.6.0-staging.86

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.
@@ -40,6 +40,21 @@ import { ensureCodexAuth } from "./codex-auth.js";
40
40
  import { parserFactoryFor } from "./cli-parsers.js";
41
41
  import { sandboxTier, sandboxArgsFor, codexSandboxPolicy } from "../harness/sandbox.js";
42
42
  import { ProtocolRuntime, protocolRuntimeFromEnv, protocolCommandsFromEnv } from "./protocol.js";
43
+ import { codexSlashCommands, opencodeSlashCommands } from "./slash-commands.js";
44
+ /**
45
+ * On-disk slash commands (custom prompts/commands) for the CLI agents that keep
46
+ * them as markdown on the node — Codex's `$CODEX_HOME/prompts`, opencode's
47
+ * global + project `command` dirs. Populates their composer menu and makes an
48
+ * invoked `/name` actually run (see SlashCommandProvider). Any other agent has no
49
+ * such directory convention, so it returns undefined (no agent-native commands).
50
+ */
51
+ function cliSlashCommands(id) {
52
+ if (id === "codex")
53
+ return codexSlashCommands();
54
+ if (id === "opencode")
55
+ return opencodeSlashCommands();
56
+ return undefined;
57
+ }
43
58
  export * from "./types.js";
44
59
  export { NodeCredentialResolver, createCredentialStore } from "./credentials.js";
45
60
  const PI_CAPABILITIES = {
@@ -982,6 +997,11 @@ function codexApprovalsInfo() {
982
997
  packages: false,
983
998
  fork: false,
984
999
  sessionDiscovery: true,
1000
+ // getUsage() returns the shim's real token/cost snapshot, and `codex resume
1001
+ // <id>` reopens the thread in Codex's TUI — advertise both so the catalog
1002
+ // (and the pre-session picker) match what the session actually backs.
1003
+ usageReporting: true,
1004
+ interactiveTui: installed,
985
1005
  // The governed/resumable Codex variant is the one that owns native
986
1006
  // discovery+adoption (issue #156) — not the plain exec runtime below —
987
1007
  // so an adopted session gets per-tool approvals from the moment it's
@@ -1098,7 +1118,12 @@ function codexAppServerRuntime(credsDir, tier) {
1098
1118
  ],
1099
1119
  },
1100
1120
  ],
1101
- capabilities: { toolInterception: true, modelSelection: true, resume: true, nativeSessionDiscovery: true, nativeSessionAdoption: true },
1121
+ // usageReporting: ProtocolSession.getUsage() already returns the shim's real
1122
+ // token/cost snapshot — advertise it so the catalog matches what's backed.
1123
+ // interactiveTui: `codex resume <rolloutId>` reopens the exact thread in
1124
+ // Codex's own TUI (the same verified command as native discovery/takeover),
1125
+ // gated on the codex binary being present — mirrors Claude's interactiveTui.
1126
+ capabilities: { toolInterception: true, modelSelection: true, resume: true, usageReporting: true, interactiveTui: commandAvailable("codex"), nativeSessionDiscovery: true, nativeSessionAdoption: true },
1102
1127
  // Resume: the shim reconnects a prior thread via thread/resume by its rollout
1103
1128
  // id, and history preloads from the same on-disk rollout the exec path reads —
1104
1129
  // so takeover/reopen continues a governed session. (Validated on codex-cli
@@ -1115,6 +1140,15 @@ function codexAppServerRuntime(credsDir, tier) {
1115
1140
  // Bivy didn't start, so a pre-existing `codex` session can be adopted here
1116
1141
  // (the governed variant), never the plain exec runtime below.
1117
1142
  discoverNativeSessions: () => discoverNativeCodexSessions(),
1143
+ // Codex custom prompts ($CODEX_HOME/prompts/*.md) → composer slash menu; an
1144
+ // invoked one is expanded and sent as the turn (the app-server doesn't expand
1145
+ // /prompt names itself). resolveCodexHome() matches the prepare'd CODEX_HOME.
1146
+ slashCommands: codexSlashCommands(),
1147
+ // "Continue in terminal": resume this exact thread in Codex's TUI by its
1148
+ // rollout id. `codex resume <id>` is the same command native discovery and
1149
+ // takeover already use (server.ts RESUME/NATIVE_RESUME maps); `env` carries
1150
+ // the minted CODEX_HOME so the TUI reads the same auth.json chat did.
1151
+ interactiveTui: ({ sessionRef, env }) => (sessionRef ? { command: "codex", args: ["resume", sessionRef], env } : null),
1118
1152
  });
1119
1153
  }
1120
1154
  // --- #2: the GENERAL ACP adapter (Agent Client Protocol) --------------------
@@ -1135,6 +1169,7 @@ function acpShimPath() {
1135
1169
  * ACP promotion path so both wrap agents identically.
1136
1170
  */
1137
1171
  function acpRuntimeOptions(opts) {
1172
+ const slashCommands = cliSlashCommands(opts.id);
1138
1173
  return {
1139
1174
  id: opts.id,
1140
1175
  displayName: opts.displayName,
@@ -1144,6 +1179,9 @@ function acpRuntimeOptions(opts) {
1144
1179
  // the FIRST session (before the shim's hello lands); the hello confirms them.
1145
1180
  capabilities: { toolInterception: true, resume: true },
1146
1181
  resumable: true,
1182
+ // An ACP-promoted opencode still surfaces/expands its on-disk commands (the
1183
+ // ACP handshake doesn't carry them); a bare ACP agent has none.
1184
+ ...(slashCommands ? { slashCommands } : {}),
1147
1185
  ...(opts.credsDir ? { credentials: createCredentialStore(opts.credsDir) } : {}),
1148
1186
  };
1149
1187
  }
@@ -1658,5 +1696,5 @@ function makeCliRuntime(id, options) {
1658
1696
  : [a.replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier)]),
1659
1697
  }
1660
1698
  : {};
1661
- return new ProcessRuntime({ id, displayName: spec.displayName, command: spec.command, args: runArgs, promptMode: spec.promptMode, credentials: createCredentialStore(options.credsDir), parserFactory: parserFactoryFor(parserId), preflight, prepare, model: cliModelConfig(id), thinking: cliThinkingConfig(id), usageReporting: cliUsageReporting(id), ...resumeOpts });
1699
+ return new ProcessRuntime({ id, displayName: spec.displayName, command: spec.command, args: runArgs, promptMode: spec.promptMode, credentials: createCredentialStore(options.credsDir), parserFactory: parserFactoryFor(parserId), preflight, prepare, model: cliModelConfig(id), thinking: cliThinkingConfig(id), usageReporting: cliUsageReporting(id), slashCommands: cliSlashCommands(id), ...resumeOpts });
1662
1700
  }
@@ -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.
@@ -380,7 +402,7 @@ class ProcessSession {
380
402
  this.emit({ type: "agent_end", code, signal });
381
403
  });
382
404
  if (this.runtimeOptions.promptMode !== "argv") {
383
- child.stdin.end(`${prompt}\n`);
405
+ child.stdin.end(`${promptToSend}\n`);
384
406
  }
385
407
  else {
386
408
  // 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
+ }
package/dist/server.js CHANGED
@@ -3084,6 +3084,7 @@ const RELAY_COMMANDS = {
3084
3084
  },
3085
3085
  async "models.list"(msg) {
3086
3086
  const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
3087
+ const wantedRuntimeId = typeof msg.runtimeId === "string" && msg.runtimeId ? msg.runtimeId : undefined;
3087
3088
  let record;
3088
3089
  try {
3089
3090
  record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, msg.path) : active;
@@ -3096,7 +3097,12 @@ const RELAY_COMMANDS = {
3096
3097
  relay?.sendEvent({ type: "session.error", sessionId: requestedSessionId, error: "Session not found" });
3097
3098
  return;
3098
3099
  }
3099
- record ??= await sessionForModelQuery();
3100
+ // On a draft (no session id), a runtime hint from the composer takes
3101
+ // precedence so an agent switch previews *that* agent's models even if a
3102
+ // stale `active` on another runtime lingers on the node.
3103
+ if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
3104
+ record = null;
3105
+ record ??= await sessionForModelQuery(wantedRuntimeId);
3100
3106
  const session = record.session;
3101
3107
  const current = session.getCurrentModel();
3102
3108
  const models = await publicModelsList(session, current);
@@ -3108,6 +3114,17 @@ const RELAY_COMMANDS = {
3108
3114
  // (e.g. Claude) — the "Claude shows Codex models" bug.
3109
3115
  relay?.sendEvent({ type: "models.list", sessionId: record.id, runtimeId: record.runtimeId, current: current ? publicModel(current, current) : null, models, thinking });
3110
3116
  },
3117
+ "models.prefetch"(msg) {
3118
+ // The composer's agent picker opened: warm the scratch session for each
3119
+ // offered agent in the background so the first switch to any of them answers
3120
+ // instantly. Fire-and-forget — no reply; the follow-up models.list carries
3121
+ // the result. Ignore anything but a bounded string[] of runtime ids.
3122
+ const ids = Array.isArray(msg.runtimeIds)
3123
+ ? msg.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
3124
+ : [];
3125
+ if (ids.length)
3126
+ prefetchModels(ids);
3127
+ },
3111
3128
  async "model.select"(msg) {
3112
3129
  const requestedSessionId = typeof msg.sessionId === "string" && msg.sessionId ? msg.sessionId : undefined;
3113
3130
  let record;
@@ -8055,32 +8072,75 @@ async function resolveOrResumeSession(sessionId, sessionPath) {
8055
8072
  // races the runtime.select that switches the default agent, pin the pill to the
8056
8073
  // *previous* runtime (the reported agent-switching bug). Mirror how session.new/
8057
8074
  // session.open already refuse to touch `active` for remote clients: reuse a
8058
- // single non-active scratch session on the current default runtime instead of
8059
- // spawning a fresh runtime process on every picker read.
8060
- let modelQueryScratch;
8061
- let modelQueryScratchPending;
8062
- async function sessionForModelQuery() {
8063
- if (active)
8075
+ // non-active scratch session per runtime instead of spawning a fresh runtime
8076
+ // process on every picker read.
8077
+ //
8078
+ // Keyed by runtime id, not a single slot: switching agents (Claude → Codex →
8079
+ // Claude) used to evict and re-spawn the one scratch on every switch — the
8080
+ // "switching agent takes a long time before models appear" bug. A map keeps one
8081
+ // warm scratch per runtime so a switch back to an agent already viewed this
8082
+ // session answers from the live session with no re-spawn, and `prefetchModels`
8083
+ // can warm several ahead of the first pick.
8084
+ const modelQueryScratch = new Map();
8085
+ const modelQueryScratchPending = new Map();
8086
+ async function sessionForModelQuery(runtimeId) {
8087
+ const wanted = resolveRuntimeId(runtimeId);
8088
+ // A live active session answers for itself — but only when it IS the runtime
8089
+ // being queried, so a prefetch/draft read for a *different* agent doesn't get
8090
+ // the active session's (wrong-runtime) model list.
8091
+ if (active && active.runtimeId === wanted)
8064
8092
  return active;
8065
- const wanted = resolveRuntimeId();
8066
- if (modelQueryScratch &&
8067
- openSessions.has(modelQueryScratch.id) &&
8068
- modelQueryScratch.runtimeId === wanted &&
8069
- !sessionBusy(modelQueryScratch)) {
8070
- touchSession(modelQueryScratch);
8071
- return modelQueryScratch;
8072
- }
8073
- // De-dupe concurrent picker reads. Without this, a WS models.list and an HTTP
8074
- // GET /api/models fired together on page load both miss the reuse guard above
8075
- // (the scratch assignment only lands after createSession resolves ~0.3s later)
8076
- // and each stand up a session, leaving two empty rows a fraction of a second
8077
- // apart. Collapse concurrent builds onto one promise, mirroring resumingSessions.
8078
- if (modelQueryScratchPending)
8079
- return modelQueryScratchPending;
8080
- modelQueryScratchPending = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true })
8081
- .then((rec) => { modelQueryScratch = rec; return rec; })
8082
- .finally(() => { modelQueryScratchPending = undefined; });
8083
- return modelQueryScratchPending;
8093
+ const cached = modelQueryScratch.get(wanted);
8094
+ if (cached && openSessions.has(cached.id) && cached.runtimeId === wanted && !sessionBusy(cached)) {
8095
+ touchSession(cached);
8096
+ return cached;
8097
+ }
8098
+ // De-dupe concurrent picker reads per runtime. Without this, a WS models.list
8099
+ // and an HTTP GET /api/models fired together on page load both miss the reuse
8100
+ // guard above (the scratch assignment only lands after createSession resolves
8101
+ // ~0.3s later) and each stand up a session, leaving two empty rows a fraction
8102
+ // of a second apart. Collapse concurrent builds onto one promise per runtime,
8103
+ // mirroring resumingSessions.
8104
+ const inflight = modelQueryScratchPending.get(wanted);
8105
+ if (inflight)
8106
+ return inflight;
8107
+ const build = createSession(defaultWorkspace, undefined, { makeActive: false, ephemeral: true, runtimeId: wanted })
8108
+ .then((rec) => { modelQueryScratch.set(wanted, rec); return rec; })
8109
+ .finally(() => { modelQueryScratchPending.delete(wanted); });
8110
+ modelQueryScratchPending.set(wanted, build);
8111
+ return build;
8112
+ }
8113
+ /**
8114
+ * Warm the model-query scratch for one or more runtimes in the background so the
8115
+ * first agent switch to any of them answers instantly instead of paying the
8116
+ * runtime spin-up on the critical path. Fired when the agent picker opens (see
8117
+ * the `models.prefetch` command). Best-effort and de-duped: a runtime already
8118
+ * warm (or being warmed) is a no-op, and a spin-up failure is swallowed — the
8119
+ * normal models.list path will surface any real error when the user picks it.
8120
+ */
8121
+ function prefetchModels(runtimeIds) {
8122
+ const wanted = [];
8123
+ for (const id of runtimeIds) {
8124
+ let resolved;
8125
+ try {
8126
+ resolved = resolveRuntimeId(id);
8127
+ }
8128
+ catch {
8129
+ continue; // unknown/uninstalled agent — nothing to warm
8130
+ }
8131
+ if (wanted.includes(resolved))
8132
+ continue;
8133
+ const cached = modelQueryScratch.get(resolved);
8134
+ if (cached && openSessions.has(cached.id) && !sessionBusy(cached))
8135
+ continue;
8136
+ if (modelQueryScratchPending.has(resolved))
8137
+ continue;
8138
+ wanted.push(resolved);
8139
+ }
8140
+ // Warm serially, not in a burst: spinning up every agent subprocess at once
8141
+ // would spike a small node's memory/CPU right as the user is interacting. Each
8142
+ // build is cached (and de-duped) so this cost is paid at most once per runtime.
8143
+ void wanted.reduce((chain, id) => chain.then(() => sessionForModelQuery(id).then(() => undefined, () => undefined)), Promise.resolve());
8084
8144
  }
8085
8145
  async function createRepoSession(parsed, opts = {}) {
8086
8146
  const token = await resolveTokenForRepo(parsed.owner, parsed.repo);
@@ -8971,10 +9031,13 @@ app.get("/api/models", async (req, res, next) => {
8971
9031
  try {
8972
9032
  const requestedSessionId = typeof req.query.sessionId === "string" && req.query.sessionId ? req.query.sessionId : undefined;
8973
9033
  const requestedPath = typeof req.query.path === "string" ? req.query.path : undefined;
9034
+ const wantedRuntimeId = typeof req.query.runtimeId === "string" && req.query.runtimeId ? req.query.runtimeId : undefined;
8974
9035
  let record = requestedSessionId ? await resolveOrResumeSession(requestedSessionId, requestedPath) : active;
8975
9036
  if (requestedSessionId && !record)
8976
9037
  return res.status(404).json({ error: "Session not found" });
8977
- record ??= await sessionForModelQuery();
9038
+ if (!requestedSessionId && wantedRuntimeId && record?.runtimeId !== wantedRuntimeId)
9039
+ record = undefined;
9040
+ record ??= await sessionForModelQuery(wantedRuntimeId);
8978
9041
  const session = record.session;
8979
9042
  const current = session.getCurrentModel();
8980
9043
  const models = await publicModelsList(session, current);
@@ -8985,6 +9048,17 @@ app.get("/api/models", async (req, res, next) => {
8985
9048
  next(error);
8986
9049
  }
8987
9050
  });
9051
+ // Warm the per-runtime model-query scratch ahead of the first agent switch (see
9052
+ // prefetchModels). Fire-and-forget: returns immediately while the runtimes spin
9053
+ // up in the background, so the picker never blocks on it.
9054
+ app.post("/api/models/prefetch", (req, res) => {
9055
+ const ids = Array.isArray(req.body?.runtimeIds)
9056
+ ? req.body.runtimeIds.filter((id) => typeof id === "string" && !!id).slice(0, 16)
9057
+ : [];
9058
+ if (ids.length)
9059
+ prefetchModels(ids);
9060
+ res.json({ ok: true });
9061
+ });
8988
9062
  app.post("/api/models/select", async (req, res, next) => {
8989
9063
  try {
8990
9064
  const requestedSessionId = typeof req.body?.sessionId === "string" && req.body.sessionId ? req.body.sessionId : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.6.0-staging.84",
3
+ "version": "0.6.0-staging.86",
4
4
  "type": "module",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "description": "Run coding agents on machines you own. Source-available, self-hostable agent workspace.",