@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,541 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /**
4
+ * Remote runtime adapter — the daemon-side half of Stage 1
5
+ * (docs/agent-node-decoupling.md). `RemoteRuntimeSession implements
6
+ * RuntimeSession` but owns no child process: it speaks the RPC protocol
7
+ * (src/runtime/rpc-protocol.ts) to an agent service that hosts the real runtime
8
+ * in another process. Because it satisfies the same `RuntimeSession` interface,
9
+ * server.ts's ~40 `openSessions` call sites are untouched — the seam is
10
+ * `RuntimeHost.get()` returning a `RemoteRuntime` instead of an in-process one.
11
+ *
12
+ * Two design points make this a faithful drop-in:
13
+ * - The synchronous accessors the daemon reads (getMessages/getName/
14
+ * getCurrentModel/isStreaming/sessionFile/activePid/…) cannot round-trip, so
15
+ * they read a LOCAL MIRROR the service keeps current via snapshot deltas that
16
+ * ride each event. The mutated fields land on the SAME frame as `agent_end`,
17
+ * so the mirror is correct at the boundary the daemon's post-turn logic reads
18
+ * (usage refresh, worktree diff, PR detection) — matching process.ts timing.
19
+ * - The tool interceptor (the guardian/QuestionManager hook) stays on the
20
+ * daemon. The service asks the daemon to adjudicate each tool via a reverse
21
+ * RPC (`intercept`), so AskUserQuestion is answered by the daemon exactly as
22
+ * in-process and no `user_question` event ever crosses this link.
23
+ *
24
+ * The concrete socket transport lives here too, but the session/runtime take an
25
+ * injected `RpcTransport`, so they unit-test against a fake (see the tests).
26
+ */
27
+ import { EventEmitter } from "node:events";
28
+ import net from "node:net";
29
+ import { RPC_PROTOCOL_VERSION, FrameDecoder, encodeFrame, } from "./rpc-protocol.js";
30
+ /** A RuntimeSession backed by an agent service over an RpcTransport. */
31
+ export class RemoteRuntimeSession {
32
+ id;
33
+ cwd;
34
+ emitter = new EventEmitter();
35
+ transport;
36
+ toolInterceptor;
37
+ toolProvider;
38
+ onDispose;
39
+ nextId = 1;
40
+ pending = new Map();
41
+ /** In-flight interceptor calls, so abort() can settle a blocked question card. */
42
+ interceptAborts = new Map();
43
+ disposed = false;
44
+ // ---- Mirror of the synchronous RuntimeSession state ----------------------
45
+ _sessionFile;
46
+ _isStreaming;
47
+ _name;
48
+ _currentModel;
49
+ _activePid;
50
+ _thinkingLevel;
51
+ _messages;
52
+ _supportsThinking;
53
+ _availableThinkingLevels;
54
+ _commands;
55
+ constructor(transport, snapshot, options = {}) {
56
+ this.transport = transport;
57
+ this.toolInterceptor = options.toolInterceptor;
58
+ this.toolProvider = options.toolProvider;
59
+ this.onDispose = options.onDispose;
60
+ this.id = snapshot.sessionId;
61
+ this.cwd = snapshot.cwd;
62
+ this._sessionFile = snapshot.sessionFile ?? undefined;
63
+ this._isStreaming = snapshot.isStreaming;
64
+ this._name = snapshot.name ?? undefined;
65
+ this._currentModel = snapshot.currentModel ?? undefined;
66
+ this._activePid = snapshot.activePid ?? undefined;
67
+ this._thinkingLevel = snapshot.thinkingLevel ?? undefined;
68
+ this._messages = snapshot.messages ?? [];
69
+ this._supportsThinking = snapshot.supportsThinking;
70
+ this._availableThinkingLevels = snapshot.availableThinkingLevels ?? [];
71
+ this._commands = snapshot.commands ?? [];
72
+ }
73
+ // ---- Inbound wiring (driven by the transport owner) -----------------------
74
+ /** Apply one service→daemon message. Public so the connector can route it. */
75
+ handleMessage(message) {
76
+ switch (message.t) {
77
+ case "event":
78
+ if (message.snapshot)
79
+ this.applySnapshot(message.snapshot);
80
+ this.emitter.emit("event", message.event);
81
+ return;
82
+ case "state":
83
+ this.applySnapshot(message.snapshot);
84
+ return;
85
+ case "res": {
86
+ const entry = this.pending.get(message.id);
87
+ if (!entry)
88
+ return;
89
+ this.pending.delete(message.id);
90
+ if (message.ok) {
91
+ if (message.snapshot)
92
+ this.applySnapshot(message.snapshot);
93
+ entry.resolve(message.value);
94
+ }
95
+ else {
96
+ entry.reject(new Error(message.error));
97
+ }
98
+ return;
99
+ }
100
+ case "intercept":
101
+ void this.handleIntercept(message.id, message.ctx);
102
+ return;
103
+ case "tool-invoke":
104
+ void this.handleToolInvoke(message.id, message.toolName, message.toolCallId, message.params);
105
+ return;
106
+ case "started":
107
+ // Late/duplicate handshake reply — the connector already consumed it.
108
+ return;
109
+ }
110
+ }
111
+ /** Called when the underlying link closes. Fails a live turn cleanly. */
112
+ handleClose(err) {
113
+ if (this.disposed)
114
+ return;
115
+ const error = err ?? new Error("agent service connection closed");
116
+ for (const { reject } of this.pending.values())
117
+ reject(error);
118
+ this.pending.clear();
119
+ for (const controller of this.interceptAborts.values())
120
+ controller.abort();
121
+ this.interceptAborts.clear();
122
+ // If a turn was live, surface an error + close it so the daemon's busy
123
+ // tracking and post-turn logic don't hang waiting for an agent_end.
124
+ if (this._isStreaming) {
125
+ this._isStreaming = false;
126
+ this._activePid = undefined;
127
+ this.emitter.emit("event", { type: "session.error", error: error.message });
128
+ this.emitter.emit("event", { type: "agent_end", code: null, signal: null, disconnected: true });
129
+ }
130
+ }
131
+ applySnapshot(s) {
132
+ if (s.isStreaming !== undefined)
133
+ this._isStreaming = s.isStreaming;
134
+ if (s.sessionFile !== undefined)
135
+ this._sessionFile = s.sessionFile ?? undefined;
136
+ if (s.name !== undefined)
137
+ this._name = s.name ?? undefined;
138
+ if (s.currentModel !== undefined)
139
+ this._currentModel = s.currentModel ?? undefined;
140
+ if (s.activePid !== undefined)
141
+ this._activePid = s.activePid ?? undefined;
142
+ if (s.thinkingLevel !== undefined)
143
+ this._thinkingLevel = s.thinkingLevel ?? undefined;
144
+ if (s.messages !== undefined)
145
+ this._messages = s.messages;
146
+ }
147
+ async handleIntercept(id, ctx) {
148
+ const controller = new AbortController();
149
+ this.interceptAborts.set(id, controller);
150
+ let decision = {};
151
+ try {
152
+ if (this.toolInterceptor) {
153
+ const call = { sessionId: ctx.sessionId, toolName: ctx.toolName, input: ctx.input, signal: controller.signal };
154
+ decision = (await this.toolInterceptor(call)) || {};
155
+ }
156
+ }
157
+ catch (error) {
158
+ decision = { block: true, reason: error instanceof Error ? error.message : String(error) };
159
+ }
160
+ finally {
161
+ this.interceptAborts.delete(id);
162
+ }
163
+ if (!this.disposed)
164
+ this.transport.send({ t: "intercept-res", id, decision });
165
+ }
166
+ /**
167
+ * Execute a node-hosted tool the remote agent invoked (reverse RPC). The tool
168
+ * runs HERE on the daemon via the injected ToolProvider — where its credentials
169
+ * live — and the result goes back to the service, which hands it to the agent.
170
+ */
171
+ async handleToolInvoke(id, toolName, toolCallId, params) {
172
+ let result;
173
+ try {
174
+ result = this.toolProvider
175
+ ? await this.toolProvider.invoke(toolName, toolCallId, params)
176
+ : { content: [{ type: "text", text: `No tool provider for ${toolName}` }], isError: true };
177
+ }
178
+ catch (error) {
179
+ result = { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], isError: true };
180
+ }
181
+ if (!this.disposed)
182
+ this.transport.send({ t: "tool-invoke-res", id, result });
183
+ }
184
+ // ---- Request/response helpers --------------------------------------------
185
+ request(method, args) {
186
+ if (this.disposed)
187
+ return Promise.reject(new Error("session disposed"));
188
+ const id = this.nextId++;
189
+ return new Promise((resolve, reject) => {
190
+ this.pending.set(id, { resolve: resolve, reject });
191
+ this.transport.send({ t: "req", id, method, args });
192
+ });
193
+ }
194
+ notify(method, args) {
195
+ if (this.disposed && method !== "dispose")
196
+ return;
197
+ this.transport.send({ t: "notify", method, args });
198
+ }
199
+ // ---- RuntimeSession: streaming state --------------------------------------
200
+ get sessionFile() {
201
+ return this._sessionFile;
202
+ }
203
+ get isStreaming() {
204
+ return this._isStreaming;
205
+ }
206
+ activePid() {
207
+ return this._activePid;
208
+ }
209
+ getMessages() {
210
+ return this._messages;
211
+ }
212
+ subscribe(listener) {
213
+ this.emitter.on("event", listener);
214
+ return () => this.emitter.off("event", listener);
215
+ }
216
+ // ---- RuntimeSession: turn control -----------------------------------------
217
+ async prompt(text, options) {
218
+ await this.request("prompt", [text, options ?? {}]);
219
+ }
220
+ async abort() {
221
+ // Settle any question card blocking on the interceptor before the round-trip.
222
+ for (const controller of this.interceptAborts.values())
223
+ controller.abort();
224
+ await this.request("abort", []);
225
+ }
226
+ dispose() {
227
+ if (this.disposed)
228
+ return;
229
+ this.disposed = true;
230
+ // Send the `dispose` notify BEFORE closing so the agent service reaps the
231
+ // child (rather than detaching it). If the link is already gone the session
232
+ // was detached earlier, so there is nothing local left to reap.
233
+ try {
234
+ this.notify("dispose", []);
235
+ }
236
+ catch {
237
+ // best-effort
238
+ }
239
+ this.teardownLocal("session disposed");
240
+ }
241
+ /**
242
+ * Drop this local handle WITHOUT reaping the remote session (Stage 2). No
243
+ * `dispose` notify is sent, so the agent service sees only the transport close
244
+ * and DETACHES — keeping the child alive so another handle (this daemon later,
245
+ * or another daemon) can re-attach via RemoteRuntime.attachSession. Used when
246
+ * the daemon evicts a session from its local cache without ending it.
247
+ */
248
+ detach() {
249
+ if (this.disposed)
250
+ return;
251
+ this.disposed = true;
252
+ this.teardownLocal("session detached");
253
+ }
254
+ teardownLocal(reason) {
255
+ for (const { reject } of this.pending.values())
256
+ reject(new Error(reason));
257
+ this.pending.clear();
258
+ for (const controller of this.interceptAborts.values())
259
+ controller.abort();
260
+ this.interceptAborts.clear();
261
+ this.emitter.removeAllListeners();
262
+ try {
263
+ this.transport.close();
264
+ }
265
+ catch {
266
+ // best-effort
267
+ }
268
+ this.onDispose?.();
269
+ }
270
+ // ---- RuntimeSession: models -----------------------------------------------
271
+ async getModels() {
272
+ return (await this.request("getModels", [])) ?? [];
273
+ }
274
+ getCurrentModel() {
275
+ return this._currentModel;
276
+ }
277
+ async setModel(provider, id) {
278
+ // The `res` snapshot carries the new currentModel, applied by handleMessage.
279
+ await this.request("setModel", [provider, id]);
280
+ }
281
+ async getAllModels() {
282
+ return (await this.request("getAllModels", [])) ?? [];
283
+ }
284
+ // ---- RuntimeSession: thinking ---------------------------------------------
285
+ getThinkingLevel() {
286
+ return this._thinkingLevel;
287
+ }
288
+ setThinkingLevel(level) {
289
+ // Optimistically mirror so the daemon reads it back immediately, then notify.
290
+ this._thinkingLevel = this._availableThinkingLevels.includes(level.trim()) ? level.trim() : undefined;
291
+ this.notify("setThinkingLevel", [level]);
292
+ }
293
+ getAvailableThinkingLevels() {
294
+ return this._availableThinkingLevels;
295
+ }
296
+ supportsThinking() {
297
+ return this._supportsThinking;
298
+ }
299
+ // ---- RuntimeSession: naming -----------------------------------------------
300
+ getName() {
301
+ return this._name;
302
+ }
303
+ setName(name) {
304
+ this._name = name;
305
+ this.notify("setName", [name]);
306
+ }
307
+ async suggestName(firstPrompt) {
308
+ return this.request("suggestName", [firstPrompt]);
309
+ }
310
+ // ---- RuntimeSession: misc capabilities ------------------------------------
311
+ async interactiveTuiCommand() {
312
+ return (await this.request("interactiveTuiCommand", [])) ?? null;
313
+ }
314
+ async getUsage() {
315
+ return this.request("getUsage", []);
316
+ }
317
+ getCommands() {
318
+ return this._commands;
319
+ }
320
+ async invokeCommand(name, args) {
321
+ await this.request("invokeCommand", [name, args]);
322
+ }
323
+ }
324
+ /**
325
+ * Drive the `start` handshake on a fresh transport and return a live session.
326
+ * Buffers any events the service emits between `started` and the session being
327
+ * wired, then replays them, so the first streamed frames are never dropped.
328
+ */
329
+ export async function startRemoteSession(transport, params, options = {}) {
330
+ const startId = 1;
331
+ const buffered = [];
332
+ // Held in an object so the closures below can read it before the (single) late
333
+ // assignment once the handshake resolves.
334
+ const state = {};
335
+ let settle;
336
+ let fail;
337
+ const ready = new Promise((resolve, reject) => {
338
+ settle = resolve;
339
+ fail = reject;
340
+ });
341
+ transport.onMessage((message) => {
342
+ if (state.session) {
343
+ state.session.handleMessage(message);
344
+ return;
345
+ }
346
+ if (message.t === "started" && message.id === startId) {
347
+ settle?.(message.snapshot);
348
+ return;
349
+ }
350
+ if (message.t === "res" && message.id === startId && message.ok === false) {
351
+ fail?.(new Error(message.error));
352
+ return;
353
+ }
354
+ buffered.push(message);
355
+ });
356
+ transport.onClose((err) => {
357
+ if (state.session)
358
+ state.session.handleClose(err);
359
+ else
360
+ fail?.(err ?? new Error("agent service connection closed during start"));
361
+ });
362
+ transport.send({
363
+ t: "start",
364
+ id: startId,
365
+ protocol: RPC_PROTOCOL_VERSION,
366
+ runtime: params.runtime,
367
+ sandbox: params.sandbox,
368
+ op: params.op,
369
+ options: params.options,
370
+ });
371
+ const snapshot = await ready;
372
+ const session = new RemoteRuntimeSession(transport, snapshot, options);
373
+ state.session = session;
374
+ for (const message of buffered)
375
+ session.handleMessage(message);
376
+ return { session, warning: snapshot.warning };
377
+ }
378
+ /** Parse a remote-runtime address into `net.connect` options. */
379
+ export function parseRemoteAddress(raw) {
380
+ const value = raw.trim();
381
+ if (value.startsWith("unix:"))
382
+ return { path: value.slice("unix:".length) };
383
+ if (value.startsWith("/") || value.startsWith("./"))
384
+ return { path: value };
385
+ const lastColon = value.lastIndexOf(":");
386
+ if (lastColon >= 0) {
387
+ const host = value.slice(0, lastColon) || "127.0.0.1";
388
+ const port = Number(value.slice(lastColon + 1));
389
+ if (Number.isInteger(port) && port > 0)
390
+ return { host, port };
391
+ }
392
+ const port = Number(value);
393
+ if (Number.isInteger(port) && port > 0)
394
+ return { host: "127.0.0.1", port };
395
+ throw new Error(`Invalid remote runtime address: ${raw}`);
396
+ }
397
+ /** Connect a real socket transport to the agent service. */
398
+ export function connectSocketTransport(raw) {
399
+ const opts = parseRemoteAddress(raw);
400
+ return new Promise((resolve, reject) => {
401
+ const socket = net.connect(opts);
402
+ const decoder = new FrameDecoder();
403
+ let messageHandler;
404
+ let closeHandler;
405
+ let settled = false;
406
+ let closeErr;
407
+ socket.once("connect", () => {
408
+ settled = true;
409
+ resolve({
410
+ send(message) {
411
+ socket.write(encodeFrame(message));
412
+ },
413
+ onMessage(handler) {
414
+ messageHandler = handler;
415
+ },
416
+ onClose(handler) {
417
+ closeHandler = handler;
418
+ if (closeErr)
419
+ handler(closeErr);
420
+ },
421
+ close() {
422
+ // Graceful FIN so any just-written frame (notably the `dispose` notify,
423
+ // which the service must see to REAP rather than detach) flushes first.
424
+ // Fall back to a hard destroy if the peer doesn't close promptly.
425
+ try {
426
+ socket.end();
427
+ setTimeout(() => socket.destroy(), 1000).unref();
428
+ }
429
+ catch {
430
+ socket.destroy();
431
+ }
432
+ },
433
+ });
434
+ });
435
+ socket.on("data", (chunk) => {
436
+ let messages;
437
+ try {
438
+ messages = decoder.push(chunk);
439
+ }
440
+ catch (error) {
441
+ socket.destroy(error instanceof Error ? error : new Error(String(error)));
442
+ return;
443
+ }
444
+ if (messageHandler)
445
+ for (const message of messages)
446
+ messageHandler(message);
447
+ });
448
+ socket.on("error", (error) => {
449
+ if (!settled) {
450
+ settled = true;
451
+ reject(error);
452
+ return;
453
+ }
454
+ closeErr = error;
455
+ });
456
+ socket.on("close", () => {
457
+ if (closeHandler)
458
+ closeHandler(closeErr);
459
+ });
460
+ });
461
+ }
462
+ /**
463
+ * `AgentRuntime` facade the daemon holds in place of an in-process runtime. Each
464
+ * createSession/openSession opens its OWN transport to the agent service (one
465
+ * connection per session), which makes the service's lifecycle bookkeeping and
466
+ * child ownership per-session and unambiguous.
467
+ */
468
+ export class RemoteRuntime {
469
+ config;
470
+ id;
471
+ displayName;
472
+ capabilities;
473
+ /** The agent-service address this runtime drives (for Stage 2 advertise/route). */
474
+ agentServiceAddress;
475
+ constructor(config) {
476
+ this.config = config;
477
+ this.id = config.targetRuntime;
478
+ this.displayName = config.displayName;
479
+ this.capabilities = config.capabilities;
480
+ this.agentServiceAddress = config.agentServiceAddress;
481
+ }
482
+ async createSession(options) {
483
+ const transport = await this.config.connect();
484
+ const toolSpecs = options.toolProvider?.list();
485
+ return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "create", options: { workspace: options.workspace, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
486
+ }
487
+ async openSession(options) {
488
+ const transport = await this.config.connect();
489
+ const toolSpecs = options.toolProvider?.list();
490
+ return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "open", options: { workspace: options.workspace, sessionFile: options.sessionFile, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
491
+ }
492
+ /**
493
+ * Re-attach to a session already live on the agent service (Stage 2 routing).
494
+ * The agent service keeps a session running after a daemon disconnect (the
495
+ * "detach & keep running" policy), so ANY daemon that knows the session's
496
+ * agent-service address can rebind to the same live child via the Stage 1
497
+ * `attach` op — no in-process handle required. The daemon re-supplies its tool
498
+ * interceptor so the guardian keeps adjudicating every tool after the hand-off.
499
+ */
500
+ async attachSession(sessionId, options = {}) {
501
+ const transport = await this.config.connect();
502
+ const toolSpecs = options.toolProvider?.list();
503
+ return startRemoteSession(transport, { runtime: this.id, sandbox: this.config.sandbox, op: "attach", options: { sessionId, hasToolInterceptor: Boolean(options.toolInterceptor), ...(toolSpecs?.length ? { toolSpecs } : {}) } }, { toolInterceptor: options.toolInterceptor, toolProvider: options.toolProvider });
504
+ }
505
+ async listSessions() {
506
+ return (await this.runtimeCall("listSessions", [])) ?? [];
507
+ }
508
+ async deleteSession(sessionId, sessionFile) {
509
+ return (await this.runtimeCall("deleteSession", [sessionId, sessionFile])) ?? false;
510
+ }
511
+ /** Session-less runtime-level RPC on a short-lived connection. */
512
+ async runtimeCall(method, args) {
513
+ const transport = await this.config.connect();
514
+ return new Promise((resolve, reject) => {
515
+ const id = 1;
516
+ let done = false;
517
+ const finish = (fn) => {
518
+ if (done)
519
+ return;
520
+ done = true;
521
+ try {
522
+ transport.close();
523
+ }
524
+ catch {
525
+ // ignore
526
+ }
527
+ fn();
528
+ };
529
+ transport.onMessage((message) => {
530
+ if (message.t === "res" && message.id === id) {
531
+ if (message.ok)
532
+ finish(() => resolve(message.value));
533
+ else
534
+ finish(() => reject(new Error(message.error)));
535
+ }
536
+ });
537
+ transport.onClose((err) => finish(() => (err ? reject(err) : resolve(undefined))));
538
+ transport.send({ t: "rt", id, protocol: RPC_PROTOCOL_VERSION, runtime: this.id, sandbox: this.config.sandbox, method, args });
539
+ });
540
+ }
541
+ }
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: FSL-1.1-ALv2
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ /**
4
+ * Protocol version. Bump on any breaking change to the message shapes below.
5
+ * The handshake (`start`) carries it so a daemon and service that disagree fail
6
+ * loudly at connect time instead of misparsing frames mid-session.
7
+ */
8
+ export const RPC_PROTOCOL_VERSION = 1;
9
+ /** 4-byte big-endian length prefix; guards against a hostile/buggy peer. */
10
+ export const FRAME_HEADER_BYTES = 4;
11
+ /** Hard cap on a single decoded frame. Generous — a big diff/file read fits. */
12
+ export const MAX_RPC_FRAME_BYTES = 64 * 1024 * 1024;
13
+ // ---------------------------------------------------------------------------
14
+ // Framing codec
15
+ // ---------------------------------------------------------------------------
16
+ /** Encode one message as a length-prefixed JSON frame. */
17
+ export function encodeFrame(message) {
18
+ const json = Buffer.from(JSON.stringify(message), "utf8");
19
+ if (json.length > MAX_RPC_FRAME_BYTES) {
20
+ throw new Error(`RPC frame too large: ${json.length} > ${MAX_RPC_FRAME_BYTES}`);
21
+ }
22
+ const header = Buffer.allocUnsafe(FRAME_HEADER_BYTES);
23
+ header.writeUInt32BE(json.length, 0);
24
+ return Buffer.concat([header, json]);
25
+ }
26
+ /**
27
+ * Streaming decoder: feed it socket chunks, get back complete messages. Buffers
28
+ * partial frames across chunk boundaries. Throws on a frame that exceeds the cap
29
+ * (a corrupt/hostile length prefix) so the caller can drop the connection rather
30
+ * than allocate unbounded memory.
31
+ */
32
+ export class FrameDecoder {
33
+ buf = Buffer.alloc(0);
34
+ push(chunk) {
35
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
36
+ const out = [];
37
+ for (;;) {
38
+ if (this.buf.length < FRAME_HEADER_BYTES)
39
+ break;
40
+ const len = this.buf.readUInt32BE(0);
41
+ if (len > MAX_RPC_FRAME_BYTES) {
42
+ throw new Error(`RPC frame too large: ${len} > ${MAX_RPC_FRAME_BYTES}`);
43
+ }
44
+ if (this.buf.length < FRAME_HEADER_BYTES + len)
45
+ break;
46
+ const body = this.buf.subarray(FRAME_HEADER_BYTES, FRAME_HEADER_BYTES + len);
47
+ out.push(JSON.parse(body.toString("utf8")));
48
+ this.buf = this.buf.subarray(FRAME_HEADER_BYTES + len);
49
+ }
50
+ return out;
51
+ }
52
+ /** Bytes currently buffered awaiting a complete frame (test/introspection). */
53
+ get pending() {
54
+ return this.buf.length;
55
+ }
56
+ }