@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.
Files changed (146) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +265 -5
  3. package/bin/acp-shim.mjs +298 -0
  4. package/bin/agent-manifest.json +277 -0
  5. package/bin/bivy.mjs +4100 -0
  6. package/bin/codex-app-server-shim.mjs +447 -0
  7. package/bin/patch-pi-dependencies.mjs +44 -0
  8. package/bin/prune-sessions.mjs +52 -0
  9. package/bin/sessions-list.mjs +27 -0
  10. package/bin/shim-path.mjs +126 -0
  11. package/bin/uninstall-paths.mjs +48 -0
  12. package/dist/approval.js +87 -0
  13. package/dist/attach.js +248 -0
  14. package/dist/auth.js +258 -0
  15. package/dist/bivy-login.js +180 -0
  16. package/dist/browser-open.js +50 -0
  17. package/dist/control-plane-tasks.js +236 -0
  18. package/dist/data-dir.js +25 -0
  19. package/dist/device-registry.js +201 -0
  20. package/dist/e2e.js +70 -0
  21. package/dist/ephemeral-exec.js +109 -0
  22. package/dist/exec.js +209 -0
  23. package/dist/git-auth.js +155 -0
  24. package/dist/github-app-auth.js +107 -0
  25. package/dist/github-app-connect.js +235 -0
  26. package/dist/github-app-manifest.js +82 -0
  27. package/dist/github-app-sync-cli.js +93 -0
  28. package/dist/github-app-vault.js +106 -0
  29. package/dist/github-apps.js +121 -0
  30. package/dist/github-connect-repo.js +74 -0
  31. package/dist/github-device-auth.js +109 -0
  32. package/dist/github-tasks.js +650 -0
  33. package/dist/guard.js +109 -0
  34. package/dist/harness/cache-evict.js +88 -0
  35. package/dist/harness/checkpoint.js +0 -0
  36. package/dist/harness/cow-clone.js +84 -0
  37. package/dist/harness/dep-cache.js +78 -0
  38. package/dist/harness/disk-admission.js +46 -0
  39. package/dist/harness/egress.js +30 -0
  40. package/dist/harness/manager.js +97 -0
  41. package/dist/harness/mcp-config-formats.js +164 -0
  42. package/dist/harness/mcp-config.js +111 -0
  43. package/dist/harness/mcp-inject.js +134 -0
  44. package/dist/harness/mcp-proxy-cli.js +88 -0
  45. package/dist/harness/mcp-proxy.js +150 -0
  46. package/dist/harness/net-proxy.js +120 -0
  47. package/dist/harness/sandbox.js +96 -0
  48. package/dist/history-sync.js +26 -0
  49. package/dist/hosted-endpoints.d.mts +14 -0
  50. package/dist/hosted-endpoints.mjs +35 -0
  51. package/dist/identity.js +153 -0
  52. package/dist/integrations/index.js +4 -0
  53. package/dist/integrations/manager.js +279 -0
  54. package/dist/integrations/oauth.js +78 -0
  55. package/dist/integrations/registry.js +239 -0
  56. package/dist/integrations/store.js +54 -0
  57. package/dist/integrations/types.js +1 -0
  58. package/dist/linear-tasks.js +49 -0
  59. package/dist/metadata.js +226 -0
  60. package/dist/multiplexer.js +79 -0
  61. package/dist/native-pi.js +38 -0
  62. package/dist/node-stats.js +237 -0
  63. package/dist/pairing-crypto.js +105 -0
  64. package/dist/policy/conditions.js +103 -0
  65. package/dist/policy/policy-engine.js +20 -0
  66. package/dist/policy/risk.js +18 -0
  67. package/dist/policy/ruleset.js +113 -0
  68. package/dist/policy/run-policy.js +108 -0
  69. package/dist/policy/session-reroute.js +96 -0
  70. package/dist/pty-runner.py +95 -0
  71. package/dist/question.js +146 -0
  72. package/dist/redact.js +97 -0
  73. package/dist/relay-attach.js +345 -0
  74. package/dist/relay-chunk.js +73 -0
  75. package/dist/relay-cli-crypto.js +70 -0
  76. package/dist/relay-client.js +344 -0
  77. package/dist/relay-setup.js +262 -0
  78. package/dist/repo-workspace.js +208 -0
  79. package/dist/runtime/adoption.js +45 -0
  80. package/dist/runtime/agent-service-bin.js +149 -0
  81. package/dist/runtime/agent-service.js +439 -0
  82. package/dist/runtime/ansi.js +27 -0
  83. package/dist/runtime/anthropic-preflight.js +80 -0
  84. package/dist/runtime/claude-code.js +1364 -0
  85. package/dist/runtime/cli-parsers.js +647 -0
  86. package/dist/runtime/codex-auth.js +168 -0
  87. package/dist/runtime/codex-preflight.js +60 -0
  88. package/dist/runtime/codex-sessions.js +229 -0
  89. package/dist/runtime/control-plane-location.js +74 -0
  90. package/dist/runtime/credential-ingest.js +122 -0
  91. package/dist/runtime/credential-provisioning.js +79 -0
  92. package/dist/runtime/credential-store.js +435 -0
  93. package/dist/runtime/credentials.js +153 -0
  94. package/dist/runtime/host.js +153 -0
  95. package/dist/runtime/index.js +1548 -0
  96. package/dist/runtime/local-model-store.js +194 -0
  97. package/dist/runtime/location-registry.js +28 -0
  98. package/dist/runtime/model-catalog.js +97 -0
  99. package/dist/runtime/model-namer.js +85 -0
  100. package/dist/runtime/native-process-scan.js +102 -0
  101. package/dist/runtime/native-session-discovery.js +103 -0
  102. package/dist/runtime/normalize.js +75 -0
  103. package/dist/runtime/oauth/model-oauth-providers.js +75 -0
  104. package/dist/runtime/oauth/model-oauth.js +324 -0
  105. package/dist/runtime/opencode-preflight.js +55 -0
  106. package/dist/runtime/pi-auth.js +82 -0
  107. package/dist/runtime/pi-oauth.js +52 -0
  108. package/dist/runtime/pi-session-discovery.js +42 -0
  109. package/dist/runtime/pi.js +518 -0
  110. package/dist/runtime/process.js +499 -0
  111. package/dist/runtime/protocol.js +630 -0
  112. package/dist/runtime/remote.js +541 -0
  113. package/dist/runtime/rpc-protocol.js +56 -0
  114. package/dist/runtime/ruleset-store.js +117 -0
  115. package/dist/runtime/session-location.js +50 -0
  116. package/dist/runtime/types.js +17 -0
  117. package/dist/secrets-cli.js +134 -0
  118. package/dist/secrets.js +264 -0
  119. package/dist/server.js +9411 -0
  120. package/dist/session/bivy-session.js +1 -0
  121. package/dist/session/checkpoint-pack.js +133 -0
  122. package/dist/session/event-log.js +340 -0
  123. package/dist/session/fork-dirty.js +73 -0
  124. package/dist/session/fork-prereqs.js +61 -0
  125. package/dist/session/fork.js +57 -0
  126. package/dist/session/native-import.js +56 -0
  127. package/dist/session/reconnect.js +168 -0
  128. package/dist/session/replication-service.js +236 -0
  129. package/dist/session/replication.js +106 -0
  130. package/dist/session/replicator.js +140 -0
  131. package/dist/session/session-new-dedupe.js +42 -0
  132. package/dist/session/sibling-client.js +201 -0
  133. package/dist/session/transcript-merge.js +131 -0
  134. package/dist/session/transcript-normal.js +130 -0
  135. package/dist/session/workspace-context.js +1 -0
  136. package/dist/session-event-coalescer.js +50 -0
  137. package/dist/session-identity.js +34 -0
  138. package/dist/session-ref.js +65 -0
  139. package/dist/stt-cli.js +131 -0
  140. package/dist/stt.js +168 -0
  141. package/dist/terminal.js +409 -0
  142. package/dist/wire-format.js +67 -0
  143. package/dist/worktree-provision.js +118 -0
  144. package/dist/worktree.js +117 -0
  145. package/package.json +40 -6
  146. 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
+ }