@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,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
+ }