@bivy/bivy 0.0.0 → 0.1.0-staging.2
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/LICENSE +105 -0
- package/README.md +265 -5
- package/bin/acp-shim.mjs +298 -0
- package/bin/agent-manifest.json +277 -0
- package/bin/bivy.mjs +4100 -0
- package/bin/codex-app-server-shim.mjs +447 -0
- package/bin/patch-pi-dependencies.mjs +44 -0
- package/bin/prune-sessions.mjs +52 -0
- package/bin/sessions-list.mjs +27 -0
- package/bin/shim-path.mjs +126 -0
- package/bin/uninstall-paths.mjs +48 -0
- package/dist/approval.js +87 -0
- package/dist/attach.js +248 -0
- package/dist/auth.js +258 -0
- package/dist/bivy-login.js +180 -0
- package/dist/browser-open.js +50 -0
- package/dist/control-plane-tasks.js +236 -0
- package/dist/data-dir.js +25 -0
- package/dist/device-registry.js +201 -0
- package/dist/e2e.js +70 -0
- package/dist/ephemeral-exec.js +109 -0
- package/dist/exec.js +209 -0
- package/dist/git-auth.js +155 -0
- package/dist/github-app-auth.js +107 -0
- package/dist/github-app-connect.js +235 -0
- package/dist/github-app-manifest.js +82 -0
- package/dist/github-app-sync-cli.js +93 -0
- package/dist/github-app-vault.js +106 -0
- package/dist/github-apps.js +121 -0
- package/dist/github-connect-repo.js +74 -0
- package/dist/github-device-auth.js +109 -0
- package/dist/github-tasks.js +650 -0
- package/dist/guard.js +109 -0
- package/dist/harness/cache-evict.js +88 -0
- package/dist/harness/checkpoint.js +0 -0
- package/dist/harness/cow-clone.js +84 -0
- package/dist/harness/dep-cache.js +78 -0
- package/dist/harness/disk-admission.js +46 -0
- package/dist/harness/egress.js +30 -0
- package/dist/harness/manager.js +97 -0
- package/dist/harness/mcp-config-formats.js +164 -0
- package/dist/harness/mcp-config.js +111 -0
- package/dist/harness/mcp-inject.js +134 -0
- package/dist/harness/mcp-proxy-cli.js +88 -0
- package/dist/harness/mcp-proxy.js +150 -0
- package/dist/harness/net-proxy.js +120 -0
- package/dist/harness/sandbox.js +96 -0
- package/dist/history-sync.js +26 -0
- package/dist/hosted-endpoints.d.mts +14 -0
- package/dist/hosted-endpoints.mjs +35 -0
- package/dist/identity.js +153 -0
- package/dist/integrations/index.js +4 -0
- package/dist/integrations/manager.js +279 -0
- package/dist/integrations/oauth.js +78 -0
- package/dist/integrations/registry.js +239 -0
- package/dist/integrations/store.js +54 -0
- package/dist/integrations/types.js +1 -0
- package/dist/linear-tasks.js +49 -0
- package/dist/metadata.js +226 -0
- package/dist/multiplexer.js +79 -0
- package/dist/native-pi.js +38 -0
- package/dist/node-stats.js +237 -0
- package/dist/pairing-crypto.js +105 -0
- package/dist/policy/conditions.js +103 -0
- package/dist/policy/policy-engine.js +20 -0
- package/dist/policy/risk.js +18 -0
- package/dist/policy/ruleset.js +113 -0
- package/dist/policy/run-policy.js +108 -0
- package/dist/policy/session-reroute.js +96 -0
- package/dist/pty-runner.py +95 -0
- package/dist/question.js +146 -0
- package/dist/redact.js +97 -0
- package/dist/relay-attach.js +345 -0
- package/dist/relay-chunk.js +73 -0
- package/dist/relay-cli-crypto.js +70 -0
- package/dist/relay-client.js +344 -0
- package/dist/relay-setup.js +262 -0
- package/dist/repo-workspace.js +208 -0
- package/dist/runtime/adoption.js +45 -0
- package/dist/runtime/agent-service-bin.js +149 -0
- package/dist/runtime/agent-service.js +439 -0
- package/dist/runtime/ansi.js +27 -0
- package/dist/runtime/anthropic-preflight.js +80 -0
- package/dist/runtime/claude-code.js +1364 -0
- package/dist/runtime/cli-parsers.js +647 -0
- package/dist/runtime/codex-auth.js +168 -0
- package/dist/runtime/codex-preflight.js +60 -0
- package/dist/runtime/codex-sessions.js +229 -0
- package/dist/runtime/control-plane-location.js +74 -0
- package/dist/runtime/credential-ingest.js +122 -0
- package/dist/runtime/credential-provisioning.js +79 -0
- package/dist/runtime/credential-store.js +435 -0
- package/dist/runtime/credentials.js +153 -0
- package/dist/runtime/host.js +153 -0
- package/dist/runtime/index.js +1548 -0
- package/dist/runtime/local-model-store.js +194 -0
- package/dist/runtime/location-registry.js +28 -0
- package/dist/runtime/model-catalog.js +97 -0
- package/dist/runtime/model-namer.js +85 -0
- package/dist/runtime/native-process-scan.js +102 -0
- package/dist/runtime/native-session-discovery.js +103 -0
- package/dist/runtime/normalize.js +75 -0
- package/dist/runtime/oauth/model-oauth-providers.js +75 -0
- package/dist/runtime/oauth/model-oauth.js +324 -0
- package/dist/runtime/opencode-preflight.js +55 -0
- package/dist/runtime/pi-auth.js +82 -0
- package/dist/runtime/pi-oauth.js +52 -0
- package/dist/runtime/pi-session-discovery.js +42 -0
- package/dist/runtime/pi.js +518 -0
- package/dist/runtime/process.js +499 -0
- package/dist/runtime/protocol.js +630 -0
- package/dist/runtime/remote.js +541 -0
- package/dist/runtime/rpc-protocol.js +56 -0
- package/dist/runtime/ruleset-store.js +117 -0
- package/dist/runtime/session-location.js +50 -0
- package/dist/runtime/types.js +17 -0
- package/dist/secrets-cli.js +134 -0
- package/dist/secrets.js +264 -0
- package/dist/server.js +9411 -0
- package/dist/session/bivy-session.js +1 -0
- package/dist/session/checkpoint-pack.js +133 -0
- package/dist/session/event-log.js +340 -0
- package/dist/session/fork-dirty.js +73 -0
- package/dist/session/fork-prereqs.js +61 -0
- package/dist/session/fork.js +57 -0
- package/dist/session/native-import.js +56 -0
- package/dist/session/reconnect.js +168 -0
- package/dist/session/replication-service.js +236 -0
- package/dist/session/replication.js +106 -0
- package/dist/session/replicator.js +140 -0
- package/dist/session/session-new-dedupe.js +42 -0
- package/dist/session/sibling-client.js +201 -0
- package/dist/session/transcript-merge.js +131 -0
- package/dist/session/transcript-normal.js +130 -0
- package/dist/session/workspace-context.js +1 -0
- package/dist/session-event-coalescer.js +50 -0
- package/dist/session-identity.js +34 -0
- package/dist/session-ref.js +65 -0
- package/dist/stt-cli.js +131 -0
- package/dist/stt.js +168 -0
- package/dist/terminal.js +409 -0
- package/dist/wire-format.js +67 -0
- package/dist/worktree-provision.js +118 -0
- package/dist/worktree.js +117 -0
- package/package.json +40 -6
- package/public/qr.js +464 -0
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
3
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
4
|
+
// Codex ⇄ Bivy Agent Protocol shim (Tier 2 — in-chat approvals).
|
|
5
|
+
//
|
|
6
|
+
// Bivy's Tier-1 Codex runtime drives `codex exec --json`, which has no channel to
|
|
7
|
+
// gate a tool *before* it runs (stdin is the prompt; there is no approval event to
|
|
8
|
+
// answer). So Codex governance there is effect-level only (the exec jail).
|
|
9
|
+
//
|
|
10
|
+
// This shim gives Codex the same per-tool Approve/Deny cards as Claude/Pi by
|
|
11
|
+
// driving Codex's experimental `app-server` (stdio JSON-RPC) and translating it to
|
|
12
|
+
// the bivy-agent-protocol JSONL that `ProtocolRuntime` (src/runtime/protocol.ts)
|
|
13
|
+
// speaks. The app-server raises a server→client `requestApproval` before running a
|
|
14
|
+
// shell command or applying a patch; we surface that as a protocol `tool.call`,
|
|
15
|
+
// block on Bivy's `tool.decision`, and answer the app-server accept/decline. With
|
|
16
|
+
// `approvalPolicy: "untrusted"` Codex escalates every model-proposed action, so
|
|
17
|
+
// Bivy's guardianInterceptor sees — and can veto — each one.
|
|
18
|
+
//
|
|
19
|
+
// Protocol surfaces (verified against codex-cli 0.144.1):
|
|
20
|
+
// • initialize → handshake
|
|
21
|
+
// • thread/start {cwd, approvalPolicy, sandbox} → threadId
|
|
22
|
+
// • thread/resume {threadId, cwd, approvalPolicy, sandbox} → threadId
|
|
23
|
+
// • turn/start {threadId, input:[{type:"text",text}]}
|
|
24
|
+
// • item/agentMessage/delta {delta} → assistant text
|
|
25
|
+
// • item/commandExecution/requestApproval → shell approval ({decision:accept|decline})
|
|
26
|
+
// • item/fileChange/requestApproval → patch approval
|
|
27
|
+
// • turn/completed → turn done
|
|
28
|
+
//
|
|
29
|
+
// This is a first-class v1: governed sessions with shell/patch approvals and a
|
|
30
|
+
// generic protocol `session.resume` primitive (thread/resume — validated against
|
|
31
|
+
// codex-cli 0.144.1). MCP-tool routing is still a follow-up.
|
|
32
|
+
|
|
33
|
+
import { spawn } from "node:child_process";
|
|
34
|
+
import { createInterface } from "node:readline";
|
|
35
|
+
|
|
36
|
+
const CODEX_BIN = process.env.BIVY_CODEX_BIN || "codex";
|
|
37
|
+
// Force Codex to ask before every model-proposed action so Bivy governs each one.
|
|
38
|
+
const APPROVAL_POLICY = process.env.BIVY_CODEX_APPROVAL_POLICY || "untrusted";
|
|
39
|
+
const SANDBOX_MODE = process.env.BIVY_CODEX_SANDBOX || "workspace-write";
|
|
40
|
+
|
|
41
|
+
// --- bivy-agent-protocol output (our stdout) --------------------------------
|
|
42
|
+
function bivy(obj) {
|
|
43
|
+
process.stdout.write(`${JSON.stringify(obj)}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- codex app-server (child JSON-RPC over its stdio) -----------------------
|
|
47
|
+
const as = spawn(CODEX_BIN, ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
48
|
+
let asNextId = 1;
|
|
49
|
+
const asPending = new Map(); // jsonrpc id -> {resolve, reject}
|
|
50
|
+
// itemId -> app-server request id, so a later tool.decision can answer the right approval.
|
|
51
|
+
const approvalRequests = new Map();
|
|
52
|
+
|
|
53
|
+
function asRequest(method, params) {
|
|
54
|
+
const id = asNextId++;
|
|
55
|
+
as.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
|
|
56
|
+
return new Promise((resolve, reject) => asPending.set(id, { resolve, reject }));
|
|
57
|
+
}
|
|
58
|
+
function asReply(id, result) {
|
|
59
|
+
as.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- session state ----------------------------------------------------------
|
|
63
|
+
let threadId = null;
|
|
64
|
+
let initialized = false;
|
|
65
|
+
let sawError = null;
|
|
66
|
+
let models = [];
|
|
67
|
+
let selectedModel = null;
|
|
68
|
+
const itemInputs = new Map();
|
|
69
|
+
const itemOutputs = new Map();
|
|
70
|
+
const agentMessageItems = new Set();
|
|
71
|
+
const reasoningItems = new Set();
|
|
72
|
+
|
|
73
|
+
function text(value) {
|
|
74
|
+
if (Array.isArray(value)) return value.map(text).filter(Boolean).join("\n");
|
|
75
|
+
if (value == null) return "";
|
|
76
|
+
if (typeof value === "string") return value;
|
|
77
|
+
if (typeof value === "object") return text(value.text ?? value.content ?? value.summary ?? value.message ?? "");
|
|
78
|
+
return String(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function commandText(command) {
|
|
82
|
+
return Array.isArray(command) ? command.join(" ") : text(command);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function paramsItemId(params, fallback) {
|
|
86
|
+
return String(params?.itemId || params?.callId || params?.item?.id || fallback || "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function rememberItem(item) {
|
|
90
|
+
if (!item || typeof item !== "object") return;
|
|
91
|
+
const id = String(item.id || "");
|
|
92
|
+
if (!id) return;
|
|
93
|
+
if (item.type === "commandExecution") {
|
|
94
|
+
itemInputs.set(id, {
|
|
95
|
+
command: commandText(item.command),
|
|
96
|
+
cwd: item.cwd,
|
|
97
|
+
...(item.source ? { source: item.source } : {}),
|
|
98
|
+
});
|
|
99
|
+
if (item.aggregatedOutput) itemOutputs.set(id, String(item.aggregatedOutput).slice(-4000));
|
|
100
|
+
} else if (item.type === "fileChange") {
|
|
101
|
+
itemInputs.set(id, { changes: item.changes ?? [], status: item.status });
|
|
102
|
+
} else if (item.type === "mcpToolCall") {
|
|
103
|
+
itemInputs.set(id, { server: item.server, tool: item.tool, arguments: item.arguments });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function updateInput(itemId, patch = {}) {
|
|
108
|
+
return { ...(itemInputs.get(itemId) || {}), ...patch };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function appendOutput(itemId, delta) {
|
|
112
|
+
const output = `${itemOutputs.get(itemId) || ""}${text(delta)}`.slice(-4000);
|
|
113
|
+
itemOutputs.set(itemId, output);
|
|
114
|
+
return output;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function emitToolUpdate(itemId, name, input) {
|
|
118
|
+
if (!itemId) return;
|
|
119
|
+
bivy({ type: "tool_execution_update", toolCallId: itemId, toolName: name, input });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function emitReasoning(itemId, delta) {
|
|
123
|
+
const body = text(delta);
|
|
124
|
+
if (!body) return;
|
|
125
|
+
if (itemId) reasoningItems.add(itemId);
|
|
126
|
+
bivy({ type: "message.reasoning", text: body });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function reasoningFromItem(item) {
|
|
130
|
+
return text(item?.summary).trim() || text(item?.content).trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function handleCompletedItem(params) {
|
|
134
|
+
const item = params?.item;
|
|
135
|
+
rememberItem(item);
|
|
136
|
+
const itemId = paramsItemId(params);
|
|
137
|
+
if (!item || typeof item !== "object" || !itemId) return;
|
|
138
|
+
switch (item.type) {
|
|
139
|
+
case "agentMessage":
|
|
140
|
+
if (!agentMessageItems.has(itemId) && item.text) {
|
|
141
|
+
agentMessageItems.add(itemId);
|
|
142
|
+
bivy({ type: "message.delta", text: String(item.text) });
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
case "plan":
|
|
146
|
+
if (!reasoningItems.has(itemId) && item.text) emitReasoning(itemId, item.text);
|
|
147
|
+
return;
|
|
148
|
+
case "reasoning": {
|
|
149
|
+
const body = reasoningFromItem(item);
|
|
150
|
+
if (!reasoningItems.has(itemId) && body) emitReasoning(itemId, body);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
case "commandExecution": {
|
|
154
|
+
const output = text(item.aggregatedOutput ?? itemOutputs.get(itemId));
|
|
155
|
+
if (output) itemOutputs.set(itemId, output.slice(-4000));
|
|
156
|
+
emitToolUpdate(itemId, "shell", updateInput(itemId, { output }));
|
|
157
|
+
bivy({ type: "tool.result", toolCallId: itemId, name: "shell", result: output || text(item.status || "completed") });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case "fileChange": {
|
|
161
|
+
const status = text(item.status || "completed");
|
|
162
|
+
itemInputs.set(itemId, updateInput(itemId, { changes: item.changes ?? [], status }));
|
|
163
|
+
emitToolUpdate(itemId, "apply_patch", updateInput(itemId));
|
|
164
|
+
bivy({ type: "tool.result", toolCallId: itemId, name: "apply_patch", result: status });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
case "mcpToolCall": {
|
|
168
|
+
const name = text(item.tool || "mcp");
|
|
169
|
+
const result = text(item.result ?? item.error ?? "");
|
|
170
|
+
bivy({ type: "tool.result", toolCallId: itemId, name, result });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
default:
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
as.stderr.on("data", (d) => {
|
|
179
|
+
const s = d.toString();
|
|
180
|
+
// Bubblewrap-missing is a benign warning; surface anything else for debugging.
|
|
181
|
+
if (!/bubblewrap|configWarning/.test(s)) process.stderr.write(`[codex-app-server] ${s}`);
|
|
182
|
+
});
|
|
183
|
+
as.on("close", (code) => {
|
|
184
|
+
bivy({ type: "session.error", error: `codex app-server exited (${code})` });
|
|
185
|
+
process.exit(code ?? 1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Parse the app-server's newline-delimited JSON-RPC.
|
|
189
|
+
createInterface({ input: as.stdout }).on("line", (line) => {
|
|
190
|
+
if (!line.trim()) return;
|
|
191
|
+
let m;
|
|
192
|
+
try { m = JSON.parse(line); } catch { return; }
|
|
193
|
+
// Response to one of our requests.
|
|
194
|
+
if (m.id !== undefined && (m.result !== undefined || m.error !== undefined) && !m.method) {
|
|
195
|
+
const p = asPending.get(m.id);
|
|
196
|
+
if (p) {
|
|
197
|
+
asPending.delete(m.id);
|
|
198
|
+
if (m.error) p.reject(new Error(m.error.message || "app-server error"));
|
|
199
|
+
else p.resolve(m.result);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Server→client request (needs a reply).
|
|
204
|
+
if (m.id !== undefined && m.method) return onServerRequest(m);
|
|
205
|
+
// Notification (event).
|
|
206
|
+
if (m.method) return onNotification(m);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
function onServerRequest(m) {
|
|
210
|
+
const { method, id, params } = m;
|
|
211
|
+
if (method === "item/commandExecution/requestApproval" || method === "execCommandApproval") {
|
|
212
|
+
const itemId = paramsItemId(params, id);
|
|
213
|
+
approvalRequests.set(itemId, id);
|
|
214
|
+
const input = {
|
|
215
|
+
command: commandText(params.command),
|
|
216
|
+
cwd: params.cwd,
|
|
217
|
+
reason: params.reason,
|
|
218
|
+
...(params.environmentId ? { environmentId: params.environmentId } : {}),
|
|
219
|
+
};
|
|
220
|
+
itemInputs.set(itemId, input);
|
|
221
|
+
bivy({ type: "tool.call", toolCallId: itemId, name: "shell", input });
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (method === "item/fileChange/requestApproval" || method === "applyPatchApproval") {
|
|
225
|
+
const itemId = paramsItemId(params, id);
|
|
226
|
+
approvalRequests.set(itemId, id);
|
|
227
|
+
const input = {
|
|
228
|
+
reason: params.reason,
|
|
229
|
+
changes: params.changes ?? params.fileChanges,
|
|
230
|
+
...(params.grantRoot ? { grantRoot: params.grantRoot } : {}),
|
|
231
|
+
};
|
|
232
|
+
itemInputs.set(itemId, input);
|
|
233
|
+
bivy({ type: "tool.call", toolCallId: itemId, name: "apply_patch", input });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Any other server→client request we don't model yet: accept so the turn isn't
|
|
237
|
+
// wedged (these are non-tool prompts like auth-token refresh). Decline would
|
|
238
|
+
// abort the turn; the exec jail still bounds real effects.
|
|
239
|
+
asReply(id, {});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function onNotification(m) {
|
|
243
|
+
const { method, params } = m;
|
|
244
|
+
switch (method) {
|
|
245
|
+
case "item/agentMessage/delta":
|
|
246
|
+
if (typeof params.delta === "string" && params.delta) {
|
|
247
|
+
const itemId = paramsItemId(params);
|
|
248
|
+
if (itemId) agentMessageItems.add(itemId);
|
|
249
|
+
bivy({ type: "message.delta", text: params.delta });
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
case "item/plan/delta":
|
|
253
|
+
emitReasoning(paramsItemId(params), params.delta);
|
|
254
|
+
return;
|
|
255
|
+
case "item/reasoning/summaryTextDelta":
|
|
256
|
+
case "item/reasoning/textDelta":
|
|
257
|
+
emitReasoning(paramsItemId(params), params.delta);
|
|
258
|
+
return;
|
|
259
|
+
case "item/commandExecution/outputDelta": {
|
|
260
|
+
const itemId = paramsItemId(params);
|
|
261
|
+
const output = appendOutput(itemId, params.delta);
|
|
262
|
+
emitToolUpdate(itemId, "shell", updateInput(itemId, { output }));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
case "item/fileChange/outputDelta": {
|
|
266
|
+
const itemId = paramsItemId(params);
|
|
267
|
+
const output = appendOutput(itemId, params.delta);
|
|
268
|
+
emitToolUpdate(itemId, "apply_patch", updateInput(itemId, { output }));
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
case "item/fileChange/patchUpdated": {
|
|
272
|
+
const itemId = paramsItemId(params);
|
|
273
|
+
itemInputs.set(itemId, updateInput(itemId, { changes: params.changes ?? [] }));
|
|
274
|
+
emitToolUpdate(itemId, "apply_patch", updateInput(itemId));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
case "item/started": {
|
|
278
|
+
const item = params?.item;
|
|
279
|
+
rememberItem(item);
|
|
280
|
+
const itemId = paramsItemId(params);
|
|
281
|
+
if (item?.type === "commandExecution") emitToolUpdate(itemId, "shell", updateInput(itemId));
|
|
282
|
+
if (item?.type === "fileChange") emitToolUpdate(itemId, "apply_patch", updateInput(itemId));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
case "item/completed":
|
|
286
|
+
handleCompletedItem(params);
|
|
287
|
+
return;
|
|
288
|
+
case "thread/tokenUsage/updated":
|
|
289
|
+
if (params?.tokenUsage) bivy({ type: "usage", usage: params.tokenUsage.last ?? params.tokenUsage.total ?? params.tokenUsage });
|
|
290
|
+
return;
|
|
291
|
+
case "rawResponse/completed":
|
|
292
|
+
if (params?.usage) bivy({ type: "usage", usage: params.usage });
|
|
293
|
+
return;
|
|
294
|
+
case "turn/started":
|
|
295
|
+
bivy({ type: "session.status", status: "working" });
|
|
296
|
+
return;
|
|
297
|
+
case "turn/completed": {
|
|
298
|
+
bivy({ type: "session.status", status: "idle" });
|
|
299
|
+
if (sawError) { bivy({ type: "session.error", error: sawError }); sawError = null; }
|
|
300
|
+
else bivy({ type: "session.done" });
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
case "turn/failed":
|
|
304
|
+
case "error":
|
|
305
|
+
sawError = params?.error?.message || params?.message || "Codex turn failed";
|
|
306
|
+
return;
|
|
307
|
+
default:
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- bivy-agent-protocol input (our stdin) ----------------------------------
|
|
313
|
+
async function ensureInitialized() {
|
|
314
|
+
if (initialized) return;
|
|
315
|
+
await asRequest("initialize", { clientInfo: { name: "bivy", title: "Bivy", version: "0.1.0" }, capabilities: { experimentalApi: true } });
|
|
316
|
+
initialized = true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function threadParams(msg, extra = {}) {
|
|
320
|
+
return {
|
|
321
|
+
cwd: msg.workspace || process.cwd(),
|
|
322
|
+
approvalPolicy: APPROVAL_POLICY,
|
|
323
|
+
sandbox: SANDBOX_MODE,
|
|
324
|
+
...(selectedModel ? { model: selectedModel } : {}),
|
|
325
|
+
...extra,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function onBivyCommand(msg) {
|
|
330
|
+
const type = String(msg.type || "");
|
|
331
|
+
const id = msg.id;
|
|
332
|
+
try {
|
|
333
|
+
switch (type) {
|
|
334
|
+
case "hello.ack":
|
|
335
|
+
return;
|
|
336
|
+
case "session.create": {
|
|
337
|
+
await ensureInitialized();
|
|
338
|
+
// Back-compat for older ProtocolRuntime builds that carried resume as a
|
|
339
|
+
// field on session.create. New hosts use the explicit session.resume
|
|
340
|
+
// primitive below.
|
|
341
|
+
if (typeof msg.resume === "string" && msg.resume) {
|
|
342
|
+
const resumed = await asRequest("thread/resume", threadParams(msg, { threadId: msg.resume }));
|
|
343
|
+
threadId = resumed?.thread?.id ?? resumed?.threadId ?? msg.resume;
|
|
344
|
+
bivy({ replyTo: id, ok: true, runtimeSessionRef: threadId });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const started = await asRequest("thread/start", threadParams(msg));
|
|
348
|
+
threadId = started?.thread?.id ?? started?.threadId ?? null;
|
|
349
|
+
bivy({ replyTo: id, ok: true, runtimeSessionRef: threadId });
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
case "session.resume": {
|
|
353
|
+
await ensureInitialized();
|
|
354
|
+
const resumeRef = String(msg.runtimeSessionRef || msg.resumeRef || msg.sessionId || "");
|
|
355
|
+
if (!resumeRef) { bivy({ replyTo: id, ok: false, error: "missing resume ref" }); return; }
|
|
356
|
+
const resumed = await asRequest("thread/resume", threadParams(msg, { threadId: resumeRef }));
|
|
357
|
+
threadId = resumed?.thread?.id ?? resumed?.threadId ?? resumeRef;
|
|
358
|
+
bivy({ replyTo: id, ok: true, runtimeSessionRef: threadId });
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
case "chat.send": {
|
|
362
|
+
if (!threadId) { bivy({ replyTo: id, ok: false, error: "no codex thread" }); return; }
|
|
363
|
+
// Ack immediately. The turn's lifecycle — assistant deltas, per-tool
|
|
364
|
+
// approval cards, completion — is delivered through the streamed
|
|
365
|
+
// session.status / session.done / session.error events below, NOT through
|
|
366
|
+
// this reply. Deferring the ack until turn/completed made ProtocolRuntime's
|
|
367
|
+
// 30s command timeout (src/runtime/protocol.ts) fire on any turn that ran
|
|
368
|
+
// longer than 30s — which, for this approvals runtime, is essentially every
|
|
369
|
+
// turn, since it can't complete until the human taps Approve on each card.
|
|
370
|
+
// That rejected the prompt ("chat.send timed out"), stranded the session,
|
|
371
|
+
// and forced a fresh thread on the next message.
|
|
372
|
+
bivy({ replyTo: id, ok: true });
|
|
373
|
+
asRequest("turn/start", {
|
|
374
|
+
threadId,
|
|
375
|
+
input: [{ type: "text", text: String(msg.text ?? "") }],
|
|
376
|
+
...(selectedModel ? { model: selectedModel } : {}),
|
|
377
|
+
})
|
|
378
|
+
.catch((error) => bivy({ type: "session.error", error: error instanceof Error ? error.message : String(error) }));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
case "model.set": {
|
|
382
|
+
const model = String(msg.model || "").trim();
|
|
383
|
+
if (!model || !models.some((entry) => entry.id === model)) {
|
|
384
|
+
bivy({ replyTo: id, ok: false, error: "unknown Codex model: " + (model || "(empty)") });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (threadId) await asRequest("thread/settings/update", { threadId, model });
|
|
388
|
+
selectedModel = model;
|
|
389
|
+
bivy({ replyTo: id, ok: true, model });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
case "tool.decision": {
|
|
393
|
+
const reqId = approvalRequests.get(msg.toolCallId);
|
|
394
|
+
if (reqId !== undefined) {
|
|
395
|
+
approvalRequests.delete(msg.toolCallId);
|
|
396
|
+
const decision = msg.decision === "deny" ? "decline" : "accept";
|
|
397
|
+
asReply(reqId, { decision });
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
case "session.abort": {
|
|
402
|
+
if (threadId) await asRequest("turn/interrupt", { threadId }).catch(() => {});
|
|
403
|
+
if (id !== undefined) bivy({ replyTo: id, ok: true });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
default:
|
|
407
|
+
if (id !== undefined) bivy({ replyTo: id, ok: true });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (id !== undefined) bivy({ replyTo: id, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Announce capabilities; ProtocolRuntime finalizes them from this handshake.
|
|
416
|
+
// resume: true — thread/resume reconnects a prior thread by its rollout id
|
|
417
|
+
// (verified against codex-cli 0.144.1); the resume plumbing is Bivy-side.
|
|
418
|
+
async function announceHello() {
|
|
419
|
+
try {
|
|
420
|
+
await ensureInitialized();
|
|
421
|
+
let cursor = null;
|
|
422
|
+
const catalog = [];
|
|
423
|
+
do {
|
|
424
|
+
const result = await asRequest("model/list", cursor ? { cursor } : {});
|
|
425
|
+
if (Array.isArray(result?.data)) catalog.push(...result.data);
|
|
426
|
+
cursor = typeof result?.nextCursor === "string" && result.nextCursor ? result.nextCursor : null;
|
|
427
|
+
} while (cursor);
|
|
428
|
+
models = catalog.filter((model) => model && typeof model.id === "string" && !model.hidden).map((model) => ({
|
|
429
|
+
provider: "openai", id: model.id,
|
|
430
|
+
name: typeof model.displayName === "string" && model.displayName ? model.displayName : model.id,
|
|
431
|
+
reasoning: Array.isArray(model.supportedReasoningEfforts) && model.supportedReasoningEfforts.length > 0,
|
|
432
|
+
}));
|
|
433
|
+
selectedModel = catalog.find((model) => model?.isDefault && !model.hidden)?.id ?? models[0]?.id ?? null;
|
|
434
|
+
} catch (error) {
|
|
435
|
+
process.stderr.write("[codex-app-server] model discovery failed: " + String(error) + "\n");
|
|
436
|
+
}
|
|
437
|
+
bivy({ type: "hello", runtime: { models, ...(selectedModel ? { currentModel: selectedModel } : {}), capabilities: { toolInterception: true, modelSelection: models.length > 0, resume: true } } });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
void announceHello();
|
|
441
|
+
|
|
442
|
+
createInterface({ input: process.stdin }).on("line", (line) => {
|
|
443
|
+
if (!line.trim()) return;
|
|
444
|
+
let msg;
|
|
445
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
446
|
+
void onBivyCommand(msg);
|
|
447
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
3
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
4
|
+
/**
|
|
5
|
+
* pi-coding-agent 0.82.1 publishes an npm-shrinkwrap that pins the vulnerable
|
|
6
|
+
* brace-expansion 5.0.7 below its own node_modules. npm overrides update the
|
|
7
|
+
* outer lockfile/audit result but do not replace the shrinkwrapped files during
|
|
8
|
+
* installation. Replace that one nested package with our direct, exact 5.0.8
|
|
9
|
+
* dependency until pi publishes a corrected shrinkwrap.
|
|
10
|
+
*/
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
16
|
+
|
|
17
|
+
// npm-global installs usually nest dependencies under bivy, while npm/npx may
|
|
18
|
+
// hoist them to an ancestor node_modules. Support both layouts.
|
|
19
|
+
function findDependency(relativePath) {
|
|
20
|
+
let dir = root;
|
|
21
|
+
while (true) {
|
|
22
|
+
const candidate = path.join(dir, "node_modules", relativePath);
|
|
23
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
24
|
+
const parent = path.dirname(dir);
|
|
25
|
+
if (parent === dir) return undefined;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const source = findDependency("brace-expansion");
|
|
31
|
+
const piPackage = findDependency(path.join("@earendil-works", "pi-coding-agent"));
|
|
32
|
+
const target = piPackage && path.join(piPackage, "node_modules", "brace-expansion");
|
|
33
|
+
|
|
34
|
+
if (!target || !fs.existsSync(target)) process.exit(0);
|
|
35
|
+
if (!source) throw new Error("Security patch source brace-expansion@5.0.8 is missing");
|
|
36
|
+
|
|
37
|
+
const sourcePackage = JSON.parse(fs.readFileSync(path.join(source, "package.json"), "utf8"));
|
|
38
|
+
if (sourcePackage.version !== "5.0.8") {
|
|
39
|
+
throw new Error(`Refusing dependency patch from unexpected brace-expansion ${sourcePackage.version}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
43
|
+
fs.cpSync(source, target, { recursive: true });
|
|
44
|
+
console.log("Patched pi-coding-agent's nested brace-expansion to 5.0.8");
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Pure session-selection logic for `bivy prune --sessions`, extracted so it can
|
|
4
|
+
// be unit-tested without executing the CLI (bin/bivy.mjs runs main() on import).
|
|
5
|
+
//
|
|
6
|
+
// Background: since terminal-started agents were adopted (shim → Bivy PTY →
|
|
7
|
+
// "continue as chat"), the sessions the app lists come from `.bivy/metadata.json`
|
|
8
|
+
// (the durable, deletion-aware index that drives the sidebar) across every agent —
|
|
9
|
+
// not just Pi's `.bivy/pi/sessions`. Prune selects from that index; these helpers
|
|
10
|
+
// decide which entries are stale. No I/O here on purpose.
|
|
11
|
+
|
|
12
|
+
// Mirrors the server's isEmptyUntitledSummary: a session with no real title, no
|
|
13
|
+
// first message and no messages is the hidden "Untitled" noise the sidebar filters
|
|
14
|
+
// out. Keep-N protects only *real* sessions; empties are always eligible.
|
|
15
|
+
export function isUntitledSessionName(name) {
|
|
16
|
+
const title = String(name ?? "").trim();
|
|
17
|
+
return !title || /^untitled session$/i.test(title);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isEmptySession(s) {
|
|
21
|
+
return isUntitledSessionName(s.name) && !String(s.firstMessage ?? "").trim() && Number(s.messageCount ?? 0) <= 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Last-activity time in ms, most reliable field first.
|
|
25
|
+
export function sessionActivityMs(s) {
|
|
26
|
+
for (const v of [s.lastActivityAt, s.updatedAt, s.modified, s.createdAt]) {
|
|
27
|
+
const n = new Date(v).getTime();
|
|
28
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Removal set for sessions. Live ("working") sessions are never touched. The
|
|
34
|
+
// newest `keep` NON-EMPTY sessions (across every agent) survive; empty/untitled
|
|
35
|
+
// sessions are always beyond the keep window. An item is removed only when it is
|
|
36
|
+
// BOTH beyond the keep window AND older than `ageMs` — the same safe intersection
|
|
37
|
+
// as the file-based selectStale, so keep-only and age-only both behave sensibly.
|
|
38
|
+
// `keep`/`ageMs` of null disable that half of the test.
|
|
39
|
+
export function selectStaleSessions(sessions, keep, ageMs, now) {
|
|
40
|
+
const eligible = sessions.filter((s) => s.status !== "working");
|
|
41
|
+
const nonEmptyDesc = eligible
|
|
42
|
+
.filter((s) => !isEmptySession(s))
|
|
43
|
+
.sort((a, b) => sessionActivityMs(b) - sessionActivityMs(a));
|
|
44
|
+
const rank = new Map(nonEmptyDesc.map((s, i) => [s.id, i]));
|
|
45
|
+
return eligible
|
|
46
|
+
.filter((s) => {
|
|
47
|
+
const beyondKeep = keep === null ? true : (isEmptySession(s) ? true : (rank.get(s.id) ?? Infinity) >= keep);
|
|
48
|
+
const olderThan = ageMs === null ? true : now - sessionActivityMs(s) >= ageMs;
|
|
49
|
+
return beyondKeep && olderThan;
|
|
50
|
+
})
|
|
51
|
+
.sort((a, b) => sessionActivityMs(b) - sessionActivityMs(a));
|
|
52
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Pure list-sizing logic for `bivy sessions` / `bivy ls`, extracted so it can be
|
|
4
|
+
// unit-tested without executing the CLI (bin/bivy.mjs runs main() on import).
|
|
5
|
+
//
|
|
6
|
+
// Background (#71): sessions are never "active" vs "inactive" in storage — the
|
|
7
|
+
// server's /api/sessions already returns the full durable history, sorted most
|
|
8
|
+
// recent first (see listAllSessions in src/server.ts). `bivy sessions` used to
|
|
9
|
+
// hard-truncate that list to the newest 15 by default, which made older,
|
|
10
|
+
// perfectly resumable sessions invisible and unselectable-by-index. The default
|
|
11
|
+
// is now "no cap" (Infinity); --limit/--n remains an explicit opt-in to shorten
|
|
12
|
+
// the list.
|
|
13
|
+
|
|
14
|
+
// Resolve the --limit/--n value (the raw string from argValue, or "" / undefined
|
|
15
|
+
// when the flag wasn't passed) into the number of saved sessions to show.
|
|
16
|
+
// Anything that isn't a positive number means "no limit."
|
|
17
|
+
export function resolveSessionsLimit(rawArg) {
|
|
18
|
+
const n = Number(rawArg);
|
|
19
|
+
return n > 0 ? Math.floor(n) : Infinity;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Apply the resolved limit to the (already most-recent-first) saved-sessions
|
|
23
|
+
// list. A plain wrapper over slice, kept alongside resolveSessionsLimit so the
|
|
24
|
+
// pairing is covered by one test file.
|
|
25
|
+
export function truncateSavedSessions(sessions, limit) {
|
|
26
|
+
return sessions.slice(0, limit);
|
|
27
|
+
}
|