@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,630 @@
|
|
|
1
|
+
// SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { EventEmitter } from "node:events";
|
|
6
|
+
import { buildAgentCredentialEnv } from "./credentials.js";
|
|
7
|
+
import { extractTokenUsage } from "./cli-parsers.js";
|
|
8
|
+
/** A protocol `usage` message → UsageSnapshot (reuses the CLI token-key scan). */
|
|
9
|
+
function parseProtocolUsage(raw) {
|
|
10
|
+
if (!raw || typeof raw !== "object")
|
|
11
|
+
return undefined;
|
|
12
|
+
const o = raw;
|
|
13
|
+
const tokenUsage = extractTokenUsage(o.tokens ?? o);
|
|
14
|
+
const snapshot = tokenUsage ? { ...tokenUsage } : {};
|
|
15
|
+
if (typeof o.costUsd === "number")
|
|
16
|
+
snapshot.costUsd = o.costUsd;
|
|
17
|
+
return Object.keys(snapshot).length ? snapshot : undefined;
|
|
18
|
+
}
|
|
19
|
+
function splitArgs(value) {
|
|
20
|
+
if (!value?.trim())
|
|
21
|
+
return [];
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(value);
|
|
24
|
+
if (Array.isArray(parsed))
|
|
25
|
+
return parsed.map(String);
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
const out = [];
|
|
29
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = re.exec(value)))
|
|
32
|
+
out.push(match[1] ?? match[2] ?? match[3] ?? "");
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
export function protocolRuntimeFromEnv() {
|
|
36
|
+
const command = process.env.BIVY_PROTOCOL_COMMAND?.trim();
|
|
37
|
+
if (!command)
|
|
38
|
+
return null;
|
|
39
|
+
const commands = protocolCommandsFromEnv();
|
|
40
|
+
return {
|
|
41
|
+
command,
|
|
42
|
+
args: splitArgs(process.env.BIVY_PROTOCOL_ARGS),
|
|
43
|
+
displayName: process.env.BIVY_PROTOCOL_NAME?.trim() || "Bivy Protocol Agent",
|
|
44
|
+
// Seed advertised slash commands so the composer can offer them before the
|
|
45
|
+
// first session's hello lands (the constructor merges these into the live
|
|
46
|
+
// capabilities, and a hello that omits `commands` preserves the seed).
|
|
47
|
+
...(commands ? { capabilities: { commands } } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Agent-native slash commands a protocol shim declares up front via
|
|
52
|
+
* `BIVY_PROTOCOL_COMMANDS` — a JSON array of `{ name, description }`, e.g.
|
|
53
|
+
* `[{"name":"/compact","description":"Compact the conversation."}]`. Seeding
|
|
54
|
+
* them (rather than only advertising via the hello) lets the composer offer them
|
|
55
|
+
* in autocomplete before any session exists, and keeps the catalog RuntimeInfo
|
|
56
|
+
* and the live runtime in agreement. Malformed entries are dropped; returns
|
|
57
|
+
* undefined when nothing valid is set.
|
|
58
|
+
*/
|
|
59
|
+
export function protocolCommandsFromEnv() {
|
|
60
|
+
const raw = process.env.BIVY_PROTOCOL_COMMANDS?.trim();
|
|
61
|
+
if (!raw)
|
|
62
|
+
return undefined;
|
|
63
|
+
try {
|
|
64
|
+
return parseAgentCommands(JSON.parse(raw));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Validate a `streamingBehaviors` array from a hello (e.g.
|
|
72
|
+
* `["steer","followUp"]`). A shim must explicitly opt in before the client
|
|
73
|
+
* will ever attempt a mid-turn prompt against it — see RuntimeCapabilities.
|
|
74
|
+
* streamingBehaviors — so anything malformed/absent is dropped rather than
|
|
75
|
+
* defaulted to some assumed support.
|
|
76
|
+
*/
|
|
77
|
+
function parseStreamingBehaviors(raw) {
|
|
78
|
+
if (!Array.isArray(raw))
|
|
79
|
+
return undefined;
|
|
80
|
+
const out = Array.from(new Set(raw.filter((v) => v === "steer" || v === "followUp")));
|
|
81
|
+
return out.length ? out : undefined;
|
|
82
|
+
}
|
|
83
|
+
function capabilitiesFromHello(raw) {
|
|
84
|
+
const c = raw && typeof raw === "object" ? raw : {};
|
|
85
|
+
const streamingBehaviors = parseStreamingBehaviors(c.streamingBehaviors);
|
|
86
|
+
return {
|
|
87
|
+
toolInterception: c.toolInterception === true,
|
|
88
|
+
modelSelection: c.modelSelection === true,
|
|
89
|
+
packages: false,
|
|
90
|
+
resume: c.resume === true,
|
|
91
|
+
fork: false,
|
|
92
|
+
commands: parseAgentCommands(c.commands),
|
|
93
|
+
...(streamingBehaviors ? { streamingBehaviors } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Validate the `commands` array from a hello. Each entry must carry a "/name"
|
|
98
|
+
* string; anything malformed is dropped so a sloppy shim can't inject junk into
|
|
99
|
+
* the composer menu. Returns undefined when nothing valid is advertised, keeping
|
|
100
|
+
* the capability absent rather than an empty array.
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* Validate a `models` array advertised in a shim's hello (`hello.runtime.models`).
|
|
104
|
+
* Each entry needs a string `id` (the agent's own model name); `name`/`provider`
|
|
105
|
+
* are optional. Anything malformed is dropped so a sloppy shim can't inject junk
|
|
106
|
+
* into the picker. Returns [] when nothing valid is advertised.
|
|
107
|
+
*/
|
|
108
|
+
export function parseModels(raw) {
|
|
109
|
+
if (!Array.isArray(raw))
|
|
110
|
+
return [];
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const entry of raw) {
|
|
113
|
+
const e = entry && typeof entry === "object" ? entry : {};
|
|
114
|
+
const id = typeof e.id === "string" ? e.id.trim() : "";
|
|
115
|
+
if (!id)
|
|
116
|
+
continue;
|
|
117
|
+
out.push({
|
|
118
|
+
provider: typeof e.provider === "string" && e.provider.trim() ? e.provider : "agent",
|
|
119
|
+
id,
|
|
120
|
+
name: typeof e.name === "string" && e.name.trim() ? e.name : id,
|
|
121
|
+
reasoning: e.reasoning === true || undefined,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
export function parseAgentCommands(raw) {
|
|
127
|
+
if (!Array.isArray(raw))
|
|
128
|
+
return undefined;
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const entry of raw) {
|
|
131
|
+
const e = entry && typeof entry === "object" ? entry : {};
|
|
132
|
+
const name = typeof e.name === "string" ? e.name.trim() : "";
|
|
133
|
+
if (!name.startsWith("/") || name.length < 2)
|
|
134
|
+
continue;
|
|
135
|
+
const description = typeof e.description === "string" ? e.description.trim() : undefined;
|
|
136
|
+
// Only honor the two known invocation modes; anything else (including a
|
|
137
|
+
// sloppy shim advertising junk) falls back to prompt invocation, which every
|
|
138
|
+
// agent can serve. "protocol" is honored because ProtocolSession backs it
|
|
139
|
+
// with a real `command.invoke` message.
|
|
140
|
+
const mode = e.mode === "protocol" ? "protocol" : e.mode === "prompt" ? "prompt" : undefined;
|
|
141
|
+
const command = { name };
|
|
142
|
+
if (description)
|
|
143
|
+
command.description = description;
|
|
144
|
+
if (mode)
|
|
145
|
+
command.mode = mode;
|
|
146
|
+
out.push(command);
|
|
147
|
+
}
|
|
148
|
+
return out.length ? out : undefined;
|
|
149
|
+
}
|
|
150
|
+
class ProtocolSession {
|
|
151
|
+
runtimeOptions;
|
|
152
|
+
cwd;
|
|
153
|
+
capabilitiesRef;
|
|
154
|
+
toolInterceptor;
|
|
155
|
+
id;
|
|
156
|
+
child;
|
|
157
|
+
buffer = "";
|
|
158
|
+
pending = new Map();
|
|
159
|
+
emitter = new EventEmitter();
|
|
160
|
+
messages = [];
|
|
161
|
+
streaming = false;
|
|
162
|
+
name;
|
|
163
|
+
runtimeSessionRef;
|
|
164
|
+
resumeRef;
|
|
165
|
+
started = false;
|
|
166
|
+
assistantText = "";
|
|
167
|
+
reasoningText = "";
|
|
168
|
+
stderrOutput = "";
|
|
169
|
+
lastUsage;
|
|
170
|
+
// Accumulate the current turn's tool calls/results so getMessages() keeps them
|
|
171
|
+
// in history — re-opening a session then shows what the agent actually did, not
|
|
172
|
+
// just its final text. Cleared at the start/end of each turn.
|
|
173
|
+
turnToolUses = [];
|
|
174
|
+
turnToolResults = [];
|
|
175
|
+
constructor(runtimeOptions, cwd, capabilitiesRef, toolInterceptor,
|
|
176
|
+
// When resuming, the agent's own session ref (e.g. a Codex thread id). Passed
|
|
177
|
+
// back to the shim via the generic session.resume primitive so it reconnects
|
|
178
|
+
// instead of starting fresh; also used to preload history.
|
|
179
|
+
resumeRef) {
|
|
180
|
+
this.runtimeOptions = runtimeOptions;
|
|
181
|
+
this.cwd = cwd;
|
|
182
|
+
this.capabilitiesRef = capabilitiesRef;
|
|
183
|
+
this.toolInterceptor = toolInterceptor;
|
|
184
|
+
this.id = resumeRef || randomUUID();
|
|
185
|
+
this.resumeRef = resumeRef;
|
|
186
|
+
if (resumeRef) {
|
|
187
|
+
this.runtimeSessionRef = resumeRef;
|
|
188
|
+
if (runtimeOptions.loadHistory) {
|
|
189
|
+
try {
|
|
190
|
+
this.messages = runtimeOptions.loadHistory(resumeRef);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Best-effort preload; a resumed session can start blank if the
|
|
194
|
+
// transcript can't be read, and the agent still continues from its ref.
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// The resume token the daemon persists and passes to openSession() later: the
|
|
200
|
+
// agent's own session ref. undefined until session.create replies for a fresh
|
|
201
|
+
// session.
|
|
202
|
+
get sessionFile() { return this.runtimeSessionRef ?? this.resumeRef; }
|
|
203
|
+
get isStreaming() { return this.streaming; }
|
|
204
|
+
/** PID of the live agent subprocess (see RuntimeSession). */
|
|
205
|
+
activePid() { return this.child?.pid; }
|
|
206
|
+
getMessages() { return this.messages; }
|
|
207
|
+
// Models advertised by the shim's hello (hello.runtime.models); empty when the
|
|
208
|
+
// agent doesn't expose a picker. setModel forwards the choice as a `model.set`
|
|
209
|
+
// command the shim answers, so selection is real transport, not a stub.
|
|
210
|
+
models = [];
|
|
211
|
+
currentModelId;
|
|
212
|
+
/** Provider of the selected model — scopes custom base-URL env injection. */
|
|
213
|
+
currentModelProvider;
|
|
214
|
+
getModels() { return this.models; }
|
|
215
|
+
getCurrentModel() {
|
|
216
|
+
if (!this.currentModelId)
|
|
217
|
+
return undefined;
|
|
218
|
+
return this.models.find((m) => m.id === this.currentModelId) ?? { provider: "agent", id: this.currentModelId, name: this.currentModelId };
|
|
219
|
+
}
|
|
220
|
+
async setModel(provider, id) {
|
|
221
|
+
if (!this.models.length)
|
|
222
|
+
throw new Error("Model selection is not supported by this protocol agent.");
|
|
223
|
+
const modelId = id.trim();
|
|
224
|
+
if (!modelId) {
|
|
225
|
+
this.currentModelId = undefined;
|
|
226
|
+
this.currentModelProvider = undefined;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Forward to the shim and only commit the selection once it acknowledges.
|
|
230
|
+
await this.command("model.set", { sessionId: this.id, model: modelId });
|
|
231
|
+
this.currentModelId = modelId;
|
|
232
|
+
this.currentModelProvider = provider?.trim().toLowerCase() || undefined;
|
|
233
|
+
}
|
|
234
|
+
async getUsage() { return this.lastUsage; }
|
|
235
|
+
/**
|
|
236
|
+
* Invoke a protocol-mode agent command out-of-band (see AgentCommand.mode).
|
|
237
|
+
* Ensures the child + session are up, then sends a `command.invoke` the shim
|
|
238
|
+
* answers. Any streamed output/events the command produces arrive over the
|
|
239
|
+
* normal event channel (session.status / message.delta / session.done), so a
|
|
240
|
+
* command that "runs a turn" (e.g. `/compact`) surfaces exactly like a prompt.
|
|
241
|
+
*/
|
|
242
|
+
async invokeCommand(name, args) {
|
|
243
|
+
await this.open();
|
|
244
|
+
await this.command("command.invoke", { sessionId: this.id, runtimeSessionRef: this.runtimeSessionRef, name, args: args ?? "" });
|
|
245
|
+
}
|
|
246
|
+
getName() { return this.name; }
|
|
247
|
+
setName(name) { this.name = name; }
|
|
248
|
+
async suggestName(firstPrompt) {
|
|
249
|
+
return this.runtimeOptions.suggestName?.(firstPrompt, { cwd: this.cwd, model: this.currentModelId });
|
|
250
|
+
}
|
|
251
|
+
subscribe(listener) { this.emitter.on("event", listener); return () => this.emitter.off("event", listener); }
|
|
252
|
+
emit(event) { this.emitter.emit("event", event); }
|
|
253
|
+
async start() {
|
|
254
|
+
if (this.child)
|
|
255
|
+
return;
|
|
256
|
+
const credentialEnv = this.runtimeOptions.credentials
|
|
257
|
+
? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
|
|
258
|
+
: {};
|
|
259
|
+
const child = spawn(this.runtimeOptions.command, this.runtimeOptions.args ?? [], {
|
|
260
|
+
cwd: this.cwd,
|
|
261
|
+
env: { ...process.env, ...this.runtimeOptions.env, ...credentialEnv },
|
|
262
|
+
stdio: "pipe",
|
|
263
|
+
});
|
|
264
|
+
this.child = child;
|
|
265
|
+
child.stdout.on("data", (chunk) => this.onData(chunk.toString("utf8")));
|
|
266
|
+
child.stderr.on("data", (chunk) => {
|
|
267
|
+
this.stderrOutput += chunk.toString("utf8");
|
|
268
|
+
this.emit({ type: "tool_execution_update", toolName: "agent_output", toolCallId: "agent-output", input: { stream: "stderr", output: this.stderrOutput.slice(-4000) } });
|
|
269
|
+
});
|
|
270
|
+
// The agent's stdin pipe can break (EPIPE) when the shim exits mid-turn — for
|
|
271
|
+
// example a dispose()/abort() racing an in-flight write (a tool.decision reply,
|
|
272
|
+
// a chat.send). Node emits that as an 'error' on the stdin socket; with no
|
|
273
|
+
// listener it is fatal to the whole daemon. Absorb it: the child is already
|
|
274
|
+
// gone, so mark the turn stopped and fail any pending commands instead of
|
|
275
|
+
// crashing the process over a normal teardown race.
|
|
276
|
+
child.stdin.on("error", (error) => { this.streaming = false; this.failAll(error instanceof Error ? error : new Error(String(error))); });
|
|
277
|
+
child.on("error", (error) => this.failAll(error));
|
|
278
|
+
child.on("close", (code, signal) => {
|
|
279
|
+
this.streaming = false;
|
|
280
|
+
this.failAll(new Error(`Protocol agent exited (${code ?? signal ?? "unknown"})`));
|
|
281
|
+
this.emit({ type: "agent_end", code, signal });
|
|
282
|
+
});
|
|
283
|
+
await new Promise((resolve, reject) => {
|
|
284
|
+
const timer = setTimeout(() => reject(new Error("Protocol agent did not send hello.")), 10_000);
|
|
285
|
+
const onHello = (msg) => {
|
|
286
|
+
if (msg.type !== "hello")
|
|
287
|
+
return;
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
this.emitter.off("protocol-message", onHello);
|
|
290
|
+
const runtime = msg.runtime;
|
|
291
|
+
const next = capabilitiesFromHello(runtime?.capabilities ?? msg.capabilities);
|
|
292
|
+
// A hello that advertises no commands must not wipe commands seeded via
|
|
293
|
+
// ProtocolRuntimeOptions.capabilities (a shim may declare them up front).
|
|
294
|
+
if (next.commands === undefined)
|
|
295
|
+
delete next.commands;
|
|
296
|
+
Object.assign(this.capabilitiesRef, next);
|
|
297
|
+
// Model registry: a shim that advertises models gets a real picker —
|
|
298
|
+
// getModels() returns these and setModel() forwards a model.set command.
|
|
299
|
+
const models = parseModels(runtime?.models ?? msg.models);
|
|
300
|
+
if (models.length) {
|
|
301
|
+
this.models = models;
|
|
302
|
+
this.capabilitiesRef.modelSelection = true;
|
|
303
|
+
const current = typeof runtime?.currentModel === "string" ? runtime.currentModel : typeof msg.currentModel === "string" ? msg.currentModel : undefined;
|
|
304
|
+
if (current)
|
|
305
|
+
this.currentModelId = current;
|
|
306
|
+
}
|
|
307
|
+
// A runtime configured `resumable` keeps resume support even if a shim's
|
|
308
|
+
// hello omits the flag — the resume plumbing (session.create `resume` +
|
|
309
|
+
// openSession) lives on the Bivy side, so the option is authoritative.
|
|
310
|
+
if (this.runtimeOptions.resumable)
|
|
311
|
+
this.capabilitiesRef.resume = true;
|
|
312
|
+
this.write({ id: "cmd_hello_ack", type: "hello.ack", maxProtocol: "bivy-agent-protocol/0" });
|
|
313
|
+
resolve();
|
|
314
|
+
};
|
|
315
|
+
this.emitter.on("protocol-message", onHello);
|
|
316
|
+
child.once("error", reject);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
onData(data) {
|
|
320
|
+
this.buffer += data;
|
|
321
|
+
for (;;) {
|
|
322
|
+
const idx = this.buffer.indexOf("\n");
|
|
323
|
+
if (idx < 0)
|
|
324
|
+
break;
|
|
325
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
326
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
327
|
+
if (!line)
|
|
328
|
+
continue;
|
|
329
|
+
let msg;
|
|
330
|
+
try {
|
|
331
|
+
msg = JSON.parse(line);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
this.emit({ type: "runtime.debug", message: `Invalid protocol JSON: ${line.slice(0, 200)}` });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
this.handleMessage(msg);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
handleMessage(msg) {
|
|
341
|
+
this.emitter.emit("protocol-message", msg);
|
|
342
|
+
const replyTo = typeof msg.replyTo === "string" ? msg.replyTo : "";
|
|
343
|
+
if (replyTo && this.pending.has(replyTo)) {
|
|
344
|
+
const pending = this.pending.get(replyTo);
|
|
345
|
+
clearTimeout(pending.timer);
|
|
346
|
+
this.pending.delete(replyTo);
|
|
347
|
+
if (msg.ok === false)
|
|
348
|
+
pending.reject(new Error(String(msg.error || "Protocol command failed")));
|
|
349
|
+
else
|
|
350
|
+
pending.resolve(msg);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
void this.handleEvent(msg);
|
|
354
|
+
}
|
|
355
|
+
async handleEvent(msg) {
|
|
356
|
+
const type = String(msg.type || "runtime.event");
|
|
357
|
+
if (type === "session.started") {
|
|
358
|
+
if (typeof msg.runtimeSessionRef === "string")
|
|
359
|
+
this.runtimeSessionRef = msg.runtimeSessionRef;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (type === "message.delta") {
|
|
363
|
+
const text = String(msg.text ?? "");
|
|
364
|
+
if (!this.assistantText)
|
|
365
|
+
this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
|
|
366
|
+
this.assistantText += text;
|
|
367
|
+
this.emit({ type: "message_update", message: { role: "assistant", content: this.assistantText } });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (type === "message.reasoning" || type === "reasoning.delta") {
|
|
371
|
+
// Reasoning/thinking stream → the same intermediate thinking block the
|
|
372
|
+
// daemon renders for Pi/Claude (display-only, kept out of the transcript).
|
|
373
|
+
const text = String(msg.text ?? msg.delta ?? "");
|
|
374
|
+
if (text) {
|
|
375
|
+
this.reasoningText += text;
|
|
376
|
+
this.emit({ type: "message_update", message: { role: "assistant", content: [{ type: "thinking", thinking: this.reasoningText }] } });
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (type === "usage") {
|
|
381
|
+
// Best-effort token/cost snapshot the shim reports; surfaced via getUsage().
|
|
382
|
+
this.lastUsage = parseProtocolUsage(msg.usage ?? msg);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (type === "session.status") {
|
|
386
|
+
const status = String(msg.status || "");
|
|
387
|
+
if (status === "working")
|
|
388
|
+
this.emit({ type: "turn_start" });
|
|
389
|
+
if (status === "idle")
|
|
390
|
+
this.emit({ type: "turn_end" });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (type === "session.done") {
|
|
394
|
+
const message = { role: "assistant", content: this.assistantText };
|
|
395
|
+
// Persist the assistant turn. When the turn used tools, store content blocks
|
|
396
|
+
// (text + tool_use) plus a trailing user message carrying the tool_result
|
|
397
|
+
// blocks, matched by tool_use_id — the same shape the PWA renders from live
|
|
398
|
+
// streaming, so a re-opened transcript looks identical to what was on screen.
|
|
399
|
+
// A tool-free turn keeps the plain-text form it always used.
|
|
400
|
+
if (this.turnToolUses.length || this.turnToolResults.length) {
|
|
401
|
+
const assistantContent = [];
|
|
402
|
+
if (this.assistantText)
|
|
403
|
+
assistantContent.push({ type: "text", text: this.assistantText });
|
|
404
|
+
assistantContent.push(...this.turnToolUses);
|
|
405
|
+
if (assistantContent.length)
|
|
406
|
+
this.messages.push({ role: "assistant", content: assistantContent, timestamp: Date.now() });
|
|
407
|
+
if (this.turnToolResults.length)
|
|
408
|
+
this.messages.push({ role: "user", content: this.turnToolResults, timestamp: Date.now() });
|
|
409
|
+
}
|
|
410
|
+
else if (this.assistantText) {
|
|
411
|
+
this.messages.push(message);
|
|
412
|
+
}
|
|
413
|
+
this.emit({ type: "message_end", message });
|
|
414
|
+
this.streaming = false;
|
|
415
|
+
this.assistantText = "";
|
|
416
|
+
this.reasoningText = "";
|
|
417
|
+
this.turnToolUses = [];
|
|
418
|
+
this.turnToolResults = [];
|
|
419
|
+
this.emit({ type: "agent_end" });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (type === "session.error") {
|
|
423
|
+
this.streaming = false;
|
|
424
|
+
this.reasoningText = "";
|
|
425
|
+
this.turnToolUses = [];
|
|
426
|
+
this.turnToolResults = [];
|
|
427
|
+
this.emit({ type: "session.error", error: String(msg.error || "Protocol agent error") });
|
|
428
|
+
this.emit({ type: "agent_end" });
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (type === "tool.call") {
|
|
432
|
+
this.turnToolUses.push({
|
|
433
|
+
type: "tool_use",
|
|
434
|
+
id: String(msg.toolCallId || msg.id || ""),
|
|
435
|
+
name: String(msg.name || "tool"),
|
|
436
|
+
input: msg.input ?? {},
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
if (type === "tool.call" && this.capabilitiesRef.toolInterception && this.toolInterceptor) {
|
|
440
|
+
const toolCallId = String(msg.toolCallId || "");
|
|
441
|
+
const toolName = String(msg.name || "tool");
|
|
442
|
+
this.emit({ type: "tool_call", toolName, input: msg.input, toolCallId });
|
|
443
|
+
const decision = await this.toolInterceptor({ sessionId: this.id, toolName, input: msg.input });
|
|
444
|
+
try {
|
|
445
|
+
this.write({ id: randomUUID(), type: "tool.decision", sessionId: this.id, toolCallId, decision: decision?.block ? "deny" : "allow", reason: decision?.reason });
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
// The child exited before we could answer (aborted/disposed mid-turn).
|
|
449
|
+
// There is nowhere to deliver the decision; drop it rather than throw out
|
|
450
|
+
// of this async event handler (which would surface as an unhandled rejection).
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (type === "tool.result") {
|
|
455
|
+
const result = msg.result ?? msg.output ?? msg.content ?? msg.text ?? msg.summary ?? "";
|
|
456
|
+
this.turnToolResults.push({
|
|
457
|
+
type: "tool_result",
|
|
458
|
+
tool_use_id: String(msg.toolCallId || msg.tool_use_id || msg.id || ""),
|
|
459
|
+
content: result,
|
|
460
|
+
});
|
|
461
|
+
this.emit({ type: "tool_result", toolName: String(msg.name || "tool"), toolCallId: String(msg.toolCallId || msg.tool_use_id || msg.id || ""), result });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
this.emit({ type, ...msg });
|
|
465
|
+
}
|
|
466
|
+
write(obj) {
|
|
467
|
+
if (!this.child || this.child.killed)
|
|
468
|
+
throw new Error("Protocol agent is not running.");
|
|
469
|
+
this.child.stdin.write(`${JSON.stringify(obj)}\n`);
|
|
470
|
+
}
|
|
471
|
+
command(type, payload, timeoutMs = 30_000) {
|
|
472
|
+
const id = randomUUID();
|
|
473
|
+
const out = { id, type, ...payload };
|
|
474
|
+
const promise = new Promise((resolve, reject) => {
|
|
475
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`${type} timed out`)); }, timeoutMs);
|
|
476
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
477
|
+
});
|
|
478
|
+
this.write(out);
|
|
479
|
+
return promise;
|
|
480
|
+
}
|
|
481
|
+
failAll(error) {
|
|
482
|
+
for (const [id, pending] of this.pending) {
|
|
483
|
+
clearTimeout(pending.timer);
|
|
484
|
+
pending.reject(error);
|
|
485
|
+
this.pending.delete(id);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
async open() {
|
|
489
|
+
await this.start();
|
|
490
|
+
if (this.started)
|
|
491
|
+
return;
|
|
492
|
+
const created = this.resumeRef
|
|
493
|
+
? await this.command("session.resume", { workspace: this.cwd, sessionId: this.id, runtimeSessionRef: this.resumeRef, resumeRef: this.resumeRef })
|
|
494
|
+
: await this.command("session.create", { workspace: this.cwd, sessionId: this.id });
|
|
495
|
+
if (typeof created.runtimeSessionRef === "string")
|
|
496
|
+
this.runtimeSessionRef = created.runtimeSessionRef;
|
|
497
|
+
this.started = true;
|
|
498
|
+
}
|
|
499
|
+
async prompt(text, options) {
|
|
500
|
+
const wasStarted = this.started;
|
|
501
|
+
await this.open();
|
|
502
|
+
if (!wasStarted)
|
|
503
|
+
this.emit({ type: "agent_start" });
|
|
504
|
+
const prompt = text.trim();
|
|
505
|
+
// Multimodal input: the daemon hands image attachments through PromptOptions
|
|
506
|
+
// (the same ones native Claude receives). Forward them so a protocol agent
|
|
507
|
+
// can act on images too, instead of them being silently dropped here. A
|
|
508
|
+
// shim that can't see images just ignores the field. An images-only prompt
|
|
509
|
+
// (empty text) is still a real turn, so don't bail when only images arrive.
|
|
510
|
+
const images = (options?.images ?? []).map((img) => ({ type: "image", data: img.data, mimeType: img.mimeType }));
|
|
511
|
+
if (!prompt && !images.length)
|
|
512
|
+
return;
|
|
513
|
+
this.messages.push({ role: "user", content: prompt, timestamp: Date.now() });
|
|
514
|
+
this.streaming = true;
|
|
515
|
+
this.assistantText = "";
|
|
516
|
+
this.reasoningText = "";
|
|
517
|
+
this.stderrOutput = "";
|
|
518
|
+
this.turnToolUses = [];
|
|
519
|
+
this.turnToolResults = [];
|
|
520
|
+
await this.command("chat.send", {
|
|
521
|
+
sessionId: this.id,
|
|
522
|
+
runtimeSessionRef: this.runtimeSessionRef,
|
|
523
|
+
text: prompt,
|
|
524
|
+
// Optional multimodal + streaming hints. Present only when the caller
|
|
525
|
+
// supplied them, so a text-only turn keeps the exact payload it always had.
|
|
526
|
+
...(images.length ? { images } : {}),
|
|
527
|
+
...(options?.streamingBehavior ? { streamingBehavior: options.streamingBehavior } : {}),
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async abort() {
|
|
531
|
+
if (!this.child)
|
|
532
|
+
return;
|
|
533
|
+
if (this.started)
|
|
534
|
+
await this.command("session.abort", { sessionId: this.id }, 5_000).catch(() => undefined);
|
|
535
|
+
this.child.kill("SIGTERM");
|
|
536
|
+
}
|
|
537
|
+
dispose() {
|
|
538
|
+
void this.abort();
|
|
539
|
+
this.emitter.removeAllListeners();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
export class ProtocolRuntime {
|
|
543
|
+
options;
|
|
544
|
+
id;
|
|
545
|
+
displayName;
|
|
546
|
+
capabilities = { toolInterception: false, modelSelection: false, packages: false, resume: false, fork: false };
|
|
547
|
+
sessions = [];
|
|
548
|
+
constructor(options) {
|
|
549
|
+
this.options = options;
|
|
550
|
+
this.id = options.id || "bivy-agent-protocol";
|
|
551
|
+
this.displayName = options.displayName || "Bivy Protocol Agent";
|
|
552
|
+
// A resumable runtime advertises resume even before the handshake, so the UI
|
|
553
|
+
// and takeover treat it as resumable up front (the ProcessRuntime convention).
|
|
554
|
+
if (options.resumable)
|
|
555
|
+
this.capabilities.resume = true;
|
|
556
|
+
if (options.capabilities)
|
|
557
|
+
Object.assign(this.capabilities, options.capabilities);
|
|
558
|
+
}
|
|
559
|
+
listCatalog() {
|
|
560
|
+
return this.options.catalog ?? [];
|
|
561
|
+
}
|
|
562
|
+
async createSession(options) {
|
|
563
|
+
const session = new ProtocolSession(this.options, options.workspace, this.capabilities, options.toolInterceptor);
|
|
564
|
+
await session.start();
|
|
565
|
+
this.sessions.push(session);
|
|
566
|
+
return { session };
|
|
567
|
+
}
|
|
568
|
+
async openSession(options) {
|
|
569
|
+
const session = new ProtocolSession(this.options, options.workspace, this.capabilities, options.toolInterceptor, options.sessionFile);
|
|
570
|
+
await session.start();
|
|
571
|
+
if (!this.options.resumable && !this.capabilities.resume) {
|
|
572
|
+
session.dispose();
|
|
573
|
+
throw new Error(`${this.displayName} does not support resume.`);
|
|
574
|
+
}
|
|
575
|
+
this.sessions.push(session);
|
|
576
|
+
return { session };
|
|
577
|
+
}
|
|
578
|
+
// Render a resumed session's prior turns without a live child (e.g. the daemon
|
|
579
|
+
// hydrating history on reopen), when the runtime knows how to read them.
|
|
580
|
+
readMessages(sessionFile) {
|
|
581
|
+
return this.options.loadHistory?.(sessionFile);
|
|
582
|
+
}
|
|
583
|
+
/** See ProtocolRuntimeOptions.discoverNativeSessions (issue #156). */
|
|
584
|
+
async discoverNativeSessions() {
|
|
585
|
+
try {
|
|
586
|
+
return (await this.options.discoverNativeSessions?.()) ?? [];
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
return [];
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async listSessions() {
|
|
593
|
+
return this.sessions.map((session) => ({ id: session.id, path: session.sessionFile, cwd: session.cwd, name: session.getName(), messageCount: session.getMessages().length }));
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Forget a session on a user-initiated delete: drop the in-memory handle so
|
|
597
|
+
* listSessions stops returning it, and — for agents that persist transcripts in
|
|
598
|
+
* their own store (Codex's rollout, reached via `deleteHistory`) — remove that
|
|
599
|
+
* on-disk copy. Matches on the session id or its persisted resume ref. Returns
|
|
600
|
+
* true if anything was removed.
|
|
601
|
+
*/
|
|
602
|
+
async deleteSession(sessionId, sessionFile) {
|
|
603
|
+
let removed = false;
|
|
604
|
+
for (let i = this.sessions.length - 1; i >= 0; i--) {
|
|
605
|
+
const s = this.sessions[i];
|
|
606
|
+
if (s.id === sessionId || (sessionFile && s.sessionFile === sessionFile)) {
|
|
607
|
+
try {
|
|
608
|
+
s.dispose();
|
|
609
|
+
}
|
|
610
|
+
catch { /* already torn down by the caller's close */ }
|
|
611
|
+
this.sessions.splice(i, 1);
|
|
612
|
+
removed = true;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (this.options.deleteHistory) {
|
|
616
|
+
// Codex's rollout is keyed by the runtime session ref; try the id and the
|
|
617
|
+
// provided sessionFile (either may be the rollout id depending on caller).
|
|
618
|
+
for (const ref of new Set([sessionId, sessionFile].filter((v) => Boolean(v)))) {
|
|
619
|
+
try {
|
|
620
|
+
this.options.deleteHistory(ref);
|
|
621
|
+
removed = true;
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
// Best-effort store cleanup — a missing/locked rollout must not fail the delete.
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return removed;
|
|
629
|
+
}
|
|
630
|
+
}
|