@bivy/bivy 0.6.0 → 0.7.0-staging.101
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/acp-shim.mjs +128 -14
- package/bin/agent-manifest.json +39 -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/metadata.js +18 -0
- package/dist/policy/conditions.js +54 -3
- package/dist/policy/run-policy.js +2 -1
- package/dist/policy/session-reroute.js +52 -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 +177 -20
- package/dist/runtime/oauth/model-oauth.js +5 -4
- package/dist/runtime/process.js +33 -9
- package/dist/runtime/protocol.js +64 -1
- package/dist/runtime/slash-commands.js +246 -0
- package/dist/server.js +789 -108
- 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
|
@@ -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
|
+
}
|