@bivy/bivy 0.6.0 → 0.7.0-staging.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -6
- package/bin/agent-manifest.json +38 -0
- package/bin/bivy.mjs +99 -13
- package/bin/patch-pi-dependencies.mjs +22 -16
- package/dist/automation-checks.js +68 -0
- package/dist/bivy-login.js +13 -0
- package/dist/control-plane-tasks.js +39 -8
- package/dist/diagnostics.js +75 -0
- package/dist/github-tasks.js +27 -7
- package/dist/guard.js +51 -8
- package/dist/harness/egress.js +64 -1
- package/dist/harness/mcp-config.js +89 -6
- package/dist/harness/mcp-inject.js +31 -8
- package/dist/harness/net-proxy.js +28 -0
- package/dist/repo-workspace.js +19 -0
- package/dist/runtime/anthropic-preflight.js +41 -0
- package/dist/runtime/codex-sessions.js +10 -1
- package/dist/runtime/credential-store.js +35 -6
- package/dist/runtime/index.js +83 -3
- package/dist/runtime/oauth/model-oauth.js +5 -4
- package/dist/runtime/process.js +33 -9
- package/dist/runtime/protocol.js +49 -1
- package/dist/runtime/slash-commands.js +246 -0
- package/dist/server.js +587 -93
- package/dist/session/attachment-store.js +99 -11
- package/dist/session/event-log.js +75 -11
- package/dist/session/fork-dirty.js +41 -3
- package/dist/session/revert-file.js +44 -0
- package/dist/session/turn-watchdog.js +19 -0
- package/package.json +6 -3
|
@@ -34,6 +34,37 @@ function isStoredCredential(value) {
|
|
|
34
34
|
function providerId(id) {
|
|
35
35
|
return String(id ?? "").trim().toLowerCase();
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Should an `incoming` credential replace the `local` one during a non-destructive
|
|
39
|
+
* `importAll` merge? Pure and exported so the convergence rule is unit-testable
|
|
40
|
+
* without a vault. Rules:
|
|
41
|
+
* - No local entry → take the incoming one.
|
|
42
|
+
* - Only OAuth-vs-OAuth needs freshness arbitration (an api-key set/replace, or a
|
|
43
|
+
* type switch, keeps the existing "incoming wins on a real content change").
|
|
44
|
+
* - A snapshot that omits the refresh token must never clobber a usable one —
|
|
45
|
+
* rotated refresh tokens are single-use, so an incoming with a blank refresh is
|
|
46
|
+
* strictly worse than a local one that still has it.
|
|
47
|
+
* - Prefer the token minted LATER by `refreshedAt` (monotonic mint order) when
|
|
48
|
+
* both carry it; otherwise fall back to the access-token `expires`. In both
|
|
49
|
+
* cases a tie KEEPS the local credential (strictly-greater wins), so an equal
|
|
50
|
+
* stamp can't needlessly churn/rotate the vault, and clock skew can't let an
|
|
51
|
+
* equal-`expires` stale token win.
|
|
52
|
+
*/
|
|
53
|
+
export function preferIncomingCredential(local, incoming) {
|
|
54
|
+
if (!local)
|
|
55
|
+
return true;
|
|
56
|
+
if (local.type !== "oauth" || incoming.type !== "oauth")
|
|
57
|
+
return true;
|
|
58
|
+
const localRefresh = String(local.refresh ?? "").trim();
|
|
59
|
+
const incomingRefresh = String(incoming.refresh ?? "").trim();
|
|
60
|
+
if (!incomingRefresh && localRefresh)
|
|
61
|
+
return false;
|
|
62
|
+
const lt = Number(local.refreshedAt);
|
|
63
|
+
const it = Number(incoming.refreshedAt);
|
|
64
|
+
if (Number.isFinite(lt) && Number.isFinite(it))
|
|
65
|
+
return it > lt;
|
|
66
|
+
return (Number(incoming.expires) || 0) > (Number(local.expires) || 0);
|
|
67
|
+
}
|
|
37
68
|
/**
|
|
38
69
|
* Encrypted, cross-process-locked credential vault backed by `<vaultDir>/auth.enc`.
|
|
39
70
|
*
|
|
@@ -202,12 +233,10 @@ export class BivyCredentialStore {
|
|
|
202
233
|
if (!id || !isStoredCredential(incoming))
|
|
203
234
|
continue;
|
|
204
235
|
const local = vault[id];
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
236
|
+
// Freshest-wins, rotation-safe (see preferIncomingCredential): a lagging
|
|
237
|
+
// or refresh-less snapshot must not overwrite a fresher local login.
|
|
238
|
+
if (!preferIncomingCredential(local, incoming))
|
|
239
|
+
continue;
|
|
211
240
|
if (!(id in vault))
|
|
212
241
|
imported += 1;
|
|
213
242
|
// Only mark dirty on a real content change, so a snapshot that merely
|
package/dist/runtime/index.js
CHANGED
|
@@ -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 = {
|
|
@@ -697,6 +712,9 @@ export function cliAgentManifest() {
|
|
|
697
712
|
label: spec.displayName,
|
|
698
713
|
command: spec.command,
|
|
699
714
|
hidden: Boolean(spec.hidden),
|
|
715
|
+
supportTier: spec.supportTier ?? "beta",
|
|
716
|
+
certification: spec.testedVersion ? "release-tested" : (spec.supportTier ?? "beta") === "beta" ? "adapter-tested" : "unverified",
|
|
717
|
+
...(spec.testedVersion ? { testedVersion: spec.testedVersion } : {}),
|
|
700
718
|
headlessFlags: [...headless].filter((a) => !a.includes("{")),
|
|
701
719
|
install: spec.install ?? null,
|
|
702
720
|
};
|
|
@@ -979,6 +997,11 @@ function codexApprovalsInfo() {
|
|
|
979
997
|
packages: false,
|
|
980
998
|
fork: false,
|
|
981
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,
|
|
982
1005
|
// The governed/resumable Codex variant is the one that owns native
|
|
983
1006
|
// discovery+adoption (issue #156) — not the plain exec runtime below —
|
|
984
1007
|
// so an adopted session gets per-tool approvals from the moment it's
|
|
@@ -1095,7 +1118,12 @@ function codexAppServerRuntime(credsDir, tier) {
|
|
|
1095
1118
|
],
|
|
1096
1119
|
},
|
|
1097
1120
|
],
|
|
1098
|
-
|
|
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 },
|
|
1099
1127
|
// Resume: the shim reconnects a prior thread via thread/resume by its rollout
|
|
1100
1128
|
// id, and history preloads from the same on-disk rollout the exec path reads —
|
|
1101
1129
|
// so takeover/reopen continues a governed session. (Validated on codex-cli
|
|
@@ -1112,6 +1140,15 @@ function codexAppServerRuntime(credsDir, tier) {
|
|
|
1112
1140
|
// Bivy didn't start, so a pre-existing `codex` session can be adopted here
|
|
1113
1141
|
// (the governed variant), never the plain exec runtime below.
|
|
1114
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),
|
|
1115
1152
|
});
|
|
1116
1153
|
}
|
|
1117
1154
|
// --- #2: the GENERAL ACP adapter (Agent Client Protocol) --------------------
|
|
@@ -1132,6 +1169,7 @@ function acpShimPath() {
|
|
|
1132
1169
|
* ACP promotion path so both wrap agents identically.
|
|
1133
1170
|
*/
|
|
1134
1171
|
function acpRuntimeOptions(opts) {
|
|
1172
|
+
const slashCommands = cliSlashCommands(opts.id);
|
|
1135
1173
|
return {
|
|
1136
1174
|
id: opts.id,
|
|
1137
1175
|
displayName: opts.displayName,
|
|
@@ -1141,6 +1179,9 @@ function acpRuntimeOptions(opts) {
|
|
|
1141
1179
|
// the FIRST session (before the shim's hello lands); the hello confirms them.
|
|
1142
1180
|
capabilities: { toolInterception: true, resume: true },
|
|
1143
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 } : {}),
|
|
1144
1185
|
...(opts.credsDir ? { credentials: createCredentialStore(opts.credsDir) } : {}),
|
|
1145
1186
|
};
|
|
1146
1187
|
}
|
|
@@ -1450,6 +1491,45 @@ const PICKER_RUNTIME_IDS = new Set([
|
|
|
1450
1491
|
...NON_CLI_PICKER_IDS,
|
|
1451
1492
|
...CLI_AGENT_IDS.filter((id) => !CLI_AGENT_SPECS[id].hidden),
|
|
1452
1493
|
]);
|
|
1494
|
+
function runtimeCertification(runtime) {
|
|
1495
|
+
if (runtime.id === "pi")
|
|
1496
|
+
return { certification: "release-tested", testedVersion: "0.83.0" };
|
|
1497
|
+
if (runtime.id === "claude-code-sdk")
|
|
1498
|
+
return { certification: "release-tested", testedVersion: "0.3.220" };
|
|
1499
|
+
if (runtime.testedVersion)
|
|
1500
|
+
return { certification: "release-tested", testedVersion: runtime.testedVersion };
|
|
1501
|
+
return { certification: runtime.supportTier === "beta" ? "adapter-tested" : "unverified" };
|
|
1502
|
+
}
|
|
1503
|
+
function runtimeProtection(runtime) {
|
|
1504
|
+
// Native SDK/CLI sandboxes receive the requested read-only/workspace/full tier
|
|
1505
|
+
// in their own process boundary. The governed Codex path has both native
|
|
1506
|
+
// sandbox flags and Bivy interception; label the stronger containment source.
|
|
1507
|
+
const nativeSandbox = runtime.id === "claude-code-sdk" || runtime.id === "codex-approvals"
|
|
1508
|
+
|| (isCliAgentId(runtime.id) && Boolean(CLI_AGENT_SPECS[runtime.id].composeArgs));
|
|
1509
|
+
if (nativeSandbox)
|
|
1510
|
+
return {
|
|
1511
|
+
protectionLevel: "native-sandbox",
|
|
1512
|
+
protectionLabel: "Native sandbox",
|
|
1513
|
+
protectionDetail: "This agent enforces Bivy's selected access tier in its native sandbox. Bivy tool controls may add approvals, but are not an OS jail of their own.",
|
|
1514
|
+
};
|
|
1515
|
+
if (runtime.capabilities.toolInterception)
|
|
1516
|
+
return {
|
|
1517
|
+
protectionLevel: "tool-controls",
|
|
1518
|
+
protectionLabel: "Bivy tool controls",
|
|
1519
|
+
protectionDetail: "Structured tool calls pass through Bivy policy and approvals. Shell heuristics prevent accidents, not adversarial escape.",
|
|
1520
|
+
};
|
|
1521
|
+
if (runtime.capabilities.mcpToolApprovals)
|
|
1522
|
+
return {
|
|
1523
|
+
protectionLevel: "mcp-controls",
|
|
1524
|
+
protectionLabel: "MCP tools only",
|
|
1525
|
+
protectionDetail: "Bivy governs MCP tool calls, but the agent's built-in shell and file operations still run with your user permissions.",
|
|
1526
|
+
};
|
|
1527
|
+
return {
|
|
1528
|
+
protectionLevel: "user-permissions",
|
|
1529
|
+
protectionLabel: "Runs as your user",
|
|
1530
|
+
protectionDetail: "No Bivy-owned isolation or complete tool interception. Use a container/VM for unattended or untrusted work.",
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1453
1533
|
export function listRuntimes(currentId) {
|
|
1454
1534
|
return RUNTIME_CATALOG
|
|
1455
1535
|
// Keep the current runtime visible even if hidden, so a session pinned to a
|
|
@@ -1472,7 +1552,7 @@ export function listRuntimes(currentId) {
|
|
|
1472
1552
|
if (runtime.id === "acp")
|
|
1473
1553
|
return acpInfo();
|
|
1474
1554
|
return runtime;
|
|
1475
|
-
}).map((runtime) => ({ ...runtime, current: runtime.id === currentId }));
|
|
1555
|
+
}).map((runtime) => ({ ...runtime, ...runtimeProtection(runtime), ...runtimeCertification(runtime), current: runtime.id === currentId }));
|
|
1476
1556
|
}
|
|
1477
1557
|
export function makeRuntime(options) {
|
|
1478
1558
|
const id = (options.runtime ?? process.env.BIVY_RUNTIME ?? "pi").toLowerCase();
|
|
@@ -1616,5 +1696,5 @@ function makeCliRuntime(id, options) {
|
|
|
1616
1696
|
: [a.replace(/\{id\}/g, sessionId).replace(/\{tier\}/g, tier)]),
|
|
1617
1697
|
}
|
|
1618
1698
|
: {};
|
|
1619
|
-
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 });
|
|
1620
1700
|
}
|
|
@@ -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
|
|
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
|
}
|
package/dist/runtime/process.js
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
env
|
|
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(`${
|
|
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
|
package/dist/runtime/protocol.js
CHANGED
|
@@ -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:
|
|
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
|
+
}
|