@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,499 @@
|
|
|
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 { stripAnsi } from "./ansi.js";
|
|
7
|
+
import { buildAgentCredentialEnv } from "./credentials.js";
|
|
8
|
+
import { egressEnv } from "../harness/egress.js";
|
|
9
|
+
import { depCacheEnv } from "../harness/dep-cache.js";
|
|
10
|
+
/**
|
|
11
|
+
* Send `signal` to `child`'s whole process group when possible, so a forking CLI
|
|
12
|
+
* agent's grandchildren (it shells out to git/npm/build tools, or forks its own
|
|
13
|
+
* worker processes) die with it instead of being orphaned. Relies on the child
|
|
14
|
+
* having been spawned `detached` (making it its own process-group leader, POSIX
|
|
15
|
+
* only — see the spawn() call in ProcessSession.prompt); `process.kill(-pid,
|
|
16
|
+
* signal)` then targets the whole group, mirroring `pty-runner.py`'s
|
|
17
|
+
* `os.killpg`. Falls back to killing just the direct child on Windows (no
|
|
18
|
+
* negative-pid group kill there) or if the group is already gone.
|
|
19
|
+
*/
|
|
20
|
+
function killProcessGroup(child, signal) {
|
|
21
|
+
const pid = child.pid;
|
|
22
|
+
if (pid && process.platform !== "win32") {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(-pid, signal);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// No such process group (already exited) or we're not its leader — fall
|
|
29
|
+
// through to a direct kill of the child itself.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
child.kill(signal);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Already exited.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function splitArgs(value) {
|
|
40
|
+
if (!value?.trim())
|
|
41
|
+
return [];
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(value);
|
|
44
|
+
if (Array.isArray(parsed))
|
|
45
|
+
return parsed.map(String);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// fall through to a small shell-like splitter
|
|
49
|
+
}
|
|
50
|
+
const out = [];
|
|
51
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
52
|
+
let match;
|
|
53
|
+
while ((match = re.exec(value)))
|
|
54
|
+
out.push(match[1] ?? match[2] ?? match[3] ?? "");
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Generic resume primitive for the escape-hatch CLI runtime: `BIVY_AGENT_RESUME_
|
|
59
|
+
* TEMPLATE` (a JSON arg array with an `{id}` placeholder for the agent's own
|
|
60
|
+
* session ref) opts a hand-configured agent into the same data-driven resume path
|
|
61
|
+
* the built-in CLI agents get from `CLI_AGENT_SPECS[id].resume` (see
|
|
62
|
+
* src/runtime/index.ts) — purely as operator config, no code. Absent = a fresh
|
|
63
|
+
* process per prompt, same as any other unconfigured CLI agent.
|
|
64
|
+
*/
|
|
65
|
+
function genericResumeTemplateFromEnv() {
|
|
66
|
+
const raw = process.env.BIVY_AGENT_RESUME_TEMPLATE?.trim();
|
|
67
|
+
if (!raw)
|
|
68
|
+
return undefined;
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
if (Array.isArray(parsed) && parsed.length)
|
|
72
|
+
return parsed.map(String);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// malformed override — resume stays off
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
export function processRuntimeFromEnv() {
|
|
80
|
+
const command = process.env.BIVY_AGENT_COMMAND?.trim();
|
|
81
|
+
if (!command)
|
|
82
|
+
return null;
|
|
83
|
+
const resumeTemplate = genericResumeTemplateFromEnv();
|
|
84
|
+
return {
|
|
85
|
+
id: process.env.BIVY_AGENT_ID?.trim() || "generic-cli",
|
|
86
|
+
displayName: process.env.BIVY_AGENT_NAME?.trim() || "Generic CLI Agent",
|
|
87
|
+
command,
|
|
88
|
+
args: splitArgs(process.env.BIVY_AGENT_ARGS),
|
|
89
|
+
promptMode: process.env.BIVY_AGENT_PROMPT_MODE === "argv" ? "argv" : "stdin",
|
|
90
|
+
...(resumeTemplate
|
|
91
|
+
? {
|
|
92
|
+
resumable: true,
|
|
93
|
+
resumeArgs: (sessionId) => resumeTemplate.map((a) => a.replace(/\{id\}/g, sessionId)),
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
class ProcessSession {
|
|
99
|
+
runtimeOptions;
|
|
100
|
+
cwd;
|
|
101
|
+
/** For a resumed session the id IS the agent's session id (so the daemon's
|
|
102
|
+
* session ref round-trips); a fresh session gets a random id. */
|
|
103
|
+
id;
|
|
104
|
+
child;
|
|
105
|
+
messages = [];
|
|
106
|
+
emitter = new EventEmitter();
|
|
107
|
+
streaming = false;
|
|
108
|
+
name;
|
|
109
|
+
/** Set when this session was opened to resume an existing agent session. */
|
|
110
|
+
resumeId;
|
|
111
|
+
constructor(runtimeOptions, cwd, resumeId) {
|
|
112
|
+
this.runtimeOptions = runtimeOptions;
|
|
113
|
+
this.cwd = cwd;
|
|
114
|
+
this.resumeId = resumeId;
|
|
115
|
+
this.id = resumeId ?? randomUUID();
|
|
116
|
+
// Preload prior history so a reopened session isn't blank (resumable runtimes).
|
|
117
|
+
if (resumeId && runtimeOptions.loadHistory) {
|
|
118
|
+
try {
|
|
119
|
+
this.messages = runtimeOptions.loadHistory(resumeId);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
this.messages = [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** The resume token handed back to the daemon; the id doubles as the ref. */
|
|
127
|
+
get sessionFile() {
|
|
128
|
+
return this.resumeId;
|
|
129
|
+
}
|
|
130
|
+
get isStreaming() {
|
|
131
|
+
return this.streaming;
|
|
132
|
+
}
|
|
133
|
+
/** PID of the live agent subprocess for the current turn (see RuntimeSession). */
|
|
134
|
+
activePid() {
|
|
135
|
+
return this.child?.pid;
|
|
136
|
+
}
|
|
137
|
+
getMessages() {
|
|
138
|
+
return this.messages;
|
|
139
|
+
}
|
|
140
|
+
/** Currently selected model id, or undefined to run on the agent's own default. */
|
|
141
|
+
currentModelId;
|
|
142
|
+
/** Provider of the selected model — scopes custom base-URL env injection. */
|
|
143
|
+
currentModelProvider;
|
|
144
|
+
/** Currently selected thinking level, or undefined for the agent's own default. */
|
|
145
|
+
currentThinkingLevel;
|
|
146
|
+
/** Best-effort usage parsed from the most recent turn (see getUsage). */
|
|
147
|
+
lastUsage;
|
|
148
|
+
getModels() {
|
|
149
|
+
return this.runtimeOptions.model?.models ?? [];
|
|
150
|
+
}
|
|
151
|
+
getCurrentModel() {
|
|
152
|
+
if (!this.currentModelId)
|
|
153
|
+
return undefined;
|
|
154
|
+
const known = this.getModels().find((m) => m.id === this.currentModelId);
|
|
155
|
+
// A free-form id (e.g. an operator-supplied model not in the curated list)
|
|
156
|
+
// still round-trips so the picker reflects the actual selection.
|
|
157
|
+
return known ?? { provider: "", id: this.currentModelId, name: this.currentModelId };
|
|
158
|
+
}
|
|
159
|
+
async setModel(provider, id) {
|
|
160
|
+
if (!this.runtimeOptions.model)
|
|
161
|
+
throw new Error("Model selection is not supported by this runtime.");
|
|
162
|
+
// Accept any non-empty id (curated or free-form). An empty id clears the
|
|
163
|
+
// selection so the agent falls back to its own default.
|
|
164
|
+
this.currentModelId = id.trim() || undefined;
|
|
165
|
+
this.currentModelProvider = provider?.trim().toLowerCase() || undefined;
|
|
166
|
+
}
|
|
167
|
+
// ---- Thinking / reasoning effort (see ProcessThinkingConfig) --------------
|
|
168
|
+
supportsThinking() {
|
|
169
|
+
return Boolean(this.runtimeOptions.thinking?.levels.length);
|
|
170
|
+
}
|
|
171
|
+
getAvailableThinkingLevels() {
|
|
172
|
+
return this.runtimeOptions.thinking?.levels ?? [];
|
|
173
|
+
}
|
|
174
|
+
getThinkingLevel() {
|
|
175
|
+
return this.currentThinkingLevel ?? this.runtimeOptions.thinking?.default;
|
|
176
|
+
}
|
|
177
|
+
setThinkingLevel(level) {
|
|
178
|
+
if (!this.runtimeOptions.thinking)
|
|
179
|
+
return;
|
|
180
|
+
const next = level.trim();
|
|
181
|
+
// Only accept advertised levels; anything else (incl. "off"/"") clears it so
|
|
182
|
+
// the agent runs on its own default reasoning.
|
|
183
|
+
this.currentThinkingLevel = this.runtimeOptions.thinking.levels.includes(next) ? next : undefined;
|
|
184
|
+
}
|
|
185
|
+
// ---- Usage (see ProcessRuntimeOptions.usageReporting) ---------------------
|
|
186
|
+
async getUsage() {
|
|
187
|
+
return this.lastUsage;
|
|
188
|
+
}
|
|
189
|
+
getName() {
|
|
190
|
+
return this.name;
|
|
191
|
+
}
|
|
192
|
+
setName(name) {
|
|
193
|
+
this.name = name;
|
|
194
|
+
}
|
|
195
|
+
async suggestName() {
|
|
196
|
+
// The generic CLI "dumb-pipe" runtime has no model of its own to name a
|
|
197
|
+
// session with. Returning a raw 60-char truncation of the first message here
|
|
198
|
+
// was actively harmful: it *overrode* the clean first-few-words title (and
|
|
199
|
+
// branch slug) maybeNameSession() derives deterministically, and it's never
|
|
200
|
+
// an agent-written title. Return undefined so that deterministic title stands
|
|
201
|
+
// — and so the node-level LLM namer (server.ts's suggestSessionNameFromNode,
|
|
202
|
+
// which uses whatever model credentials the node holds) can refine it.
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
subscribe(listener) {
|
|
206
|
+
this.emitter.on("event", listener);
|
|
207
|
+
return () => this.emitter.off("event", listener);
|
|
208
|
+
}
|
|
209
|
+
emit(event) {
|
|
210
|
+
this.emitter.emit("event", event);
|
|
211
|
+
}
|
|
212
|
+
async prompt(text, _options) {
|
|
213
|
+
if (this.streaming)
|
|
214
|
+
throw new Error("Agent is already running.");
|
|
215
|
+
const prompt = text.trim();
|
|
216
|
+
if (!prompt)
|
|
217
|
+
return;
|
|
218
|
+
this.messages.push({ role: "user", content: prompt, timestamp: Date.now() });
|
|
219
|
+
this.streaming = true;
|
|
220
|
+
this.emit({ type: "agent_start" });
|
|
221
|
+
this.emit({ type: "turn_start" });
|
|
222
|
+
// A resumed session continues the agent's own session id each turn via
|
|
223
|
+
// resumeArgs (e.g. `codex exec resume <id> --json`); otherwise the base args.
|
|
224
|
+
const baseArgs = this.resumeId && this.runtimeOptions.resumeArgs
|
|
225
|
+
? this.runtimeOptions.resumeArgs(this.resumeId)
|
|
226
|
+
: (this.runtimeOptions.args ?? []);
|
|
227
|
+
// Selection flags (model + thinking level) splice into the base args at their
|
|
228
|
+
// configured positions — before a trailing prompt flag, or after a leading
|
|
229
|
+
// subcommand. Both `insertAt` values index the ORIGINAL base args, so we apply
|
|
230
|
+
// them highest-index-first: an earlier splice would otherwise shift the indices
|
|
231
|
+
// a later one is relative to. No selection = the agent's own default, untouched.
|
|
232
|
+
const modelConfig = this.runtimeOptions.model;
|
|
233
|
+
const thinkingConfig = this.runtimeOptions.thinking;
|
|
234
|
+
const injections = [];
|
|
235
|
+
if (modelConfig && this.currentModelId)
|
|
236
|
+
injections.push({ at: modelConfig.insertAt ?? 0, args: modelConfig.modelArgs(this.currentModelId) });
|
|
237
|
+
// Only inject an *explicitly chosen* level — an untouched session runs on the
|
|
238
|
+
// agent's own default (which we advertise via getThinkingLevel for display).
|
|
239
|
+
if (thinkingConfig && this.currentThinkingLevel)
|
|
240
|
+
injections.push({ at: thinkingConfig.insertAt ?? 0, args: thinkingConfig.thinkingArgs(this.currentThinkingLevel) });
|
|
241
|
+
let argsWithFlags = baseArgs;
|
|
242
|
+
for (const { at, args: inject } of injections.sort((a, b) => b.at - a.at)) {
|
|
243
|
+
const idx = Math.min(Math.max(at, 0), argsWithFlags.length);
|
|
244
|
+
argsWithFlags = [...argsWithFlags.slice(0, idx), ...inject, ...argsWithFlags.slice(idx)];
|
|
245
|
+
}
|
|
246
|
+
const args = this.runtimeOptions.promptMode === "argv" ? [...argsWithFlags, prompt] : argsWithFlags;
|
|
247
|
+
// Resolve credentials per prompt so freshly-refreshed OAuth tokens (and keys
|
|
248
|
+
// added after this session started) reach the agent. The vault wins over any
|
|
249
|
+
// ambient key so Bivy's shared sign-in is authoritative.
|
|
250
|
+
const credentialEnv = this.runtimeOptions.credentials
|
|
251
|
+
? await buildAgentCredentialEnv(this.runtimeOptions.credentials, undefined, this.currentModelProvider).catch(() => ({}))
|
|
252
|
+
: {};
|
|
253
|
+
// Optional prepare step (e.g. Codex materializes its auth.json from the vault
|
|
254
|
+
// and pins CODEX_HOME). Runs after credentials, before preflight/spawn; its
|
|
255
|
+
// env patch flows into both. Best-effort — failures never block the run.
|
|
256
|
+
const prepareEnv = this.runtimeOptions.prepare
|
|
257
|
+
? (await Promise.resolve(this.runtimeOptions.prepare({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv })).catch(() => undefined)) ?? {}
|
|
258
|
+
: {};
|
|
259
|
+
// Credential preflight: if the agent has no usable credential, surface a
|
|
260
|
+
// clear, actionable error instead of spawning a subprocess that dies with an
|
|
261
|
+
// opaque upstream 401 (e.g. Codex's "unexpected status 401 Unauthorized:
|
|
262
|
+
// Missing bearer …" when no OpenAI key/login is present).
|
|
263
|
+
const preflightError = this.runtimeOptions.preflight?.({ ...process.env, ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv }, { provider: this.currentModelProvider });
|
|
264
|
+
if (preflightError) {
|
|
265
|
+
this.streaming = false;
|
|
266
|
+
const message = { role: "assistant", content: "", errorMessage: preflightError };
|
|
267
|
+
this.messages.push(message);
|
|
268
|
+
this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
|
|
269
|
+
this.emit({ type: "session.error", error: preflightError });
|
|
270
|
+
this.emit({ type: "message_end", message });
|
|
271
|
+
this.emit({ type: "turn_end" });
|
|
272
|
+
this.emit({ type: "agent_end", code: 1, signal: null });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// The agent enforces its own containment via its native sandbox (Codex
|
|
276
|
+
// --sandbox, Gemini --approval-mode, Claude permissionMode; see
|
|
277
|
+
// src/harness/sandbox.ts). Bivy no longer wraps the process in an OS jail.
|
|
278
|
+
const child = spawn(this.runtimeOptions.command, args, {
|
|
279
|
+
cwd: this.cwd,
|
|
280
|
+
// egressEnv() routes this agent's outbound traffic through the harness
|
|
281
|
+
// network broker when BIVY_EGRESS_PROXY is enabled (else it's {}).
|
|
282
|
+
env: { ...process.env, ...depCacheEnv(), ...this.runtimeOptions.env, ...credentialEnv, ...prepareEnv, ...egressEnv() },
|
|
283
|
+
stdio: "pipe",
|
|
284
|
+
// Detached so the child becomes the leader of its own process group
|
|
285
|
+
// (POSIX) — see killProcessGroup() / abort() below, which kill that whole
|
|
286
|
+
// group instead of just this direct child. Without this, a forking CLI
|
|
287
|
+
// agent's grandchildren survive `abort()` as orphans.
|
|
288
|
+
detached: process.platform !== "win32",
|
|
289
|
+
});
|
|
290
|
+
this.child = child;
|
|
291
|
+
let stdout = "";
|
|
292
|
+
let stderr = "";
|
|
293
|
+
let startedMessage = false;
|
|
294
|
+
// Phase 4 — structured mode: feed each complete stdout line to a CliParser
|
|
295
|
+
// and emit its normalized events (streaming text, tool cards, transcript)
|
|
296
|
+
// instead of the raw-stdout blob.
|
|
297
|
+
const parser = this.runtimeOptions.parserFactory?.();
|
|
298
|
+
let lineBuf = "";
|
|
299
|
+
let messagesPushed = false;
|
|
300
|
+
// Emit a batch of parser events; the moment the turn ends (agent_end), sync
|
|
301
|
+
// the parser's transcript into this.messages so getMessages() is correct as
|
|
302
|
+
// soon as agent_end fires — the parser emits agent_end during stdout parsing
|
|
303
|
+
// (on session.done), before the child 'close' handler runs.
|
|
304
|
+
const emitParserEvents = (activeParser, events) => {
|
|
305
|
+
for (const event of events)
|
|
306
|
+
this.emit(event);
|
|
307
|
+
if (!messagesPushed && events.some((e) => e.type === "agent_end")) {
|
|
308
|
+
messagesPushed = true;
|
|
309
|
+
for (const message of activeParser.messages())
|
|
310
|
+
this.messages.push(message);
|
|
311
|
+
// Capture the turn's best-effort usage so getUsage() is ready by the time
|
|
312
|
+
// the daemon's agent_end handler (refreshSessionUsage) reads it.
|
|
313
|
+
const usage = activeParser.usage?.();
|
|
314
|
+
if (usage)
|
|
315
|
+
this.lastUsage = usage;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const feedParser = (activeParser, text) => {
|
|
319
|
+
lineBuf += text;
|
|
320
|
+
let nl;
|
|
321
|
+
while ((nl = lineBuf.indexOf("\n")) >= 0) {
|
|
322
|
+
const line = lineBuf.slice(0, nl);
|
|
323
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
324
|
+
emitParserEvents(activeParser, activeParser.onLine(line));
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
const update = () => {
|
|
328
|
+
if (!startedMessage) {
|
|
329
|
+
startedMessage = true;
|
|
330
|
+
this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
|
|
331
|
+
}
|
|
332
|
+
this.emit({ type: "message_update", message: { role: "assistant", content: stripAnsi(stdout) } });
|
|
333
|
+
};
|
|
334
|
+
child.stdout.on("data", (chunk) => {
|
|
335
|
+
const text = chunk.toString("utf8");
|
|
336
|
+
stdout += text;
|
|
337
|
+
if (parser)
|
|
338
|
+
feedParser(parser, text);
|
|
339
|
+
else
|
|
340
|
+
update();
|
|
341
|
+
});
|
|
342
|
+
child.stderr.on("data", (chunk) => {
|
|
343
|
+
stderr += chunk.toString("utf8");
|
|
344
|
+
this.emit({ type: "tool_execution_update", toolName: "agent_output", toolCallId: "agent-output", input: { stream: "stderr", output: stripAnsi(stderr.slice(-4000)) } });
|
|
345
|
+
});
|
|
346
|
+
child.on("error", (error) => {
|
|
347
|
+
stderr += error.message;
|
|
348
|
+
});
|
|
349
|
+
child.on("close", (code, signal) => {
|
|
350
|
+
this.child = undefined;
|
|
351
|
+
this.streaming = false;
|
|
352
|
+
if (parser) {
|
|
353
|
+
// Flush any trailing partial line, then let the parser close the turn.
|
|
354
|
+
// The parser owns the full turn lifecycle (message_end/turn_end/
|
|
355
|
+
// agent_end via onClose/session.done), so we don't emit our own here.
|
|
356
|
+
// emitParserEvents pushes the transcript on agent_end; if the parser
|
|
357
|
+
// already finalized mid-stream this close is a no-op for messages.
|
|
358
|
+
if (lineBuf.trim())
|
|
359
|
+
emitParserEvents(parser, parser.onLine(lineBuf));
|
|
360
|
+
lineBuf = "";
|
|
361
|
+
emitParserEvents(parser, parser.onClose(code, stderr));
|
|
362
|
+
if (!messagesPushed)
|
|
363
|
+
for (const message of parser.messages())
|
|
364
|
+
this.messages.push(message);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const failed = code && code !== 0;
|
|
368
|
+
const content = stripAnsi(stdout.trim() || (failed ? stderr.trim() : ""));
|
|
369
|
+
const cleanStderr = stripAnsi(stderr.trim());
|
|
370
|
+
const message = { role: "assistant", content, ...(failed ? { errorMessage: `Process exited with code ${code}${cleanStderr ? `: ${cleanStderr}` : ""}` } : {}) };
|
|
371
|
+
this.messages.push(message);
|
|
372
|
+
if (!startedMessage)
|
|
373
|
+
this.emit({ type: "message_start", message: { role: "assistant", content: "" } });
|
|
374
|
+
this.emit({ type: "message_end", message });
|
|
375
|
+
this.emit({ type: "turn_end" });
|
|
376
|
+
this.emit({ type: "agent_end", code, signal });
|
|
377
|
+
});
|
|
378
|
+
if (this.runtimeOptions.promptMode !== "argv") {
|
|
379
|
+
child.stdin.end(`${prompt}\n`);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
// Prompt is already in argv. Still close stdin so agents that also read it
|
|
383
|
+
// (e.g. `codex exec` waits for stdin EOF even with a prompt arg) don't hang
|
|
384
|
+
// forever waiting for input that will never come.
|
|
385
|
+
child.stdin.end();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
async abort() {
|
|
389
|
+
// Capture the live child now — not inside the timer below — so a fast
|
|
390
|
+
// re-spawn (a new prompt() started after this turn's process has already
|
|
391
|
+
// exited) can never be caught by the delayed SIGKILL: the timer always
|
|
392
|
+
// targets *this* turn's process/group, whatever `this.child` points to by
|
|
393
|
+
// the time it fires.
|
|
394
|
+
const child = this.child;
|
|
395
|
+
if (!child)
|
|
396
|
+
return;
|
|
397
|
+
killProcessGroup(child, "SIGTERM");
|
|
398
|
+
setTimeout(() => killProcessGroup(child, "SIGKILL"), 2000).unref();
|
|
399
|
+
}
|
|
400
|
+
dispose() {
|
|
401
|
+
void this.abort();
|
|
402
|
+
this.emitter.removeAllListeners();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
export class ProcessRuntime {
|
|
406
|
+
options;
|
|
407
|
+
id;
|
|
408
|
+
displayName;
|
|
409
|
+
capabilities;
|
|
410
|
+
sessions = [];
|
|
411
|
+
constructor(options) {
|
|
412
|
+
this.options = options;
|
|
413
|
+
this.id = options.id ?? "generic-cli";
|
|
414
|
+
this.displayName = options.displayName ?? "Generic CLI Agent";
|
|
415
|
+
this.capabilities = {
|
|
416
|
+
toolInterception: false,
|
|
417
|
+
// Model selection only when a model config with a non-empty list is wired
|
|
418
|
+
// (e.g. Gemini/Qwen/Aider/OpenCode); otherwise the agent runs on its own
|
|
419
|
+
// default and the picker must not render a control that no-ops.
|
|
420
|
+
modelSelection: Boolean(options.model?.models.length),
|
|
421
|
+
packages: false,
|
|
422
|
+
// Resume only when the runtime was configured for it (e.g. Codex).
|
|
423
|
+
resume: Boolean(options.resumable),
|
|
424
|
+
fork: false,
|
|
425
|
+
// Usage reporting when a usage-emitting parser is wired (see getUsage).
|
|
426
|
+
usageReporting: Boolean(options.usageReporting),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/** Session-less catalog contribution: this CLI agent's configured models, grouped by provider. */
|
|
430
|
+
listCatalog() {
|
|
431
|
+
const byProvider = new Map();
|
|
432
|
+
for (const model of this.options.model?.models ?? []) {
|
|
433
|
+
const id = model.provider?.trim().toLowerCase();
|
|
434
|
+
if (!id)
|
|
435
|
+
continue;
|
|
436
|
+
let entry = byProvider.get(id);
|
|
437
|
+
if (!entry) {
|
|
438
|
+
entry = { id, name: id, models: [] };
|
|
439
|
+
byProvider.set(id, entry);
|
|
440
|
+
}
|
|
441
|
+
entry.models.push(model);
|
|
442
|
+
}
|
|
443
|
+
return [...byProvider.values()];
|
|
444
|
+
}
|
|
445
|
+
async createSession(options) {
|
|
446
|
+
const session = new ProcessSession(this.options, options.workspace);
|
|
447
|
+
this.sessions.push(session);
|
|
448
|
+
return { session, warning: "Generic CLI runtime streams stdout/stderr only; approvals, model picker, and resume depend on the underlying agent protocol." };
|
|
449
|
+
}
|
|
450
|
+
async openSession(options) {
|
|
451
|
+
// Resumable runtimes bind the agent's session id so each prompt continues it
|
|
452
|
+
// (see resumeArgs); non-resumable ones ignore the ref and start fresh.
|
|
453
|
+
if (this.options.resumable) {
|
|
454
|
+
const session = new ProcessSession(this.options, options.workspace, options.sessionFile);
|
|
455
|
+
this.sessions.push(session);
|
|
456
|
+
return { session };
|
|
457
|
+
}
|
|
458
|
+
return this.createSession(options);
|
|
459
|
+
}
|
|
460
|
+
async listSessions() {
|
|
461
|
+
return this.sessions.map((session) => ({
|
|
462
|
+
id: session.id,
|
|
463
|
+
cwd: session.cwd,
|
|
464
|
+
name: session.getName(),
|
|
465
|
+
messageCount: session.getMessages().length,
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Forget a session on a user-initiated delete. Drops the in-memory handle so
|
|
470
|
+
* listSessions (which reports the runtime's live sessions) stops returning it,
|
|
471
|
+
* and — for resume-capable agents that persist transcripts in their own store
|
|
472
|
+
* (Codex) — removes that on-disk rollout via `deleteHistory` so it can't be
|
|
473
|
+
* re-surfaced. Returns true if anything was removed.
|
|
474
|
+
*/
|
|
475
|
+
async deleteSession(sessionId, sessionFile) {
|
|
476
|
+
let removed = false;
|
|
477
|
+
for (let i = this.sessions.length - 1; i >= 0; i--) {
|
|
478
|
+
const s = this.sessions[i];
|
|
479
|
+
if (s.id === sessionId || (sessionFile && s.sessionFile === sessionFile)) {
|
|
480
|
+
try {
|
|
481
|
+
s.dispose();
|
|
482
|
+
}
|
|
483
|
+
catch { /* already torn down by the caller's close */ }
|
|
484
|
+
this.sessions.splice(i, 1);
|
|
485
|
+
removed = true;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (this.options.deleteHistory) {
|
|
489
|
+
try {
|
|
490
|
+
this.options.deleteHistory(sessionId);
|
|
491
|
+
removed = true;
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// Best-effort store cleanup — a missing/locked rollout must not fail the delete.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return removed;
|
|
498
|
+
}
|
|
499
|
+
}
|