@rivet-dev/agentos-core 0.0.0-abc-fixes-1.d8c4d95 → 0.0.0-agent-acp-codex-session-benchmark.d578a31

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 (128) hide show
  1. package/README.md +48 -48
  2. package/dist/agent-os.d.ts +215 -161
  3. package/dist/agent-os.js +1300 -2576
  4. package/dist/agentos-package.d.ts +27 -0
  5. package/dist/agentos-package.js +21 -0
  6. package/dist/base-filesystem.d.ts +1 -16
  7. package/dist/base-filesystem.js +1 -173
  8. package/dist/cron/cron-manager.d.ts +39 -14
  9. package/dist/cron/cron-manager.js +289 -128
  10. package/dist/cron/errors.d.ts +8 -0
  11. package/dist/cron/errors.js +16 -0
  12. package/dist/cron/index.d.ts +1 -3
  13. package/dist/cron/index.js +1 -2
  14. package/dist/cron/types.d.ts +2 -2
  15. package/dist/default-software.d.ts +8 -0
  16. package/dist/default-software.js +10 -0
  17. package/dist/filesystem-snapshot.d.ts +1 -1
  18. package/dist/filesystem-snapshot.js +1 -1
  19. package/dist/generated/AcpLimitsConfig.d.ts +4 -0
  20. package/dist/generated/AcpLimitsConfig.js +2 -0
  21. package/dist/generated/CreateVmConfig.d.ts +29 -0
  22. package/dist/generated/FsPermissionRule.d.ts +6 -0
  23. package/dist/generated/FsPermissionRule.js +1 -0
  24. package/dist/generated/FsPermissionRuleSet.d.ts +6 -0
  25. package/dist/generated/FsPermissionRuleSet.js +1 -0
  26. package/dist/generated/FsPermissionScope.d.ts +3 -0
  27. package/dist/generated/FsPermissionScope.js +1 -0
  28. package/dist/generated/HttpLimitsConfig.d.ts +3 -0
  29. package/dist/generated/HttpLimitsConfig.js +2 -0
  30. package/dist/generated/JsModuleResolution.d.ts +1 -0
  31. package/dist/generated/JsModuleResolution.js +2 -0
  32. package/dist/generated/JsRuntimeConfig.d.ts +31 -0
  33. package/dist/generated/JsRuntimeConfig.js +1 -0
  34. package/dist/generated/JsRuntimeLimitsConfig.d.ts +8 -0
  35. package/dist/generated/JsRuntimeLimitsConfig.js +2 -0
  36. package/dist/generated/JsRuntimePlatform.d.ts +1 -0
  37. package/dist/generated/JsRuntimePlatform.js +2 -0
  38. package/dist/generated/MountPluginDescriptor.d.ts +4 -0
  39. package/dist/generated/MountPluginDescriptor.js +2 -0
  40. package/dist/generated/NativeRootFilesystemConfig.d.ts +5 -0
  41. package/dist/generated/NativeRootFilesystemConfig.js +1 -0
  42. package/dist/generated/PatternPermissionRule.d.ts +6 -0
  43. package/dist/generated/PatternPermissionRule.js +1 -0
  44. package/dist/generated/PatternPermissionRuleSet.d.ts +6 -0
  45. package/dist/generated/PatternPermissionRuleSet.js +1 -0
  46. package/dist/generated/PatternPermissionScope.d.ts +3 -0
  47. package/dist/generated/PatternPermissionScope.js +1 -0
  48. package/dist/generated/PermissionMode.d.ts +1 -0
  49. package/dist/generated/PermissionMode.js +2 -0
  50. package/dist/generated/PermissionsPolicy.d.ts +10 -0
  51. package/dist/generated/PermissionsPolicy.js +1 -0
  52. package/dist/generated/PluginLimitsConfig.d.ts +4 -0
  53. package/dist/generated/PluginLimitsConfig.js +2 -0
  54. package/dist/generated/PythonLimitsConfig.d.ts +6 -0
  55. package/dist/generated/PythonLimitsConfig.js +2 -0
  56. package/dist/generated/ResourceLimitsConfig.d.ts +25 -0
  57. package/dist/generated/ResourceLimitsConfig.js +2 -0
  58. package/dist/generated/RootFilesystemConfig.d.ts +9 -0
  59. package/dist/generated/RootFilesystemConfig.js +1 -0
  60. package/dist/generated/RootFilesystemEntry.d.ts +13 -0
  61. package/dist/generated/RootFilesystemEntry.js +1 -0
  62. package/dist/generated/RootFilesystemEntryEncoding.d.ts +1 -0
  63. package/dist/generated/RootFilesystemEntryEncoding.js +2 -0
  64. package/dist/generated/RootFilesystemEntryKind.d.ts +1 -0
  65. package/dist/generated/RootFilesystemEntryKind.js +2 -0
  66. package/dist/generated/RootFilesystemLowerDescriptor.d.ts +7 -0
  67. package/dist/generated/RootFilesystemLowerDescriptor.js +1 -0
  68. package/dist/generated/RootFilesystemMode.d.ts +1 -0
  69. package/dist/generated/RootFilesystemMode.js +2 -0
  70. package/dist/generated/ToolLimitsConfig.d.ts +10 -0
  71. package/dist/generated/ToolLimitsConfig.js +2 -0
  72. package/dist/generated/VmDnsConfig.d.ts +6 -0
  73. package/dist/generated/VmDnsConfig.js +2 -0
  74. package/dist/generated/VmLimitsConfig.d.ts +18 -0
  75. package/dist/generated/VmLimitsConfig.js +1 -0
  76. package/dist/generated/VmListenPolicyConfig.d.ts +5 -0
  77. package/dist/generated/VmListenPolicyConfig.js +2 -0
  78. package/dist/generated/WasmLimitsConfig.d.ts +7 -0
  79. package/dist/generated/WasmLimitsConfig.js +2 -0
  80. package/dist/host-dir-mount.d.ts +5 -5
  81. package/dist/host-dir-mount.js +4 -4
  82. package/dist/host-tools-zod.js +27 -7
  83. package/dist/host-tools.d.ts +2 -9
  84. package/dist/host-tools.js +0 -18
  85. package/dist/index.d.ts +7 -4
  86. package/dist/index.js +6 -4
  87. package/dist/layers.d.ts +3 -3
  88. package/dist/layers.js +19 -10
  89. package/dist/memory-filesystem.d.ts +39 -0
  90. package/dist/memory-filesystem.js +375 -0
  91. package/dist/options-schema.d.ts +452 -0
  92. package/dist/options-schema.js +269 -0
  93. package/dist/overlay-filesystem.d.ts +1 -1
  94. package/dist/overlay-filesystem.js +3 -3
  95. package/dist/packages.d.ts +10 -123
  96. package/dist/packages.js +3 -329
  97. package/dist/runtime.d.ts +41 -184
  98. package/dist/sidecar/agentos-protocol.d.ts +232 -29
  99. package/dist/sidecar/agentos-protocol.js +456 -120
  100. package/dist/sidecar/binary.d.ts +2 -2
  101. package/dist/sidecar/binary.js +2 -2
  102. package/dist/sidecar/native-process-client.d.ts +5 -3
  103. package/dist/sidecar/native-process-client.js +5 -3
  104. package/dist/sidecar/permissions.d.ts +4 -3
  105. package/dist/sidecar/permissions.js +6 -23
  106. package/dist/sidecar/rpc-client.d.ts +74 -86
  107. package/dist/sidecar/rpc-client.js +422 -1418
  108. package/dist/test/file-system.d.ts +1 -1
  109. package/dist/test/file-system.js +3 -1
  110. package/dist/test/runtime.d.ts +2 -3
  111. package/dist/test/runtime.js +1 -1
  112. package/dist/test/terminal-harness.d.ts +5 -4
  113. package/dist/test/terminal-harness.js +12 -7
  114. package/dist/types.d.ts +12 -5
  115. package/package.json +37 -41
  116. package/dist/agents.d.ts +0 -63
  117. package/dist/agents.js +0 -42
  118. package/dist/cron/parse-schedule.d.ts +0 -25
  119. package/dist/cron/parse-schedule.js +0 -66
  120. package/dist/cron/schedule-driver.d.ts +0 -19
  121. package/dist/cron/timer-driver.d.ts +0 -17
  122. package/dist/cron/timer-driver.js +0 -61
  123. package/dist/runtime-compat.d.ts +0 -475
  124. package/dist/runtime-compat.js +0 -2010
  125. package/dist/sidecar/cargo.d.ts +0 -2
  126. package/dist/sidecar/cargo.js +0 -142
  127. package/fixtures/AGENTOS_SYSTEM_PROMPT.md +0 -21
  128. /package/dist/{cron/schedule-driver.js → generated/CreateVmConfig.js} +0 -0
package/dist/agent-os.js CHANGED
@@ -1,35 +1,11 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { randomUUID } from "node:crypto";
3
- import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
- import { tmpdir } from "node:os";
5
- import { dirname, join, posix as posixPath, resolve as resolveHostPath, } from "node:path";
6
- import { fileURLToPath } from "node:url";
7
- import commonSoftware from "@agentos-software/common";
8
- import { validateToolkits } from "./host-tools.js";
2
+ import { posix as posixPath } from "node:path";
9
3
  import { zodToJsonSchema } from "./host-tools-zod.js";
4
+ import { parseAgentOsOptions } from "./options-schema.js";
10
5
  import { resolvePublishedSidecarBinary } from "./sidecar/binary.js";
11
- import { findCargoBinary, resolveCargoBinary } from "./sidecar/cargo.js";
12
6
  export { isAcpTimeoutErrorData } from "./json-rpc.js";
13
- const ACP_PROTOCOL_VERSION = 1;
14
7
  const ACP_EXTENSION_NAMESPACE = "dev.rivet.agent-os.acp";
15
8
  const SHELL_DISPOSE_TIMEOUT_MS = 5_000;
16
- /**
17
- * Reserved `env` key on `AcpResumeSessionRequest` carrying the resolved adapter
18
- * bin entrypoint. The resume wire request omits a dedicated `adapterEntrypoint`
19
- * field; the sidecar reads the entrypoint from this key and strips it before
20
- * launching the adapter. Must stay in sync with the sidecar constant of the same
21
- * name in `crates/agentos-sidecar/src/acp_extension.rs`.
22
- */
23
- const RESUME_ADAPTER_ENTRYPOINT_ENV = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT";
24
- function defaultAcpClientCapabilities() {
25
- return {
26
- fs: {
27
- readTextFile: true,
28
- writeTextFile: true,
29
- },
30
- terminal: true,
31
- };
32
- }
33
9
  async function waitForTrackedExitPromises(promises, timeoutMs) {
34
10
  if (promises.length === 0) {
35
11
  return;
@@ -41,31 +17,21 @@ async function waitForTrackedExitPromises(promises, timeoutMs) {
41
17
  }),
42
18
  ]);
43
19
  }
44
- import { AGENT_CONFIGS } from "./agents.js";
45
- import { getBaseEnvironment, getBaseFilesystemEntries, } from "./base-filesystem.js";
46
20
  import { CronManager } from "./cron/cron-manager.js";
47
- import { TimerScheduleDriver } from "./cron/timer-driver.js";
48
- import { snapshotVirtualFilesystem, sortFilesystemEntries, } from "./filesystem-snapshot.js";
49
- import { createHostDirBackend } from "./host-dir-mount.js";
21
+ import { resolveDefaultSoftware } from "./default-software.js";
50
22
  import { serializeMountConfigForSidecar, } from "./js-bridge.js";
51
23
  import { createSnapshotExport, } from "./layers.js";
52
- import { processSoftware, resolvePackageDir, } from "./packages.js";
53
- import { allowAll } from "./runtime-compat.js";
54
- import { AcpRuntimeKind, decodeAcpCallback, decodeAcpEvent, decodeAcpResponse, encodeAcpCallbackResponse, encodeAcpRequest, } from "./sidecar/agentos-protocol.js";
24
+ import { decodeAcpCallback, decodeAcpEvent, decodeAcpResponse, encodeAcpCallbackResponse, encodeAcpRequest, } from "./sidecar/agentos-protocol.js";
55
25
  import { serializePermissionsForSidecar } from "./sidecar/permissions.js";
56
- import { createAgentOsSidecarClient, NATIVE_SIDECAR_FRAME_TIMEOUT_MS, NativeSidecarKernelProxy, SidecarProcess, serializeRootFilesystemForSidecar, } from "./sidecar/rpc-client.js";
26
+ import { createAgentOsSidecarClient, NativeSidecarKernelProxy, SidecarProcess, serializeRootFilesystemForSidecar, } from "./sidecar/rpc-client.js";
27
+ function defaultAgentStderrHandler(event) {
28
+ process.stderr.write(event.chunk);
29
+ }
30
+ function defaultAgentExitHandler(event) {
31
+ process.stderr.write(`[agentos] agent adapter exited unexpectedly: session=${event.sessionId} agent=${event.agentType} exitCode=${event.exitCode ?? "unknown"} restart=${event.restart} (${event.restartCount}/${event.maxRestarts})\n`);
32
+ }
57
33
  const LEGACY_PERMISSION_METHOD = "request/permission";
58
34
  const ACP_PERMISSION_METHOD = "session/request_permission";
59
- class AcpDispatchError extends Error {
60
- code;
61
- data;
62
- constructor(code, message, data) {
63
- super(message);
64
- this.name = "AcpDispatchError";
65
- this.code = code;
66
- this.data = data;
67
- }
68
- }
69
35
  function toJsonRpcNotification(value) {
70
36
  if (!value ||
71
37
  typeof value !== "object" ||
@@ -88,24 +54,24 @@ function toJsonRpcResponse(value) {
88
54
  }
89
55
  return value;
90
56
  }
91
- function toJsonRpcRequest(value) {
92
- if (!value ||
93
- typeof value !== "object" ||
94
- Array.isArray(value) ||
95
- value.jsonrpc !== "2.0" ||
96
- !(typeof value.id === "number" ||
97
- typeof value.id === "string" ||
98
- value.id === null) ||
99
- typeof value.method !== "string") {
100
- throw new Error("Invalid JSON-RPC request from ACP callback");
101
- }
102
- return value;
103
- }
104
57
  function toRecord(value) {
105
58
  return value && typeof value === "object" && !Array.isArray(value)
106
59
  ? value
107
60
  : {};
108
61
  }
62
+ function normalizePackageRef(value) {
63
+ // The single package reference is `packagePath`: the packed `.aospkg` file
64
+ // (registry-built packages export `{ packagePath }`), or a package dir for
65
+ // local transition fixtures. A raw string is shorthand for the same path.
66
+ if (typeof value === "string") {
67
+ return { path: value };
68
+ }
69
+ const record = toRecord(value);
70
+ if (typeof record.packagePath === "string") {
71
+ return { path: record.packagePath };
72
+ }
73
+ throw new TypeError("Invalid software package reference: expected a path string or { packagePath: string }");
74
+ }
109
75
  function parseAcpJson(value, context) {
110
76
  if (value === null) {
111
77
  return undefined;
@@ -120,16 +86,6 @@ function parseAcpJson(value, context) {
120
86
  function parseAcpJsonList(values, context) {
121
87
  return values.map((value, index) => parseAcpJson(value, `${context}[${index}]`));
122
88
  }
123
- function sidecarSessionCreatedFromAcp(response) {
124
- return {
125
- sessionId: response.sessionId,
126
- ...(response.pid !== null ? { pid: response.pid } : {}),
127
- modes: parseAcpJson(response.modes, "modes"),
128
- configOptions: parseAcpJsonList(response.configOptions, "configOptions"),
129
- agentCapabilities: parseAcpJson(response.agentCapabilities, "agentCapabilities"),
130
- agentInfo: parseAcpJson(response.agentInfo, "agentInfo"),
131
- };
132
- }
133
89
  function sidecarSessionStateFromAcp(response) {
134
90
  return {
135
91
  sessionId: response.sessionId,
@@ -143,47 +99,6 @@ function sidecarSessionStateFromAcp(response) {
143
99
  agentInfo: parseAcpJson(response.agentInfo, "agentInfo"),
144
100
  };
145
101
  }
146
- function isLocalCancelledPromptResponse(method, response) {
147
- const result = toRecord(response.result);
148
- return (method === "session/prompt" &&
149
- response.id === null &&
150
- response.error === undefined &&
151
- result.stopReason === "cancelled");
152
- }
153
- const CLOSED_SESSION_ID_RETENTION_LIMIT = 2048;
154
- const CLOSED_SHELL_ID_RETENTION_LIMIT = 2048;
155
- class BoundedSet {
156
- limit;
157
- #entries = new Map();
158
- constructor(limit) {
159
- if (!Number.isInteger(limit) || limit <= 0) {
160
- throw new Error(`BoundedSet limit must be a positive integer: ${limit}`);
161
- }
162
- this.limit = limit;
163
- }
164
- add(value) {
165
- if (this.#entries.has(value)) {
166
- this.#entries.delete(value);
167
- }
168
- this.#entries.set(value, undefined);
169
- if (this.#entries.size <= this.limit) {
170
- return;
171
- }
172
- const oldest = this.#entries.keys().next();
173
- if (!oldest.done) {
174
- this.#entries.delete(oldest.value);
175
- }
176
- }
177
- has(value) {
178
- return this.#entries.has(value);
179
- }
180
- delete(value) {
181
- return this.#entries.delete(value);
182
- }
183
- get size() {
184
- return this.#entries.size;
185
- }
186
- }
187
102
  function shouldDispatchToSessionEventHandlers(notification) {
188
103
  return notification.method === "session/update";
189
104
  }
@@ -211,20 +126,15 @@ function toAgentInfo(value) {
211
126
  }
212
127
  return value;
213
128
  }
214
- function sessionEntryFromInit(sessionId, agentType, initData) {
129
+ function sessionEntryFromRoute(response) {
215
130
  return {
216
- sessionId,
217
- agentType,
218
- processId: "",
219
- pid: null,
220
- closed: false,
221
- modes: initData.modes ?? null,
222
- configOptions: initData.configOptions ?? [],
223
- capabilities: initData.capabilities ?? {},
224
- agentInfo: initData.agentInfo ?? null,
131
+ sessionId: response.sessionId,
132
+ agentType: response.agentType,
133
+ processId: response.processId,
134
+ pid: response.pid,
225
135
  eventHandlers: new Set(),
226
136
  permissionHandlers: new Set(),
227
- configOverrides: new Map(),
137
+ warnedNoPermissionHandler: false,
228
138
  pendingPermissionReplies: new Map(),
229
139
  };
230
140
  }
@@ -234,502 +144,41 @@ function isOverlayMountConfig(config) {
234
144
  function isNativeMountConfig(config) {
235
145
  return "plugin" in config;
236
146
  }
237
- function asMountConfigJsonObject(value) {
238
- if (value && typeof value === "object" && !Array.isArray(value)) {
239
- return value;
240
- }
241
- return {};
242
- }
243
- function getHostDirMountPluginConfig(config) {
244
- const object = asMountConfigJsonObject(config);
245
- if (typeof object.hostPath !== "string") {
246
- return null;
247
- }
248
- const hostPathConfig = {
249
- hostPath: object.hostPath,
250
- };
251
- if (typeof object.readOnly === "boolean") {
252
- hostPathConfig.readOnly = object.readOnly;
253
- }
254
- return hostPathConfig;
255
- }
256
- function getSandboxAgentMountPluginConfig(config) {
257
- const object = asMountConfigJsonObject(config);
258
- if (typeof object.baseUrl !== "string") {
259
- return null;
260
- }
261
- const sandboxConfig = {
262
- baseUrl: object.baseUrl,
263
- };
264
- if (typeof object.token === "string") {
265
- sandboxConfig.token = object.token;
266
- }
267
- if (typeof object.basePath === "string") {
268
- sandboxConfig.basePath = object.basePath;
269
- }
270
- if (typeof object.timeoutMs === "number") {
271
- sandboxConfig.timeoutMs = object.timeoutMs;
272
- }
273
- if (typeof object.maxFullReadBytes === "number") {
274
- sandboxConfig.maxFullReadBytes = object.maxFullReadBytes;
275
- }
276
- if (object.headers &&
277
- typeof object.headers === "object" &&
278
- !Array.isArray(object.headers)) {
279
- const headers = Object.entries(object.headers)
280
- .filter(([, value]) => typeof value === "string")
281
- .map(([name, value]) => [name, value]);
282
- if (headers.length > 0) {
283
- sandboxConfig.headers = Object.fromEntries(headers);
284
- }
285
- }
286
- return sandboxConfig;
287
- }
288
- function getS3MountPluginConfig(config) {
289
- const object = asMountConfigJsonObject(config);
290
- if (typeof object.bucket !== "string") {
291
- return null;
292
- }
293
- const s3Config = {
294
- bucket: object.bucket,
295
- };
296
- if (typeof object.prefix === "string") {
297
- s3Config.prefix = object.prefix;
298
- }
299
- if (typeof object.region === "string") {
300
- s3Config.region = object.region;
301
- }
302
- if (typeof object.endpoint === "string") {
303
- s3Config.endpoint = object.endpoint;
304
- }
305
- if (typeof object.chunkSize === "number") {
306
- s3Config.chunkSize = object.chunkSize;
307
- }
308
- if (typeof object.inlineThreshold === "number") {
309
- s3Config.inlineThreshold = object.inlineThreshold;
310
- }
311
- if (object.credentials &&
312
- typeof object.credentials === "object" &&
313
- !Array.isArray(object.credentials) &&
314
- typeof object.credentials.accessKeyId === "string" &&
315
- typeof object.credentials.secretAccessKey === "string") {
316
- s3Config.credentials = {
317
- accessKeyId: object.credentials.accessKeyId,
318
- secretAccessKey: object.credentials.secretAccessKey,
319
- };
320
- }
321
- return s3Config;
322
- }
323
- function getGoogleDriveMountPluginConfig(config) {
324
- const object = asMountConfigJsonObject(config);
325
- if (typeof object.folderId !== "string") {
326
- return null;
327
- }
328
- if (!object.credentials ||
329
- typeof object.credentials !== "object" ||
330
- Array.isArray(object.credentials) ||
331
- typeof object.credentials.clientEmail !== "string" ||
332
- typeof object.credentials.privateKey !== "string") {
333
- return null;
334
- }
335
- const googleDriveConfig = {
336
- credentials: {
337
- clientEmail: object.credentials.clientEmail,
338
- privateKey: object.credentials.privateKey,
339
- },
340
- folderId: object.folderId,
341
- };
342
- if (typeof object.keyPrefix === "string") {
343
- googleDriveConfig.keyPrefix = object.keyPrefix;
344
- }
345
- if (typeof object.chunkSize === "number") {
346
- googleDriveConfig.chunkSize = object.chunkSize;
347
- }
348
- if (typeof object.inlineThreshold === "number") {
349
- googleDriveConfig.inlineThreshold = object.inlineThreshold;
350
- }
351
- return googleDriveConfig;
352
- }
353
- const KERNEL_POSIX_BOOTSTRAP_DIRS = [
354
- "/dev",
355
- "/proc",
356
- "/tmp",
357
- "/bin",
358
- "/lib",
359
- "/sbin",
360
- "/boot",
361
- "/etc",
362
- "/root",
363
- "/run",
364
- "/srv",
365
- "/sys",
366
- "/opt",
367
- "/mnt",
368
- "/media",
369
- "/home",
370
- "/home/agentos",
371
- "/workspace",
372
- "/usr",
373
- "/usr/bin",
374
- "/usr/games",
375
- "/usr/include",
376
- "/usr/lib",
377
- "/usr/libexec",
378
- "/usr/man",
379
- "/usr/local",
380
- "/usr/local/bin",
381
- "/usr/sbin",
382
- "/usr/share",
383
- "/usr/share/man",
384
- "/var",
385
- "/var/cache",
386
- "/var/empty",
387
- "/var/lib",
388
- "/var/lock",
389
- "/var/log",
390
- "/var/run",
391
- "/var/spool",
392
- "/var/tmp",
393
- "/etc/agentos",
394
- ];
395
- const NODE_RUNTIME_BOOTSTRAP_COMMANDS = ["node", "npm", "npx"];
396
- const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n";
397
- const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
398
- const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar");
399
- const SIDECAR_BUILD_INPUTS = [
400
- join(REPO_ROOT, "Cargo.toml"),
401
- join(REPO_ROOT, "Cargo.lock"),
402
- join(REPO_ROOT, "crates/bridge"),
403
- join(REPO_ROOT, "crates/execution"),
404
- join(REPO_ROOT, "crates/kernel"),
405
- join(REPO_ROOT, "crates/sidecar"),
406
- ];
407
- let ensuredSidecarBinary = null;
408
- function isWasmBinaryFile(path) {
409
- try {
410
- const header = readFileSync(path);
411
- return (header.length >= 4 &&
412
- header[0] === 0x00 &&
413
- header[1] === 0x61 &&
414
- header[2] === 0x73 &&
415
- header[3] === 0x6d);
416
- }
417
- catch {
418
- return false;
419
- }
420
- }
421
- function collectBootstrapWasmCommands(commandDirs) {
422
- const commands = [];
423
- const seen = new Set();
424
- for (const dir of commandDirs) {
425
- let entries;
426
- try {
427
- entries = readdirSync(dir).sort((a, b) => a.localeCompare(b));
428
- }
429
- catch {
430
- continue;
431
- }
432
- for (const entry of entries) {
433
- if (entry.startsWith(".")) {
434
- continue;
435
- }
436
- const fullPath = join(dir, entry);
437
- try {
438
- if (statSync(fullPath).isDirectory()) {
439
- continue;
440
- }
441
- }
442
- catch {
443
- continue;
444
- }
445
- if (!isWasmBinaryFile(fullPath) || seen.has(entry)) {
446
- continue;
447
- }
448
- seen.add(entry);
449
- commands.push(entry);
450
- }
451
- }
452
- return commands;
453
- }
454
- function resolveDeclaredCommandSource(commandDir, commandName, aliases) {
455
- let current = commandName;
456
- const visited = new Set();
457
- while (!visited.has(current)) {
458
- visited.add(current);
459
- const candidatePath = join(commandDir, current);
460
- if (isWasmBinaryFile(candidatePath)) {
461
- return candidatePath;
462
- }
463
- const next = aliases[current];
464
- if (!next) {
465
- return null;
466
- }
467
- current = next;
468
- }
469
- return null;
470
- }
471
- function prepareCommandDirs(commandPackages) {
472
- const commandDirs = [];
473
- const tempDirs = [];
474
- try {
475
- for (const commandPackage of commandPackages) {
476
- commandDirs.push(commandPackage.commandDir);
477
- const aliasEntries = Object.entries(commandPackage.aliases)
478
- .sort(([leftAlias], [rightAlias]) => leftAlias.localeCompare(rightAlias))
479
- .flatMap(([aliasName]) => {
480
- const aliasPath = join(commandPackage.commandDir, aliasName);
481
- if (isWasmBinaryFile(aliasPath)) {
482
- return [];
483
- }
484
- const sourcePath = resolveDeclaredCommandSource(commandPackage.commandDir, aliasName, commandPackage.aliases);
485
- if (!sourcePath) {
486
- return [];
487
- }
488
- return [[aliasName, sourcePath]];
489
- });
490
- if (aliasEntries.length === 0) {
491
- continue;
492
- }
493
- const aliasDir = mkdtempSync(join(tmpdir(), "agentos-command-aliases-"));
494
- for (const [aliasName, sourcePath] of aliasEntries) {
495
- writeFileSync(join(aliasDir, aliasName), readFileSync(sourcePath));
496
- }
497
- tempDirs.push(aliasDir);
498
- commandDirs.push(aliasDir);
499
- }
500
- }
501
- catch (error) {
502
- for (const tempDir of tempDirs) {
503
- rmSync(tempDir, { recursive: true, force: true });
504
- }
505
- throw error;
506
- }
507
- return {
508
- commandDirs,
509
- dispose() {
510
- for (const tempDir of tempDirs) {
511
- rmSync(tempDir, { recursive: true, force: true });
512
- }
513
- },
514
- };
515
- }
516
- function collectConfiguredLowerPaths(config) {
517
- const paths = new Set();
518
- for (const lower of config?.lowers ?? []) {
519
- if (lower.kind !== "snapshot-export") {
520
- continue;
521
- }
522
- for (const entry of lower.source.filesystem.entries) {
523
- paths.add(entry.path);
524
- }
525
- }
526
- return paths;
527
- }
528
- function findBootstrapSeedEntry(config, path) {
529
- for (const lower of config?.lowers ?? []) {
530
- if (lower.kind !== "snapshot-export") {
531
- continue;
532
- }
533
- const entry = lower.source.filesystem.entries.find((candidate) => candidate.path === path);
534
- if (entry) {
535
- return entry;
536
- }
537
- }
538
- return getBaseFilesystemEntries().find((entry) => entry.path === path);
539
- }
540
- function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
541
- const includesBundledBaseLayer = !(config?.disableDefaultBaseLayer ?? false);
542
- const existingPaths = collectConfiguredLowerPaths(config);
543
- if (includesBundledBaseLayer) {
544
- for (const entry of getBaseFilesystemEntries()) {
545
- existingPaths.add(entry.path);
546
- }
547
- }
548
- const entries = [
549
- {
550
- path: "/",
551
- type: "directory",
552
- mode: "755",
553
- uid: 0,
554
- gid: 0,
555
- },
556
- ];
557
- for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
558
- if (existingPaths.has(dir)) {
559
- continue;
560
- }
561
- const seed = findBootstrapSeedEntry(config, dir);
562
- entries.push({
563
- path: dir,
564
- type: "directory",
565
- mode: seed?.type === "directory" ? seed.mode : "755",
566
- uid: seed?.uid ?? 0,
567
- gid: seed?.gid ?? 0,
568
- });
569
- }
570
- if (!includesBundledBaseLayer && !existingPaths.has("/usr/bin/env")) {
571
- entries.push({
572
- path: "/usr/bin/env",
573
- type: "file",
574
- mode: "644",
575
- uid: 0,
576
- gid: 0,
577
- content: "AA==",
578
- encoding: "base64",
579
- });
580
- }
581
- const uniqueCommands = [...new Set(commandNames)].sort((a, b) => a.localeCompare(b));
582
- for (const command of uniqueCommands) {
583
- const stubPath = `/bin/${command}`;
584
- if (existingPaths.has(stubPath)) {
585
- continue;
586
- }
587
- entries.push({
588
- path: stubPath,
589
- type: "file",
590
- mode: "755",
591
- uid: 0,
592
- gid: 0,
593
- content: KERNEL_COMMAND_STUB,
594
- encoding: "utf8",
595
- });
596
- }
597
- for (const entry of sortFilesystemEntries(extraEntries)) {
598
- if (existingPaths.has(entry.path)) {
599
- continue;
600
- }
601
- entries.push(entry);
602
- }
603
- return entries.length > 1 ? createSnapshotExport(entries) : null;
604
- }
605
- function buildLiveBootstrapDirectoryEntries(existingPaths, config) {
606
- const entries = [];
607
- for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
608
- if (existingPaths.has(dir)) {
609
- continue;
610
- }
611
- const seed = findBootstrapSeedEntry(config, dir);
612
- entries.push({
613
- path: dir,
614
- kind: "directory",
615
- mode: Number.parseInt(seed?.type === "directory" ? seed.mode : "755", 8),
616
- uid: seed?.uid ?? 0,
617
- gid: seed?.gid ?? 0,
618
- executable: true,
619
- });
620
- }
621
- return entries;
622
- }
623
- async function bootstrapLiveBootstrapDirectories(client, session, vm, config) {
624
- const existingPaths = new Set((await client.snapshotRootFilesystem(session, vm)).map((entry) => entry.path));
625
- const entries = buildLiveBootstrapDirectoryEntries(existingPaths, config);
626
- if (entries.length === 0) {
627
- return;
147
+ function requireSnapshotField(entry, field) {
148
+ const value = entry[field];
149
+ if (value === undefined || value === null) {
150
+ throw new Error(`sidecar root snapshot for ${entry.path} is missing ${String(field)}`);
628
151
  }
629
- await client.bootstrapRootFilesystem(session, vm, entries);
152
+ return value;
630
153
  }
631
- function toSnapshotModeString(mode, kind) {
632
- const fallback = kind === "directory" ? 0o755 : kind === "symlink" ? 0o777 : 0o644;
633
- return `0${((mode ?? fallback) & 0o7777).toString(8)}`;
154
+ function toSnapshotModeString(mode) {
155
+ return `0${(mode & 0o7777).toString(8)}`;
634
156
  }
635
157
  function convertSidecarRootSnapshotEntries(entries) {
636
158
  return entries.map((entry) => {
637
159
  const baseEntry = {
638
160
  path: entry.path,
639
161
  type: entry.kind,
640
- mode: toSnapshotModeString(entry.mode, entry.kind),
641
- uid: entry.uid ?? 0,
642
- gid: entry.gid ?? 0,
162
+ mode: toSnapshotModeString(requireSnapshotField(entry, "mode")),
163
+ uid: requireSnapshotField(entry, "uid"),
164
+ gid: requireSnapshotField(entry, "gid"),
643
165
  };
644
166
  if (entry.kind === "file") {
645
167
  return {
646
168
  ...baseEntry,
647
- content: entry.content ?? "",
648
- encoding: entry.encoding ?? "utf8",
169
+ content: requireSnapshotField(entry, "content"),
170
+ encoding: requireSnapshotField(entry, "encoding"),
649
171
  };
650
172
  }
651
173
  if (entry.kind === "symlink") {
652
- if (entry.target === undefined) {
653
- throw new Error(`sidecar root snapshot for ${entry.path} is missing a symlink target`);
654
- }
655
174
  return {
656
175
  ...baseEntry,
657
- target: entry.target,
176
+ target: requireSnapshotField(entry, "target"),
658
177
  };
659
178
  }
660
179
  return baseEntry;
661
180
  });
662
181
  }
663
- function ensureNativeSidecarBinary() {
664
- // A published install has no in-repo Cargo workspace to build from: resolve
665
- // the prebuilt platform binary (or the AGENTOS_SIDECAR_BIN override).
666
- if (process.env.AGENTOS_SIDECAR_BIN ||
667
- !existsSync(join(REPO_ROOT, "Cargo.toml"))) {
668
- return resolvePublishedSidecarBinary();
669
- }
670
- if (ensuredSidecarBinary &&
671
- existsSync(ensuredSidecarBinary) &&
672
- !sidecarBinaryNeedsBuild()) {
673
- return ensuredSidecarBinary;
674
- }
675
- if (sidecarBinaryNeedsBuild()) {
676
- const cargoBinary = findCargoBinary();
677
- if (cargoBinary) {
678
- execFileSync(cargoBinary, ["build", "-q", "-p", "agentos-sidecar"], {
679
- cwd: REPO_ROOT,
680
- stdio: "pipe",
681
- });
682
- }
683
- else if (!existsSync(SIDECAR_BINARY)) {
684
- execFileSync(resolveCargoBinary(), ["build", "-q", "-p", "agentos-sidecar"], {
685
- cwd: REPO_ROOT,
686
- stdio: "pipe",
687
- });
688
- }
689
- }
690
- ensuredSidecarBinary = SIDECAR_BINARY;
691
- return ensuredSidecarBinary;
692
- }
693
- function sidecarBinaryNeedsBuild() {
694
- if (!existsSync(SIDECAR_BINARY)) {
695
- return true;
696
- }
697
- const binaryMtimeMs = statSync(SIDECAR_BINARY).mtimeMs;
698
- return SIDECAR_BUILD_INPUTS.some((path) => existsSync(path) && latestMtimeMs(path) > binaryMtimeMs);
699
- }
700
- function latestMtimeMs(path) {
701
- const stats = statSync(path);
702
- if (!stats.isDirectory()) {
703
- return stats.mtimeMs;
704
- }
705
- let latest = stats.mtimeMs;
706
- for (const entry of readdirSync(path)) {
707
- latest = Math.max(latest, latestMtimeMs(join(path, entry)));
708
- }
709
- return latest;
710
- }
711
- function collectGuestCommandPaths(commandDirs) {
712
- const guestPaths = new Map();
713
- for (const [index, commandDir] of commandDirs.entries()) {
714
- let entries;
715
- try {
716
- entries = readdirSync(commandDir).sort((left, right) => left.localeCompare(right));
717
- }
718
- catch {
719
- continue;
720
- }
721
- for (const entry of entries) {
722
- if (entry.startsWith(".")) {
723
- continue;
724
- }
725
- if (!isWasmBinaryFile(join(commandDir, entry)) || guestPaths.has(entry)) {
726
- continue;
727
- }
728
- guestPaths.set(entry, `/__secure_exec/commands/${index}/${entry}`);
729
- }
730
- }
731
- return guestPaths;
732
- }
733
182
  async function resolveCompatLocalMounts(mounts) {
734
183
  if (!mounts) {
735
184
  return [];
@@ -743,7 +192,7 @@ async function resolveCompatLocalMounts(mounts) {
743
192
  resolved.push({
744
193
  path: posixPath.normalize(mount.path),
745
194
  fs: mount.driver,
746
- readOnly: mount.readOnly ?? false,
195
+ readOnly: mount.readOnly,
747
196
  });
748
197
  continue;
749
198
  }
@@ -760,15 +209,13 @@ async function resolveCompatLocalMounts(mounts) {
760
209
  resolved.push({
761
210
  path: posixPath.normalize(mount.path),
762
211
  fs,
763
- readOnly: mode === "read-only",
212
+ readOnly: mount.filesystem.mode === undefined ? undefined : mode === "read-only",
764
213
  });
765
214
  }
766
215
  return resolved;
767
216
  }
768
217
  function collectSidecarMountPlan(options) {
769
218
  const sidecarMounts = [];
770
- const hostMounts = [];
771
- const hostPathMappings = [];
772
219
  const seenMounts = new Set();
773
220
  function pushMount(mount) {
774
221
  const serialized = serializeMountConfigForSidecar(mount);
@@ -778,77 +225,26 @@ function collectSidecarMountPlan(options) {
778
225
  }
779
226
  seenMounts.add(key);
780
227
  sidecarMounts.push(serialized);
781
- if (mount.plugin.id === "host_dir") {
782
- const config = getHostDirMountPluginConfig(mount.plugin.config);
783
- if (config) {
784
- hostPathMappings.push({
785
- vmPath: posixPath.normalize(mount.path),
786
- hostPath: resolveHostPath(config.hostPath),
787
- readOnly: mount.readOnly ?? config.readOnly ?? true,
788
- });
789
- }
790
- if (config && options.mounts?.some((candidate) => candidate === mount)) {
791
- hostMounts.push({
792
- vmPath: posixPath.normalize(mount.path),
793
- hostPath: resolveHostPath(config.hostPath),
794
- readOnly: mount.readOnly ?? config.readOnly ?? true,
795
- });
796
- }
797
- }
798
228
  }
799
229
  for (const mount of options.mounts ?? []) {
800
230
  if (!isNativeMountConfig(mount)) {
231
+ const readOnly = isOverlayMountConfig(mount)
232
+ ? mount.filesystem.mode === undefined
233
+ ? undefined
234
+ : mount.filesystem.mode === "read-only"
235
+ : mount.readOnly;
236
+ sidecarMounts.push({
237
+ guestPath: mount.path,
238
+ ...(readOnly === undefined ? {} : { readOnly }),
239
+ plugin: {
240
+ id: "js_bridge",
241
+ },
242
+ });
801
243
  continue;
802
244
  }
803
245
  pushMount(mount);
804
246
  }
805
- for (const root of options.softwareRoots) {
806
- pushMount({
807
- path: root.vmPath,
808
- plugin: createHostDirBackend({
809
- hostPath: root.hostPath,
810
- readOnly: true,
811
- }),
812
- readOnly: true,
813
- });
814
- }
815
- for (const [index, commandDir] of options.commandDirs.entries()) {
816
- pushMount({
817
- path: `/__secure_exec/commands/${index}`,
818
- plugin: createHostDirBackend({
819
- hostPath: commandDir,
820
- readOnly: true,
821
- }),
822
- readOnly: true,
823
- });
824
- }
825
- if (options.shimDir) {
826
- pushMount({
827
- path: "/usr/local/bin",
828
- plugin: createHostDirBackend({
829
- hostPath: options.shimDir,
830
- readOnly: true,
831
- }),
832
- readOnly: true,
833
- });
834
- }
835
- hostMounts.sort((left, right) => right.vmPath.length - left.vmPath.length);
836
- hostPathMappings.sort((left, right) => right.vmPath.length - left.vmPath.length);
837
- return { sidecarMounts, hostMounts, hostPathMappings };
838
- }
839
- function materializeToolShimDir(toolKits) {
840
- const shimDir = mkdtempSync(join(tmpdir(), "agentos-host-tools-shims-"));
841
- writeFileSync(join(shimDir, "agentos"), KERNEL_COMMAND_STUB, { mode: 0o755 });
842
- for (const toolKit of toolKits) {
843
- writeFileSync(join(shimDir, `agentos-${toolKit.name}`), KERNEL_COMMAND_STUB, { mode: 0o755 });
844
- }
845
- return shimDir;
846
- }
847
- function collectToolkitBootstrapCommands(toolKits) {
848
- if (toolKits.length === 0) {
849
- return [];
850
- }
851
- return ["agentos", ...toolKits.map((toolKit) => `agentos-${toolKit.name}`)];
247
+ return { sidecarMounts };
852
248
  }
853
249
  function validationMessage(error) {
854
250
  if (typeof error === "object" &&
@@ -866,6 +262,29 @@ function validationMessage(error) {
866
262
  }
867
263
  return error instanceof Error ? error.message : String(error);
868
264
  }
265
+ function requireVmFetchResponsePayload(value) {
266
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
267
+ throw new Error("sidecar vm.fetch response must be an object");
268
+ }
269
+ const payload = value;
270
+ if (!Number.isInteger(payload.status)) {
271
+ throw new Error("sidecar vm.fetch response is missing numeric status");
272
+ }
273
+ if (typeof payload.statusText !== "string") {
274
+ throw new Error("sidecar vm.fetch response is missing statusText");
275
+ }
276
+ if (!Array.isArray(payload.headers) ||
277
+ payload.headers.some((entry) => !Array.isArray(entry) ||
278
+ entry.length !== 2 ||
279
+ typeof entry[0] !== "string" ||
280
+ typeof entry[1] !== "string")) {
281
+ throw new Error("sidecar vm.fetch response is missing valid headers");
282
+ }
283
+ if (typeof payload.body !== "string") {
284
+ throw new Error("sidecar vm.fetch response is missing base64 body");
285
+ }
286
+ return payload;
287
+ }
869
288
  function toolToSidecarDefinition(tool) {
870
289
  return {
871
290
  description: tool.description,
@@ -881,126 +300,6 @@ function toolToSidecarDefinition(tool) {
881
300
  : {}),
882
301
  };
883
302
  }
884
- function combineInstructions(additionalInstructions, toolReference) {
885
- const parts = [additionalInstructions, toolReference]
886
- .map((part) => part?.trim())
887
- .filter((part) => Boolean(part));
888
- if (parts.length === 0) {
889
- return null;
890
- }
891
- return parts.join("\n\n");
892
- }
893
- function buildHostToolReference(toolKits) {
894
- if (toolKits.length === 0) {
895
- return "";
896
- }
897
- const lines = [
898
- "## Available Host Tools",
899
- "",
900
- "Run `agentos list-tools` to see all available tools.",
901
- "",
902
- ];
903
- for (const toolKit of toolKits) {
904
- lines.push(`### ${toolKit.name}`);
905
- lines.push("");
906
- lines.push(toolKit.description);
907
- lines.push("");
908
- for (const [toolName, tool] of Object.entries(toolKit.tools)) {
909
- const sidecarTool = toolToSidecarDefinition(tool);
910
- const signature = buildToolFlagSignature(sidecarTool.inputSchema);
911
- const suffix = signature.length > 0 ? ` ${signature}` : "";
912
- lines.push(`- \`agentos-${toolKit.name} ${toolName}${suffix}\` — ${tool.description}`);
913
- }
914
- lines.push("");
915
- const toolsWithExamples = Object.entries(toolKit.tools).filter(([, tool]) => tool.examples && tool.examples.length > 0);
916
- if (toolsWithExamples.length > 0) {
917
- lines.push("**Examples:**");
918
- lines.push("");
919
- for (const [toolName, tool] of toolsWithExamples) {
920
- for (const example of tool.examples ?? []) {
921
- const args = inputToToolFlags(example.input);
922
- const suffix = args.length > 0 ? ` ${args}` : "";
923
- lines.push(`- ${example.description}: \`agentos-${toolKit.name} ${toolName}${suffix}\``);
924
- }
925
- }
926
- lines.push("");
927
- }
928
- lines.push(`Run \`agentos-${toolKit.name} <tool> --help\` for details.`);
929
- lines.push("");
930
- }
931
- return lines.join("\n");
932
- }
933
- function buildToolFlagSignature(schema) {
934
- return describeToolFlags(schema)
935
- .map((flag) => {
936
- if (flag.required) {
937
- return `${flag.name} <${flag.type}>`;
938
- }
939
- return `[${flag.name} <${flag.type}>]`;
940
- })
941
- .join(" ");
942
- }
943
- function describeToolFlags(schema) {
944
- const schemaObject = asRecord(schema);
945
- const properties = asRecord(schemaObject.properties);
946
- const required = Array.isArray(schemaObject.required)
947
- ? new Set(schemaObject.required.filter((item) => typeof item === "string"))
948
- : new Set();
949
- return Object.entries(properties).map(([fieldName, fieldSchema]) => ({
950
- name: `--${camelToKebab(fieldName)}`,
951
- type: describeToolFlagType(fieldSchema),
952
- required: required.has(fieldName),
953
- }));
954
- }
955
- function describeToolFlagType(schema) {
956
- const schemaObject = asRecord(schema);
957
- const type = typeof schemaObject.type === "string" ? schemaObject.type : undefined;
958
- if (type === "array") {
959
- const itemType = describeJsonSchemaScalarType(schemaObject.items);
960
- return `${itemType}[]`;
961
- }
962
- if (type === "string") {
963
- const enumValues = Array.isArray(schemaObject.enum)
964
- ? schemaObject.enum.filter((item) => typeof item === "string")
965
- : [];
966
- return enumValues.length > 0 ? enumValues.join("|") : "string";
967
- }
968
- return type ?? "string";
969
- }
970
- function describeJsonSchemaScalarType(schema) {
971
- const schemaObject = asRecord(schema);
972
- return typeof schemaObject.type === "string" ? schemaObject.type : "string";
973
- }
974
- function inputToToolFlags(input) {
975
- const inputObject = asRecord(input);
976
- return Object.entries(inputObject)
977
- .flatMap(([key, value]) => {
978
- const flag = `--${camelToKebab(key)}`;
979
- if (value === true) {
980
- return [flag];
981
- }
982
- if (value === false) {
983
- return [`--no-${camelToKebab(key)}`];
984
- }
985
- if (Array.isArray(value)) {
986
- return value.map((item) => `${flag} ${toolCliString(item)}`);
987
- }
988
- return [`${flag} ${toolCliString(value)}`];
989
- })
990
- .join(" ");
991
- }
992
- function toolCliString(value) {
993
- return typeof value === "string" ? value : (JSON.stringify(value) ?? "null");
994
- }
995
- function camelToKebab(value) {
996
- return value.replace(/[A-Z]/g, (ch, index) => `${index > 0 ? "-" : ""}${ch.toLowerCase()}`);
997
- }
998
- function asRecord(value) {
999
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1000
- return value;
1001
- }
1002
- return {};
1003
- }
1004
303
  async function handleHostCallback(request, context) {
1005
304
  const payload = request.payload;
1006
305
  if (payload.type !== "host_callback") {
@@ -1010,23 +309,6 @@ async function handleHostCallback(request, context) {
1010
309
  error: `unsupported sidecar request type: ${payload.type}`,
1011
310
  };
1012
311
  }
1013
- const command = parseHostCommandCallbackInput(payload.input);
1014
- if (command) {
1015
- try {
1016
- return {
1017
- type: "host_callback_result",
1018
- invocation_id: payload.invocation_id,
1019
- result: await handleHostCommandCallback(command, context),
1020
- };
1021
- }
1022
- catch (error) {
1023
- return {
1024
- type: "host_callback_result",
1025
- invocation_id: payload.invocation_id,
1026
- error: validationMessage(error),
1027
- };
1028
- }
1029
- }
1030
312
  const tool = context.toolMap.get(payload.callback_key);
1031
313
  if (!tool) {
1032
314
  return {
@@ -1035,14 +317,6 @@ async function handleHostCallback(request, context) {
1035
317
  error: `Unknown tool "${payload.callback_key}"`,
1036
318
  };
1037
319
  }
1038
- const permissionMode = toolPermissionMode(context.permissions, payload.callback_key);
1039
- if (permissionMode !== "allow") {
1040
- return {
1041
- type: "host_callback_result",
1042
- invocation_id: payload.invocation_id,
1043
- error: `EACCES: blocked by tool.invoke policy for ${payload.callback_key}`,
1044
- };
1045
- }
1046
320
  const parsed = tool.inputSchema.safeParse(payload.input);
1047
321
  if (!parsed.success) {
1048
322
  return {
@@ -1055,7 +329,7 @@ async function handleHostCallback(request, context) {
1055
329
  return {
1056
330
  type: "host_callback_result",
1057
331
  invocation_id: payload.invocation_id,
1058
- result: await executeHostTool(tool, payload.callback_key, parsed.data),
332
+ result: await tool.execute(parsed.data),
1059
333
  };
1060
334
  }
1061
335
  catch (error) {
@@ -1075,348 +349,186 @@ function buildToolMap(toolKits) {
1075
349
  }
1076
350
  return toolMap;
1077
351
  }
1078
- function parseHostCommandCallbackInput(input) {
1079
- const value = asRecord(input);
1080
- if (value.type !== "command" ||
1081
- typeof value.command !== "string" ||
1082
- typeof value.cwd !== "string" ||
1083
- !Array.isArray(value.args) ||
1084
- !value.args.every((arg) => typeof arg === "string")) {
1085
- return null;
1086
- }
1087
- return {
1088
- type: "command",
1089
- command: value.command,
1090
- args: value.args,
1091
- cwd: value.cwd,
1092
- };
352
+ function bridgeErrorMessage(error) {
353
+ return error instanceof Error ? error.message : String(error);
1093
354
  }
1094
- async function handleHostCommandCallback(command, context) {
1095
- const directToolKit = context.toolKits.find((toolKit) => `agentos-${toolKit.name}` === command.command);
1096
- if (command.command === "agentos") {
1097
- return handleAgentOsRegistryCommand(command, context);
1098
- }
1099
- if (directToolKit) {
1100
- return handleAgentOsToolkitCommand(command, context, directToolKit);
355
+ function toBridgeArgs(value) {
356
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
357
+ throw new Error("js_bridge args must be an object");
1101
358
  }
1102
- throw new Error(`Unknown host callback command "${command.command}"`);
359
+ return value;
1103
360
  }
1104
- async function handleAgentOsRegistryCommand(command, context) {
1105
- const [subcommand, toolkitName, toolName, ...toolArgs] = command.args;
1106
- if (!subcommand || isHelpFlag(subcommand)) {
1107
- return {
1108
- usage: "agentos <command>: list-tools [toolkit], <toolkit> --help, or <toolkit> <tool> ...",
1109
- };
1110
- }
1111
- if (subcommand === "list-tools") {
1112
- return toolkitName
1113
- ? describeToolkitPayload(context.toolKits, toolkitName)
1114
- : listToolkitsPayload(context.toolKits);
361
+ function bridgePath(mountId, value) {
362
+ if (!mountId.startsWith("/")) {
363
+ throw new Error(`Unsupported js_bridge mount id: ${mountId}`);
1115
364
  }
1116
- const toolKit = context.toolKits.find((kit) => kit.name === subcommand);
1117
- if (!toolKit) {
1118
- throw new Error(`No toolkit "${subcommand}". Available: ${toolkitNames(context.toolKits)}`);
365
+ if (typeof value !== "string") {
366
+ throw new Error("js_bridge path argument must be a string");
1119
367
  }
1120
- if (!toolkitName || isHelpFlag(toolkitName)) {
1121
- return describeToolkitPayload(context.toolKits, subcommand);
1122
- }
1123
- if (toolName && isHelpFlag(toolName)) {
1124
- return describeToolPayload(toolKit, toolkitName);
1125
- }
1126
- return invokeHostTool({
1127
- toolKit,
1128
- toolName: toolkitName,
1129
- args: [toolName, ...toolArgs].filter((value) => typeof value === "string"),
1130
- cwd: command.cwd,
1131
- context,
1132
- });
368
+ return posixPath.normalize(posixPath.join(mountId, value));
1133
369
  }
1134
- async function handleAgentOsToolkitCommand(command, context, toolKit) {
1135
- const [toolName, helpOrFirstArg, ...rest] = command.args;
1136
- if (!toolName || isHelpFlag(toolName)) {
1137
- return describeToolkitPayload(context.toolKits, toolKit.name);
1138
- }
1139
- if (helpOrFirstArg && isHelpFlag(helpOrFirstArg)) {
1140
- return describeToolPayload(toolKit, toolName);
1141
- }
1142
- return invokeHostTool({
1143
- toolKit,
1144
- toolName,
1145
- args: [helpOrFirstArg, ...rest].filter((value) => typeof value === "string"),
1146
- cwd: command.cwd,
1147
- context,
1148
- });
370
+ function requireBridgeNumber(value, field) {
371
+ if (typeof value !== "number" || !Number.isFinite(value)) {
372
+ throw new Error(`js_bridge args.${field} must be a number`);
373
+ }
374
+ return value;
1149
375
  }
1150
- async function invokeHostTool({ toolKit, toolName, args, cwd, context, }) {
1151
- const tool = toolKit.tools[toolName];
1152
- if (!tool) {
1153
- throw new Error(`No tool "${toolName}" in toolkit "${toolKit.name}". Available: ${toolNames(toolKit)}`);
376
+ function decodeBridgeBytes(value, field) {
377
+ if (typeof value === "string") {
378
+ return new Uint8Array(Buffer.from(value, "base64"));
1154
379
  }
1155
- const callbackKey = `${toolKit.name}:${toolName}`;
1156
- const permissionMode = toolPermissionMode(context.permissions, callbackKey);
1157
- if (permissionMode !== "allow") {
1158
- throw new Error(`EACCES: blocked by tool.invoke policy for ${callbackKey}`);
380
+ if (Array.isArray(value) &&
381
+ value.every((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 255)) {
382
+ return new Uint8Array(value);
1159
383
  }
1160
- const input = await parseHostToolInput(tool, args, cwd, context.readFile);
1161
- return executeHostTool(tool, callbackKey, input);
384
+ throw new Error(`js_bridge args.${field} must be base64 bytes`);
1162
385
  }
1163
- async function executeHostTool(tool, callbackKey, input) {
1164
- const parsed = tool.inputSchema.safeParse(input);
1165
- if (!parsed.success) {
1166
- throw new Error(validationMessage(parsed.error));
1167
- }
1168
- return Promise.race([
1169
- Promise.resolve(tool.execute(parsed.data)),
1170
- new Promise((_, reject) => {
1171
- if (tool.timeout === undefined) {
1172
- return;
1173
- }
1174
- setTimeout(() => reject(new Error(`Tool "${callbackKey}" timed out after ${tool.timeout}ms`)), tool.timeout);
1175
- }),
1176
- ]);
1177
- }
1178
- async function parseHostToolInput(tool, args, cwd, readFile) {
1179
- if (args[0] === "--json") {
1180
- const value = args[1];
1181
- if (value === undefined) {
1182
- throw new Error("Flag --json requires a value");
1183
- }
1184
- return JSON.parse(value);
1185
- }
1186
- if (args[0] === "--json-file") {
1187
- const value = args[1];
1188
- if (value === undefined) {
1189
- throw new Error("Flag --json-file requires a value");
1190
- }
1191
- const guestPath = value.startsWith("/")
1192
- ? posixPath.normalize(value)
1193
- : posixPath.normalize(`${cwd}/${value}`);
1194
- const text = new TextDecoder().decode(await readFile(guestPath));
1195
- return JSON.parse(text);
1196
- }
1197
- return parseToolArgv(toolToSidecarDefinition(tool).inputSchema, args);
1198
- }
1199
- function parseToolArgv(schema, argv) {
1200
- const schemaObject = asRecord(schema);
1201
- const properties = asRecord(schemaObject.properties);
1202
- const required = Array.isArray(schemaObject.required)
1203
- ? new Set(schemaObject.required.filter((value) => typeof value === "string"))
1204
- : new Set();
1205
- const flagToField = new Map();
1206
- for (const [fieldName, fieldSchema] of Object.entries(properties)) {
1207
- flagToField.set(camelToKebab(fieldName), [fieldName, fieldSchema]);
1208
- }
1209
- const input = {};
1210
- for (let index = 0; index < argv.length;) {
1211
- const arg = argv[index];
1212
- if (!arg?.startsWith("--")) {
1213
- throw new Error(`Unexpected positional argument: "${arg}"`);
1214
- }
1215
- const rawFlag = arg.slice(2);
1216
- const negated = rawFlag.startsWith("no-");
1217
- const flagName = negated ? rawFlag.slice(3) : rawFlag;
1218
- const entry = flagToField.get(flagName);
1219
- if (!entry) {
1220
- throw new Error(`Unknown flag: --${rawFlag}`);
1221
- }
1222
- const [fieldName, fieldSchema] = entry;
1223
- const fieldType = jsonSchemaType(fieldSchema);
1224
- if (negated) {
1225
- if (fieldType !== "boolean") {
1226
- throw new Error(`Unknown flag: --${rawFlag}`);
1227
- }
1228
- input[fieldName] = false;
1229
- index += 1;
1230
- continue;
1231
- }
1232
- if (fieldType === "boolean") {
1233
- input[fieldName] = true;
1234
- index += 1;
1235
- continue;
1236
- }
1237
- const value = argv[index + 1];
1238
- if (value === undefined) {
1239
- throw new Error(`Flag --${rawFlag} requires a value`);
1240
- }
1241
- if (fieldType === "number" || fieldType === "integer") {
1242
- const number = Number(value);
1243
- if (!Number.isFinite(number)) {
1244
- throw new Error(`Flag --${rawFlag} expects a number, got "${value}"`);
386
+ async function handleJsBridgeCall(request, context) {
387
+ try {
388
+ const args = toBridgeArgs(request.args);
389
+ const target = context.resolveTarget(request.mount_id);
390
+ if (!target) {
391
+ throw new Error(`Unknown js_bridge mount id: ${request.mount_id}`);
392
+ }
393
+ const fs = target.filesystem;
394
+ const path = () => bridgePath(target.rootPath, args.path);
395
+ let result;
396
+ switch (request.operation) {
397
+ case "readFile":
398
+ result = Buffer.from(await fs.readFile(path())).toString("base64");
399
+ break;
400
+ case "readDir":
401
+ result = await fs.readDir(path());
402
+ break;
403
+ case "readDirWithTypes":
404
+ result = await fs.readDirWithTypes(path());
405
+ break;
406
+ case "writeFile":
407
+ await fs.writeFile(path(), decodeBridgeBytes(args.content, "content"));
408
+ break;
409
+ case "createDir":
410
+ await fs.createDir(path());
411
+ break;
412
+ case "mkdir":
413
+ await fs.mkdir(path(), { recursive: args.recursive !== false });
414
+ break;
415
+ case "exists":
416
+ result = await fs.exists(path());
417
+ break;
418
+ case "stat":
419
+ result = await fs.stat(path());
420
+ break;
421
+ case "removeFile":
422
+ await fs.removeFile(path());
423
+ break;
424
+ case "removeDir":
425
+ await fs.removeDir(path());
426
+ break;
427
+ case "rename":
428
+ await fs.rename(bridgePath(target.rootPath, args.oldPath), bridgePath(target.rootPath, args.newPath));
429
+ break;
430
+ case "realpath":
431
+ result = await fs.realpath(path());
432
+ break;
433
+ case "symlink": {
434
+ if (typeof args.target !== "string") {
435
+ throw new Error("js_bridge args.target must be a string");
436
+ }
437
+ await fs.symlink(args.target, bridgePath(target.rootPath, args.linkPath));
438
+ break;
1245
439
  }
1246
- input[fieldName] = number;
1247
- index += 2;
1248
- continue;
1249
- }
1250
- if (fieldType === "array") {
1251
- const current = Array.isArray(input[fieldName])
1252
- ? input[fieldName]
1253
- : [];
1254
- const itemSchema = asRecord(fieldSchema).items;
1255
- const itemType = jsonSchemaType(itemSchema);
1256
- current.push(itemType === "number" || itemType === "integer" ? Number(value) : value);
1257
- input[fieldName] = current;
1258
- index += 2;
1259
- continue;
1260
- }
1261
- input[fieldName] = value;
1262
- index += 2;
1263
- }
1264
- for (const fieldName of required) {
1265
- if (!(fieldName in input)) {
1266
- throw new Error(`Missing required flag: --${camelToKebab(fieldName)}`);
440
+ case "readlink":
441
+ result = await fs.readlink(path());
442
+ break;
443
+ case "lstat":
444
+ result = await fs.lstat(path());
445
+ break;
446
+ case "link":
447
+ await fs.link(bridgePath(target.rootPath, args.oldPath), bridgePath(target.rootPath, args.newPath));
448
+ break;
449
+ case "chmod":
450
+ await fs.chmod(path(), requireBridgeNumber(args.mode, "mode"));
451
+ break;
452
+ case "chown":
453
+ await fs.chown(path(), requireBridgeNumber(args.uid, "uid"), requireBridgeNumber(args.gid, "gid"));
454
+ break;
455
+ case "utimes":
456
+ await fs.utimes(path(), requireBridgeNumber(args.atimeMs, "atimeMs"), requireBridgeNumber(args.mtimeMs, "mtimeMs"));
457
+ break;
458
+ case "truncate":
459
+ await fs.truncate(path(), requireBridgeNumber(args.length, "length"));
460
+ break;
461
+ case "pread":
462
+ result = Buffer.from(await fs.pread(path(), requireBridgeNumber(args.offset, "offset"), requireBridgeNumber(args.length, "length"))).toString("base64");
463
+ break;
464
+ case "pwrite":
465
+ await fs.pwrite(path(), requireBridgeNumber(args.offset, "offset"), decodeBridgeBytes(args.content, "content"));
466
+ break;
467
+ default:
468
+ throw new Error(`Unsupported js_bridge operation: ${request.operation}`);
1267
469
  }
470
+ return {
471
+ type: "js_bridge_result",
472
+ call_id: request.call_id,
473
+ ...(result === undefined ? {} : { result }),
474
+ };
1268
475
  }
1269
- return input;
1270
- }
1271
- function listToolkitsPayload(toolKits) {
1272
- return {
1273
- toolkits: toolKits.map((toolKit) => ({
1274
- name: toolKit.name,
1275
- description: toolKit.description,
1276
- tools: Object.keys(toolKit.tools),
1277
- })),
1278
- };
1279
- }
1280
- function describeToolkitPayload(toolKits, toolkitName) {
1281
- const toolKit = toolKits.find((kit) => kit.name === toolkitName);
1282
- if (!toolKit) {
1283
- throw new Error(`No toolkit "${toolkitName}". Available: ${toolkitNames(toolKits)}`);
1284
- }
1285
- return {
1286
- name: toolKit.name,
1287
- description: toolKit.description,
1288
- tools: Object.fromEntries(Object.entries(toolKit.tools).map(([toolName, tool]) => [
1289
- toolName,
1290
- {
1291
- description: tool.description,
1292
- flags: describeToolFlags(toolToSidecarDefinition(tool).inputSchema),
1293
- },
1294
- ])),
1295
- };
1296
- }
1297
- function describeToolPayload(toolKit, toolName) {
1298
- const tool = toolKit.tools[toolName];
1299
- if (!tool) {
1300
- throw new Error(`No tool "${toolName}" in toolkit "${toolKit.name}". Available: ${toolNames(toolKit)}`);
1301
- }
1302
- return {
1303
- toolkit: toolKit.name,
1304
- tool: toolName,
1305
- description: tool.description,
1306
- flags: describeToolFlags(toolToSidecarDefinition(tool).inputSchema),
1307
- examples: tool.examples?.map((example) => ({
1308
- description: example.description,
1309
- input: example.input,
1310
- })) ?? [],
1311
- };
1312
- }
1313
- function toolPermissionMode(permissions, callbackKey) {
1314
- const scope = permissions.tool;
1315
- if (!scope) {
1316
- return "deny";
1317
- }
1318
- if (typeof scope === "string") {
1319
- return scope;
1320
- }
1321
- let mode = scope.default ?? "deny";
1322
- for (const rule of scope.rules) {
1323
- const operations = rule.operations ?? ["*"];
1324
- const patterns = rule.patterns ?? ["**"];
1325
- if (operations.some((operation) => operation === "*" || operation === "invoke") &&
1326
- patterns.some((pattern) => permissionPatternMatches(pattern, callbackKey))) {
1327
- mode = rule.mode;
1328
- }
476
+ catch (error) {
477
+ return {
478
+ type: "js_bridge_result",
479
+ call_id: request.call_id,
480
+ error: bridgeErrorMessage(error),
481
+ };
1329
482
  }
1330
- return mode;
1331
- }
1332
- function permissionPatternMatches(pattern, value) {
1333
- if (pattern === "*" || pattern === "**" || pattern === value) {
1334
- return true;
1335
- }
1336
- const parts = pattern.split(/(\*\*|\*)/u);
1337
- const source = parts
1338
- .map((part) => {
1339
- if (part === "**")
1340
- return ".*";
1341
- if (part === "*")
1342
- return "[^:]*";
1343
- return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
1344
- })
1345
- .join("");
1346
- return new RegExp(`^${source}$`).test(value);
1347
483
  }
1348
- function toolkitNames(toolKits) {
1349
- return toolKits.map((toolKit) => toolKit.name).join(", ");
1350
- }
1351
- function toolNames(toolKit) {
1352
- return Object.keys(toolKit.tools).join(", ");
1353
- }
1354
- function isHelpFlag(value) {
1355
- return value === "--help" || value === "-h";
1356
- }
1357
- function jsonSchemaType(schema) {
1358
- const schemaObject = asRecord(schema);
1359
- return typeof schemaObject.type === "string" ? schemaObject.type : undefined;
1360
- }
1361
- async function registerToolkitsOnSidecar(client, session, vm, toolKits) {
1362
- if (toolKits.length === 0) {
1363
- return "";
1364
- }
1365
- for (const toolKit of toolKits) {
1366
- await client.registerHostCallbacks(session, vm, {
484
+ function serializeToolkitsForSidecar(toolKits) {
485
+ return toolKits.map((toolKit) => {
486
+ return {
1367
487
  name: toolKit.name,
1368
488
  description: toolKit.description,
1369
- commandAliases: [`agentos-${toolKit.name}`],
1370
- registryCommandAliases: ["agentos"],
1371
489
  callbacks: Object.fromEntries(Object.entries(toolKit.tools).map(([toolName, tool]) => [
1372
490
  toolName,
1373
491
  toolToSidecarDefinition(tool),
1374
492
  ])),
1375
- });
1376
- }
1377
- return buildHostToolReference(toolKits);
493
+ };
494
+ });
1378
495
  }
1379
496
  export class AgentOs {
1380
497
  #kernel;
1381
498
  sidecar;
1382
499
  _sessions = new Map();
1383
- _closedSessionIds = new BoundedSet(CLOSED_SESSION_ID_RETENTION_LIMIT);
1384
- _sessionClosePromises = new Map();
1385
- _pendingSessionRequestResolvers = new Map();
1386
500
  _processes = new Map();
1387
501
  _shells = new Map();
1388
- _closedShellIds = new BoundedSet(CLOSED_SHELL_ID_RETENTION_LIMIT);
1389
- _pendingShellExitPromises = new Set();
1390
- _shellCounter = 0;
1391
- _acpTerminals = new Map();
1392
- _acpTerminalCounter = 0;
1393
- _softwareRoots;
1394
- _softwareAgentConfigs;
502
+ _pendingShellExitPromises = new Map();
503
+ _disposed = false;
504
+ _disposePromise = null;
1395
505
  _cronManager;
1396
506
  _toolKits = [];
1397
- _toolReference = "";
1398
- _permissions = allowAll;
1399
- _hostMounts;
1400
- _env;
1401
- _rootFilesystem;
1402
507
  _sidecarLease = null;
1403
508
  _sidecarClient;
1404
509
  _sidecarSession;
1405
510
  _sidecarVm;
1406
511
  _disposeSidecarEventListener;
1407
- constructor(kernel, sidecar, softwareRoots, softwareAgentConfigs, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm) {
512
+ _disposeSidecarRequestHandler = null;
513
+ _agentStderrHandler;
514
+ _agentExitHandler;
515
+ _limitWarningHandler;
516
+ constructor(kernel, sidecar, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm, agentStderrHandler, agentExitHandler, limitWarningHandler) {
1408
517
  this.#kernel = kernel;
1409
518
  this.sidecar = sidecar;
1410
- this._softwareRoots = softwareRoots;
1411
- this._softwareAgentConfigs = softwareAgentConfigs;
1412
- this._hostMounts = hostMounts;
1413
- this._env = env;
1414
- this._rootFilesystem = rootFilesystem;
1415
519
  this._sidecarClient = sidecarClient;
1416
520
  this._sidecarSession = sidecarSession;
1417
521
  this._sidecarVm = sidecarVm;
522
+ this._agentStderrHandler = agentStderrHandler;
523
+ this._agentExitHandler = agentExitHandler;
524
+ this._limitWarningHandler = limitWarningHandler;
1418
525
  this._disposeSidecarEventListener = this._sidecarClient.onEvent((event) => {
1419
526
  this._handleSidecarEvent(event);
527
+ }, {
528
+ scope: "vm",
529
+ connection_id: sidecarSession.connectionId,
530
+ session_id: sidecarSession.sessionId,
531
+ vm_id: sidecarVm.vmId,
1420
532
  });
1421
533
  agentOsRuntimeAdmins.set(this, {
1422
534
  kernel,
@@ -1432,143 +544,145 @@ export class AgentOs {
1432
544
  return getSharedAgentOsSidecarInternal(options);
1433
545
  }
1434
546
  static async create(options) {
547
+ options = parseAgentOsOptions(options);
548
+ // Default software is FULLY DYNAMIC: this package's own NON-agent
549
+ // @agentos-software/* dependencies (e.g. common), each default-exporting
550
+ // its registry-built descriptor. Agent packages are NOT projected here;
551
+ // callers or an upper package-manager layer must pass those packages in
552
+ // `software` before createSession(id). Unbuilt packages throw with build
553
+ // instructions; opt out via defaultSoftware: false.
554
+ const defaultSoftware = options?.defaultSoftware === false ? [] : resolveDefaultSoftware();
1435
555
  const software = options?.defaultSoftware === false
1436
556
  ? (options.software ?? [])
1437
- : [commonSoftware, ...(options?.software ?? [])];
1438
- const processed = processSoftware(software);
557
+ : [...defaultSoftware, ...(options?.software ?? [])];
558
+ // Packages are projected by the SIDECAR: the client forwards only the
559
+ // package `path` over `initializeVm` and the sidecar reads metadata from
560
+ // the packed vbare manifest (chunk1 of the `.aospkg`).
561
+ const flatSoftware = software.flat();
562
+ // Honor the AgentOsOptions.defaultSoftware contract ("entries already present
563
+ // in `software` are not duplicated"): the default bundle and an explicitly
564
+ // passed one resolve to the same package paths, so dedup by path. Without
565
+ // this the sidecar rejects the second projection with a duplicate-command
566
+ // error (e.g. coreutils' `[`).
567
+ const seenPackagePaths = new Set();
568
+ const sidecarPackages = flatSoftware.flatMap((entry) => {
569
+ const ref = normalizePackageRef(entry);
570
+ if (seenPackagePaths.has(ref.path)) {
571
+ return [];
572
+ }
573
+ seenPackagePaths.add(ref.path);
574
+ return [{ path: ref.path }];
575
+ });
576
+ // All package software is projected into `/opt/agentos` by the sidecar. The
577
+ // client stages nothing host-side and parses NO package manifests: the
578
+ // sidecar owns agent resolution, agent enumeration, and agent snapshot
579
+ // bundle loading from the projected package dirs.
1439
580
  const localMounts = await resolveCompatLocalMounts(options?.mounts);
1440
581
  const toolKits = options?.toolKits;
1441
- if (toolKits && toolKits.length > 0) {
1442
- validateToolkits(toolKits);
1443
- }
582
+ // Resolve the sidecar handle up front so every VM created here leases the
583
+ // one shared native sidecar process owned by that handle.
584
+ const sidecar = resolveAgentOsSidecar(options?.sidecar);
1444
585
  const createVmAdmin = async () => {
1445
- const preparedCommandDirs = prepareCommandDirs(processed.commandPackages);
1446
- const toolBootstrapCommands = collectToolkitBootstrapCommands(toolKits ?? []);
1447
- const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem, [
1448
- ...collectBootstrapWasmCommands(preparedCommandDirs.commandDirs),
1449
- ...NODE_RUNTIME_BOOTSTRAP_COMMANDS,
1450
- ...toolBootstrapCommands,
1451
- ]);
1452
- let toolReference = "";
586
+ // The `/opt/agentos` projection is built by the sidecar from the
587
+ // forwarded `packages` (it owns the staging dir + read-only mount, and
588
+ // runtime `linkSoftware` appends to that live dir). The client no longer
589
+ // stages packages host-side.
1453
590
  let rootBridge = null;
1454
591
  let kernel = null;
1455
592
  let client = null;
1456
- let toolShimDir = null;
593
+ let createdNativeVm = null;
594
+ let nativeSession = null;
1457
595
  let cleanedUp = false;
1458
596
  const cleanup = async () => {
1459
597
  if (cleanedUp) {
1460
598
  return;
1461
599
  }
1462
600
  cleanedUp = true;
1463
- if (toolShimDir) {
1464
- rmSync(toolShimDir, { recursive: true, force: true });
1465
- toolShimDir = null;
1466
- }
1467
- preparedCommandDirs.dispose();
1468
601
  };
1469
602
  try {
1470
- const env = getBaseEnvironment();
1471
- if (toolKits && toolKits.length > 0) {
1472
- toolShimDir = materializeToolShimDir(toolKits);
1473
- }
1474
- const commandGuestPaths = collectGuestCommandPaths(preparedCommandDirs.commandDirs);
1475
- const requestedMounts = options?.moduleAccessCwd
1476
- ? [
1477
- ...(options.mounts ?? []),
1478
- {
1479
- path: "/root/node_modules",
1480
- plugin: createHostDirBackend({
1481
- hostPath: join(options.moduleAccessCwd, "node_modules"),
1482
- readOnly: true,
1483
- }),
1484
- readOnly: true,
1485
- },
1486
- ]
1487
- : options?.mounts;
1488
- const { sidecarMounts, hostMounts, hostPathMappings } = collectSidecarMountPlan({
1489
- mounts: requestedMounts,
1490
- softwareRoots: processed.softwareRoots,
1491
- commandDirs: preparedCommandDirs.commandDirs,
1492
- shimDir: toolShimDir,
1493
- });
1494
- client = SidecarProcess.spawn({
1495
- cwd: REPO_ROOT,
1496
- command: ensureNativeSidecarBinary(),
1497
- args: [],
1498
- frameTimeoutMs: NATIVE_SIDECAR_FRAME_TIMEOUT_MS,
603
+ let env = {};
604
+ const { sidecarMounts } = collectSidecarMountPlan({
605
+ mounts: options?.mounts,
1499
606
  });
1500
- const session = await client.authenticateAndOpenSession();
1501
- const hostPermissions = options?.permissions ?? {
1502
- ...allowAll,
1503
- tool: "allow",
1504
- };
1505
- const sidecarPermissions = serializePermissionsForSidecar(hostPermissions);
607
+ // Reuse the sidecar handle's single shared native process; this VM
608
+ // becomes another tenant of it rather than spawning its own process.
609
+ const shared = await ensureSharedSidecarNativeProcess(sidecar);
610
+ client = shared.client;
611
+ const session = shared.session;
612
+ nativeSession = session;
613
+ const sidecarPermissions = options?.permissions
614
+ ? serializePermissionsForSidecar(options.permissions)
615
+ : undefined;
1506
616
  const createVmConfig = {
1507
- env,
1508
- rootFilesystem: serializeRootFilesystemForSidecar(options?.rootFilesystem, bootstrapLower),
617
+ ...(options?.rootFilesystem !== undefined
618
+ ? {
619
+ rootFilesystem: serializeRootFilesystemForSidecar(options.rootFilesystem),
620
+ }
621
+ : {}),
1509
622
  permissions: sidecarPermissions,
1510
623
  limits: options?.limits,
1511
- loopbackExemptPorts: options?.loopbackExemptPorts ?? [],
624
+ ...(options?.loopbackExemptPorts !== undefined
625
+ ? { loopbackExemptPorts: options.loopbackExemptPorts }
626
+ : {}),
1512
627
  // 0.3: the Node builtin allow-list moved from configureVm to
1513
628
  // VM creation. `undefined` => engine default allow-list;
1514
629
  // `[]` => deny all; `[..]` => exactly those. Platform and
1515
630
  // module resolution keep their engine defaults (full Node
1516
631
  // emulation), matching the prior behavior where Agent OS only
1517
632
  // constrained the builtin allow-list.
1518
- ...(options?.allowedNodeBuiltins !== undefined
633
+ ...(options?.allowedNodeBuiltins !== undefined ||
634
+ options?.highResolutionTime !== undefined
1519
635
  ? {
1520
636
  jsRuntime: {
1521
- platform: "node",
1522
- moduleResolution: "node",
1523
- allowedBuiltins: options.allowedNodeBuiltins,
637
+ ...(options?.allowedNodeBuiltins !== undefined
638
+ ? { allowedBuiltins: options.allowedNodeBuiltins }
639
+ : {}),
640
+ ...(options?.highResolutionTime !== undefined
641
+ ? { highResolutionTime: options.highResolutionTime }
642
+ : {}),
1524
643
  },
1525
644
  }
1526
645
  : {}),
646
+ ...(options?.additionalInstructions === undefined
647
+ ? {}
648
+ : {
649
+ agentAdditionalInstructions: options.additionalInstructions,
650
+ }),
1527
651
  };
1528
- const nativeVm = await client.createVm(session, {
652
+ const nativeVm = await client.initializeVm(session, {
1529
653
  runtime: "java_script",
1530
654
  config: createVmConfig,
655
+ ...(sidecarMounts.length > 0 ? { mounts: sidecarMounts } : {}),
656
+ ...(sidecarPackages.length > 0 ? { packages: sidecarPackages } : {}),
657
+ ...(toolKits === undefined || toolKits.length === 0
658
+ ? {}
659
+ : { hostCallbacks: serializeToolkitsForSidecar(toolKits) }),
1531
660
  });
1532
- await client.waitForEvent((event) => event.payload.type === "vm_lifecycle" &&
1533
- event.payload.state === "ready", 10_000);
1534
- await client.configureVm(session, nativeVm, {
1535
- mounts: sidecarMounts,
1536
- permissions: sidecarPermissions,
1537
- commandPermissions: processed.commandPermissions,
1538
- loopbackExemptPorts: options?.loopbackExemptPorts,
1539
- });
1540
- if (toolKits && toolKits.length > 0) {
1541
- toolReference = await registerToolkitsOnSidecar(client, session, nativeVm, toolKits);
1542
- commandGuestPaths.set("agentos", "/bin/agentos");
1543
- for (const toolKit of toolKits) {
1544
- commandGuestPaths.set(`agentos-${toolKit.name}`, `/bin/agentos-${toolKit.name}`);
1545
- }
1546
- }
661
+ env = { ...nativeVm.guestEnv };
662
+ createdNativeVm = nativeVm;
1547
663
  rootBridge = new NativeSidecarKernelProxy({
1548
664
  client,
1549
665
  session,
1550
666
  vm: nativeVm,
1551
- env,
1552
- cwd: "/workspace",
667
+ env: nativeVm.guestEnv,
668
+ cwd: nativeVm.guestCwd,
1553
669
  localMounts,
1554
- commandGuestPaths,
670
+ sidecarMounts,
1555
671
  onDispose: cleanup,
672
+ // The native process is owned by the AgentOsSidecar handle and
673
+ // shared across VMs; disposing this VM must not kill the process.
674
+ ownsClient: false,
1556
675
  });
1557
- await bootstrapLiveBootstrapDirectories(client, session, nativeVm, options?.rootFilesystem);
1558
676
  kernel = rootBridge;
1559
- const snapshotClient = client;
1560
677
  return {
1561
678
  env,
1562
- hostMounts,
1563
679
  kernel,
1564
680
  rootView: rootBridge.createRootView(),
681
+ sidecarMounts,
1565
682
  sidecarClient: client,
1566
683
  sidecarSession: session,
1567
684
  sidecarVm: nativeVm,
1568
- permissions: hostPermissions,
1569
- snapshotRootFilesystem: async () => createSnapshotExport(convertSidecarRootSnapshotEntries(await snapshotClient.snapshotRootFilesystem(session, nativeVm))),
1570
685
  toolKits: toolKits ?? [],
1571
- toolReference,
1572
686
  async dispose() {
1573
687
  if (kernel) {
1574
688
  const currentKernel = kernel;
@@ -1586,37 +700,48 @@ export class AgentOs {
1586
700
  };
1587
701
  }
1588
702
  catch (error) {
703
+ // The native process is shared and owned by the sidecar handle, so
704
+ // never dispose the client here — only tear down this VM's resources.
1589
705
  if (kernel) {
1590
- await kernel.dispose().catch(() => { });
706
+ await kernel.dispose().catch((cleanupError) => {
707
+ console.warn("failed to dispose kernel after VM startup failure", cleanupError);
708
+ });
1591
709
  }
1592
710
  if (rootBridge) {
1593
- await rootBridge.dispose().catch(() => { });
711
+ await rootBridge.dispose().catch((cleanupError) => {
712
+ console.warn("failed to dispose root bridge after VM startup failure", cleanupError);
713
+ });
1594
714
  }
1595
715
  else {
1596
- await client?.dispose().catch(() => { });
716
+ if (createdNativeVm && nativeSession && client) {
717
+ await client
718
+ .disposeVm(nativeSession, createdNativeVm)
719
+ .catch((cleanupError) => {
720
+ console.warn("failed to dispose sidecar VM after startup failure", cleanupError);
721
+ });
722
+ }
1597
723
  await cleanup();
1598
724
  }
1599
725
  throw error;
1600
726
  }
1601
727
  };
1602
- const sidecar = resolveAgentOsSidecar(options?.sidecar);
1603
728
  let sidecarLease = null;
1604
729
  try {
1605
730
  sidecarLease = await leaseAgentOsSidecarVm(sidecar, {
1606
731
  createVm: async () => createVmAdmin(),
1607
732
  });
1608
733
  const vmAdmin = sidecarLease.admin;
1609
- const vm = new AgentOs(vmAdmin.kernel, sidecar, processed.softwareRoots, processed.agentConfigs, vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm);
734
+ const vm = new AgentOs(vmAdmin.kernel, sidecar, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm, options?.onAgentStderr ?? defaultAgentStderrHandler, options?.onAgentExit ?? defaultAgentExitHandler, options?.onLimitWarning);
1610
735
  vm._sidecarLease = sidecarLease;
1611
736
  vm._toolKits = vmAdmin.toolKits;
1612
- vm._toolReference = vmAdmin.toolReference;
1613
- vm._permissions = vmAdmin.permissions;
1614
737
  vm._installSidecarRequestHandler();
1615
- vm._cronManager = new CronManager(vm, options?.scheduleDriver ?? new TimerScheduleDriver());
738
+ vm._cronManager = new CronManager(vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm);
1616
739
  return vm;
1617
740
  }
1618
741
  catch (error) {
1619
- await sidecarLease?.dispose().catch(() => { });
742
+ await sidecarLease?.dispose().catch((cleanupError) => {
743
+ console.warn("failed to dispose sidecar lease after AgentOs.create failure", cleanupError);
744
+ });
1620
745
  throw error;
1621
746
  }
1622
747
  }
@@ -1627,23 +752,76 @@ export class AgentOs {
1627
752
  const kernel = this.#kernel;
1628
753
  return kernel.execArgv(command, args, options);
1629
754
  }
1630
- _trackProcess(proc, command, args, stdoutHandlers, stderrHandlers, exitHandlers) {
755
+ _pruneCompletedProcessRoutes() {
756
+ let completedRoutes = 0;
757
+ for (const entry of this._processes.values()) {
758
+ if (entry.state !== "running")
759
+ completedRoutes++;
760
+ }
761
+ let removeCount = Math.max(0, completedRoutes - this._sidecarVm.processRouteRetention);
762
+ for (const [pid, entry] of this._processes) {
763
+ if (removeCount === 0)
764
+ break;
765
+ if (entry.state !== "running") {
766
+ this._processes.delete(pid);
767
+ removeCount--;
768
+ }
769
+ }
770
+ }
771
+ async _trackProcess(proc, stdoutHandlers, stderrHandlers, exitHandlers) {
772
+ const existing = this._processes.get(proc.pid);
773
+ if (existing?.state === "running") {
774
+ const duplicateError = new Error(`Sidecar returned an already-active kernel pid: ${proc.pid}`);
775
+ try {
776
+ await proc.kill(9);
777
+ }
778
+ catch (cleanupError) {
779
+ throw new AggregateError([duplicateError, cleanupError], "Sidecar returned a duplicate active pid and cleanup failed");
780
+ }
781
+ throw duplicateError;
782
+ }
783
+ this._processes.delete(proc.pid);
1631
784
  const entry = {
785
+ state: "running",
1632
786
  proc,
1633
- command,
1634
- args,
1635
787
  stdoutHandlers,
1636
788
  stderrHandlers,
1637
789
  exitHandlers,
1638
790
  };
1639
791
  this._processes.set(proc.pid, entry);
1640
- void proc.wait().then((code) => {
1641
- for (const h of exitHandlers)
1642
- h(code);
792
+ void proc
793
+ .wait()
794
+ .then((code) => {
795
+ if (this._processes.get(proc.pid) !== entry)
796
+ return;
797
+ const handlers = [...exitHandlers];
798
+ stdoutHandlers.clear();
799
+ stderrHandlers.clear();
800
+ exitHandlers.clear();
801
+ this._processes.delete(proc.pid);
802
+ this._processes.set(proc.pid, { state: "exited", exitCode: code });
803
+ this._pruneCompletedProcessRoutes();
804
+ for (const handler of handlers)
805
+ handler(code);
806
+ })
807
+ .catch((error) => {
808
+ if (this._processes.get(proc.pid) === entry) {
809
+ const routeError = error instanceof Error ? error : new Error(String(error));
810
+ stdoutHandlers.clear();
811
+ stderrHandlers.clear();
812
+ exitHandlers.clear();
813
+ this._processes.delete(proc.pid);
814
+ this._processes.set(proc.pid, {
815
+ state: "failed",
816
+ error: routeError,
817
+ });
818
+ this._pruneCompletedProcessRoutes();
819
+ }
820
+ console.error(`[agent-os] process ${proc.pid} wait failed`, error);
1643
821
  });
1644
822
  return { pid: proc.pid };
1645
823
  }
1646
- spawn(command, args, options) {
824
+ async spawn(command, args, options) {
1647
825
  const stdoutHandlers = new Set();
1648
826
  const stderrHandlers = new Set();
1649
827
  const exitHandlers = new Set();
@@ -1652,7 +830,7 @@ export class AgentOs {
1652
830
  stdoutHandlers.add(options.onStdout);
1653
831
  if (options?.onStderr)
1654
832
  stderrHandlers.add(options.onStderr);
1655
- const proc = this.#kernel.spawn(command, args, {
833
+ const proc = await this.#kernel.spawn(command, args, {
1656
834
  ...options,
1657
835
  onStdout: (data) => {
1658
836
  for (const h of stdoutHandlers)
@@ -1663,27 +841,39 @@ export class AgentOs {
1663
841
  h(data);
1664
842
  },
1665
843
  });
1666
- return this._trackProcess(proc, command, args, stdoutHandlers, stderrHandlers, exitHandlers);
844
+ return await this._trackProcess(proc, stdoutHandlers, stderrHandlers, exitHandlers);
1667
845
  }
1668
846
  /** Write data to a process's stdin. */
1669
847
  writeProcessStdin(pid, data) {
1670
848
  const entry = this._processes.get(pid);
1671
849
  if (!entry)
1672
850
  throw new Error(`Process not found: ${pid}`);
1673
- entry.proc.writeStdin(data);
851
+ if (entry.state === "exited")
852
+ return Promise.resolve();
853
+ if (entry.state === "failed")
854
+ return Promise.reject(entry.error);
855
+ return entry.proc.writeStdin(data);
1674
856
  }
1675
857
  /** Close a process's stdin stream. */
1676
858
  closeProcessStdin(pid) {
1677
859
  const entry = this._processes.get(pid);
1678
860
  if (!entry)
1679
861
  throw new Error(`Process not found: ${pid}`);
1680
- entry.proc.closeStdin();
862
+ if (entry.state === "exited")
863
+ return Promise.resolve();
864
+ if (entry.state === "failed")
865
+ return Promise.reject(entry.error);
866
+ return entry.proc.closeStdin();
1681
867
  }
1682
868
  /** Subscribe to stdout data from a process. Returns an unsubscribe function. */
1683
869
  onProcessStdout(pid, handler) {
1684
870
  const entry = this._processes.get(pid);
1685
871
  if (!entry)
1686
872
  throw new Error(`Process not found: ${pid}`);
873
+ if (entry.state === "exited")
874
+ return () => { };
875
+ if (entry.state === "failed")
876
+ throw entry.error;
1687
877
  entry.stdoutHandlers.add(handler);
1688
878
  return () => {
1689
879
  entry.stdoutHandlers.delete(handler);
@@ -1694,6 +884,10 @@ export class AgentOs {
1694
884
  const entry = this._processes.get(pid);
1695
885
  if (!entry)
1696
886
  throw new Error(`Process not found: ${pid}`);
887
+ if (entry.state === "exited")
888
+ return () => { };
889
+ if (entry.state === "failed")
890
+ throw entry.error;
1697
891
  entry.stderrHandlers.add(handler);
1698
892
  return () => {
1699
893
  entry.stderrHandlers.delete(handler);
@@ -1705,10 +899,12 @@ export class AgentOs {
1705
899
  if (!entry)
1706
900
  throw new Error(`Process not found: ${pid}`);
1707
901
  // If already exited, call immediately.
1708
- if (entry.proc.exitCode !== null) {
1709
- handler(entry.proc.exitCode);
902
+ if (entry.state === "exited") {
903
+ handler(entry.exitCode);
1710
904
  return () => { };
1711
905
  }
906
+ if (entry.state === "failed")
907
+ throw entry.error;
1712
908
  entry.exitHandlers.add(handler);
1713
909
  return () => {
1714
910
  entry.exitHandlers.delete(handler);
@@ -1719,226 +915,68 @@ export class AgentOs {
1719
915
  const entry = this._processes.get(pid);
1720
916
  if (!entry)
1721
917
  throw new Error(`Process not found: ${pid}`);
918
+ if (entry.state === "exited")
919
+ return Promise.resolve(entry.exitCode);
920
+ if (entry.state === "failed")
921
+ return Promise.reject(entry.error);
1722
922
  return entry.proc.wait();
1723
923
  }
1724
- _assertSafeAbsolutePath(path) {
1725
- if (!path.startsWith("/")) {
1726
- throw new Error(`Path must be absolute: ${path}`);
1727
- }
1728
- if (posixPath.normalize(path) !== path) {
1729
- throw new Error(`Path must be normalized: ${path}`);
1730
- }
1731
- }
1732
- _assertWritableAbsolutePath(path) {
1733
- this._assertSafeAbsolutePath(path);
1734
- if (path === "/proc" || path.startsWith("/proc/")) {
1735
- throw new Error(`Path is read-only: ${path}`);
1736
- }
1737
- }
1738
- _vfs() {
1739
- return this.#kernel.vfs;
1740
- }
1741
- async _copyPath(from, to) {
1742
- const stat = await this._vfs().lstat(from);
1743
- if (stat.isSymbolicLink) {
1744
- const target = await this._vfs().readlink(from);
1745
- await this._vfs().symlink(target, to);
1746
- return;
1747
- }
1748
- if (stat.isDirectory) {
1749
- await this._mkdirp(posixPath.dirname(to));
1750
- if (!(await this.#kernel.exists(to))) {
1751
- await this.#kernel.mkdir(to);
1752
- }
1753
- await this._vfs().chmod(to, stat.mode);
1754
- await this._vfs().chown(to, stat.uid, stat.gid);
1755
- const entries = await this.#kernel.readdir(from);
1756
- for (const entry of entries) {
1757
- if (entry === "." || entry === "..")
1758
- continue;
1759
- const fromPath = from === "/" ? `/${entry}` : `${from}/${entry}`;
1760
- const toPath = to === "/" ? `/${entry}` : `${to}/${entry}`;
1761
- await this._copyPath(fromPath, toPath);
1762
- }
1763
- return;
1764
- }
1765
- const content = await this.#kernel.readFile(from);
1766
- await this.writeFile(to, content);
1767
- await this._vfs().chmod(to, stat.mode);
1768
- await this._vfs().chown(to, stat.uid, stat.gid);
1769
- }
1770
924
  async readFile(path) {
1771
- this._assertSafeAbsolutePath(path);
1772
925
  return this.#kernel.readFile(path);
1773
926
  }
1774
927
  async writeFile(path, content) {
1775
- this._assertWritableAbsolutePath(path);
1776
928
  return this.#kernel.writeFile(path, content);
1777
929
  }
1778
- async writeFiles(entries) {
1779
- const results = [];
1780
- for (const entry of entries) {
1781
- try {
1782
- this._assertWritableAbsolutePath(entry.path);
1783
- // Create parent directories as needed
1784
- const parentDir = entry.path.substring(0, entry.path.lastIndexOf("/"));
1785
- if (parentDir) {
1786
- await this._mkdirp(parentDir);
1787
- }
1788
- await this.#kernel.writeFile(entry.path, entry.content);
1789
- results.push({ path: entry.path, success: true });
1790
- }
1791
- catch (err) {
1792
- results.push({
1793
- path: entry.path,
1794
- success: false,
1795
- error: err instanceof Error ? err.message : String(err),
1796
- });
1797
- }
1798
- }
1799
- return results;
1800
- }
1801
- async readFiles(paths) {
1802
- const results = [];
1803
- for (const path of paths) {
1804
- try {
1805
- this._assertSafeAbsolutePath(path);
1806
- const content = await this.#kernel.readFile(path);
1807
- results.push({ path, content });
1808
- }
1809
- catch (err) {
1810
- results.push({
1811
- path,
1812
- content: null,
1813
- error: err instanceof Error ? err.message : String(err),
1814
- });
1815
- }
1816
- }
1817
- return results;
1818
- }
1819
- /** Recursively create directories (mkdir -p). */
1820
- async _mkdirp(path) {
1821
- this._assertWritableAbsolutePath(path);
1822
- const parts = path.split("/").filter(Boolean);
1823
- let current = "";
1824
- for (const part of parts) {
1825
- current += `/${part}`;
1826
- if (!(await this.#kernel.exists(current))) {
1827
- await this.#kernel.mkdir(current);
1828
- }
1829
- }
1830
- }
1831
930
  async mkdir(path, options) {
1832
- if (options?.recursive) {
1833
- return this._mkdirp(path);
1834
- }
1835
- this._assertSafeAbsolutePath(path);
1836
- return this.#kernel.mkdir(path);
931
+ return this.#kernel.mkdir(path, options);
1837
932
  }
1838
933
  async readdir(path) {
1839
- this._assertSafeAbsolutePath(path);
1840
934
  return this.#kernel.readdir(path);
1841
935
  }
1842
936
  async readdirRecursive(path, options) {
1843
- this._assertSafeAbsolutePath(path);
1844
- const maxDepth = options?.maxDepth;
1845
- const exclude = options?.exclude ? new Set(options.exclude) : undefined;
1846
- const results = [];
1847
- // BFS queue: [dirPath, currentDepth]
1848
- const queue = [[path, 0]];
1849
- while (queue.length > 0) {
1850
- const item = queue.shift();
1851
- if (!item)
1852
- break;
1853
- const [dirPath, depth] = item;
1854
- const entries = await this.#kernel.readdir(dirPath);
1855
- for (const name of entries) {
1856
- if (name === "." || name === "..")
1857
- continue;
1858
- if (exclude?.has(name))
1859
- continue;
1860
- const fullPath = dirPath === "/" ? `/${name}` : `${dirPath}/${name}`;
1861
- const s = await this.#kernel.stat(fullPath);
1862
- if (s.isSymbolicLink) {
1863
- results.push({
1864
- path: fullPath,
1865
- type: "symlink",
1866
- size: s.size,
1867
- });
1868
- }
1869
- else if (s.isDirectory) {
1870
- results.push({
1871
- path: fullPath,
1872
- type: "directory",
1873
- size: s.size,
1874
- });
1875
- if (maxDepth === undefined || depth < maxDepth) {
1876
- queue.push([fullPath, depth + 1]);
1877
- }
1878
- }
1879
- else {
1880
- results.push({
1881
- path: fullPath,
1882
- type: "file",
1883
- size: s.size,
1884
- });
1885
- }
1886
- }
1887
- }
1888
- return results;
937
+ const entries = await this.#kernel.readdirRecursive(path, {
938
+ maxDepth: options?.maxDepth,
939
+ });
940
+ return entries.map((entry) => ({
941
+ path: entry.path,
942
+ type: entry.isSymbolicLink
943
+ ? "symlink"
944
+ : entry.isDirectory
945
+ ? "directory"
946
+ : "file",
947
+ size: entry.size,
948
+ }));
1889
949
  }
1890
950
  async stat(path) {
1891
- this._assertSafeAbsolutePath(path);
1892
951
  return this.#kernel.stat(path);
1893
952
  }
1894
953
  async exists(path) {
1895
- this._assertSafeAbsolutePath(path);
1896
954
  return this.#kernel.exists(path);
1897
955
  }
1898
956
  async snapshotRootFilesystem() {
1899
- const nativeSnapshot = this._sidecarLease?.admin.snapshotRootFilesystem;
1900
- if (nativeSnapshot) {
1901
- return nativeSnapshot();
1902
- }
1903
- return createSnapshotExport(await snapshotVirtualFilesystem(this._rootFilesystem));
957
+ return createSnapshotExport(convertSidecarRootSnapshotEntries(await this._sidecarClient.snapshotRootFilesystem(this._sidecarSession, this._sidecarVm)));
1904
958
  }
1905
- mountFs(path, driver, options) {
1906
- this._assertSafeAbsolutePath(path);
1907
- this.#kernel.mountFs(path, driver, { readOnly: options?.readOnly });
959
+ /**
960
+ * Mount a filesystem into the running VM. Resolves once the mount has been
961
+ * delivered to the native sidecar, so guest code can use it immediately
962
+ * after the returned promise settles; a delivery failure rejects instead of
963
+ * leaving the mount silently host-only.
964
+ */
965
+ async mountFs(path, driver, options) {
966
+ await this.#kernel.mountFs(path, driver, { readOnly: options?.readOnly });
1908
967
  }
1909
- unmountFs(path) {
1910
- this._assertSafeAbsolutePath(path);
1911
- this.#kernel.unmountFs(path);
968
+ async unmountFs(path) {
969
+ await this.#kernel.unmountFs(path);
1912
970
  }
1913
971
  async move(from, to) {
1914
- this._assertSafeAbsolutePath(from);
1915
- this._assertSafeAbsolutePath(to);
1916
- const sourceStat = await this._vfs().lstat(from);
1917
- if (!sourceStat.isDirectory || sourceStat.isSymbolicLink) {
1918
- return this.#kernel.rename(from, to);
1919
- }
1920
- await this._copyPath(from, to);
1921
- await this.delete(from, { recursive: true });
972
+ await this.#kernel.movePath(from, to);
1922
973
  }
1923
974
  async delete(path, options) {
1924
- this._assertSafeAbsolutePath(path);
1925
- const s = await this.#kernel.stat(path);
1926
- if (s.isDirectory) {
1927
- if (options?.recursive) {
1928
- const entries = await this.#kernel.readdir(path);
1929
- for (const entry of entries) {
1930
- if (entry === "." || entry === "..")
1931
- continue;
1932
- await this.delete(`${path}/${entry}`, { recursive: true });
1933
- }
1934
- }
1935
- return this.#kernel.removeDir(path);
1936
- }
1937
- return this.#kernel.removeFile(path);
975
+ await this.#kernel.removePath(path, options);
1938
976
  }
1939
977
  async fetch(port, request) {
1940
978
  const url = new URL(request.url);
1941
- const responsePayload = JSON.parse(await this._sidecarClient.vmFetch(this._sidecarSession, this._sidecarVm, {
979
+ const responsePayload = requireVmFetchResponsePayload(JSON.parse(await this._sidecarClient.vmFetch(this._sidecarSession, this._sidecarVm, {
1942
980
  port,
1943
981
  method: request.method,
1944
982
  path: `${url.pathname}${url.search}`,
@@ -1946,22 +984,21 @@ export class AgentOs {
1946
984
  ...(request.method !== "GET" && request.method !== "HEAD"
1947
985
  ? { body: await request.text() }
1948
986
  : {}),
1949
- }));
987
+ })));
1950
988
  const headers = new Headers();
1951
- for (const [key, value] of responsePayload.headers ?? []) {
989
+ for (const [key, value] of responsePayload.headers) {
1952
990
  headers.append(key, value);
1953
991
  }
1954
- return new Response(Buffer.from(responsePayload.body ?? "", "base64"), {
992
+ return new Response(Buffer.from(responsePayload.body, "base64"), {
1955
993
  status: responsePayload.status,
1956
994
  statusText: responsePayload.statusText,
1957
995
  headers,
1958
996
  });
1959
997
  }
1960
- openShell(options) {
1961
- const shellId = `shell-${++this._shellCounter}`;
1962
- this._closedShellIds.delete(shellId);
998
+ async openShell(options) {
1963
999
  const dataHandlers = new Set();
1964
- const handle = this.#kernel.openShell(options);
1000
+ const handle = await this.#kernel.openShell(options);
1001
+ const shellId = handle.processId;
1965
1002
  handle.onData = (data) => {
1966
1003
  for (const h of dataHandlers)
1967
1004
  h(data);
@@ -1969,152 +1006,165 @@ export class AgentOs {
1969
1006
  const entry = {
1970
1007
  handle,
1971
1008
  dataHandlers,
1972
- exitPromise: Promise.resolve(),
1009
+ exitPromise: Promise.resolve(0),
1010
+ closing: false,
1973
1011
  };
1974
- const exitPromise = handle.wait().then(() => undefined, () => undefined);
1975
- entry.exitPromise = exitPromise.finally(() => {
1976
- this._pendingShellExitPromises.delete(entry.exitPromise);
1012
+ const exitPromise = handle.wait();
1013
+ const finalize = () => {
1014
+ this._pendingShellExitPromises.delete(shellId);
1977
1015
  if (this._shells.get(shellId) === entry) {
1978
1016
  this._shells.delete(shellId);
1979
- this._closedShellIds.add(shellId);
1980
1017
  }
1018
+ };
1019
+ entry.exitPromise = exitPromise.then((exitCode) => {
1020
+ finalize();
1021
+ return exitCode;
1022
+ }, (error) => {
1023
+ finalize();
1024
+ throw error;
1981
1025
  });
1982
- this._pendingShellExitPromises.add(entry.exitPromise);
1026
+ this._pendingShellExitPromises.set(shellId, entry.exitPromise);
1983
1027
  this._shells.set(shellId, entry);
1984
1028
  return { shellId };
1985
1029
  }
1986
- async connectTerminal(options) {
1987
- return this.#kernel.connectTerminal(options);
1988
- }
1989
1030
  /** Write data to a shell's PTY input. */
1990
1031
  writeShell(shellId, data) {
1991
1032
  const entry = this._shells.get(shellId);
1992
- if (!entry)
1033
+ if (!entry || entry.closing)
1993
1034
  throw new Error(`Shell not found: ${shellId}`);
1994
- entry.handle.write(data);
1035
+ return entry.handle.write(data);
1995
1036
  }
1996
1037
  /** Subscribe to data output from a shell. Returns an unsubscribe function. */
1997
1038
  onShellData(shellId, handler) {
1998
1039
  const entry = this._shells.get(shellId);
1999
- if (!entry)
1040
+ if (!entry || entry.closing)
2000
1041
  throw new Error(`Shell not found: ${shellId}`);
2001
1042
  entry.dataHandlers.add(handler);
2002
1043
  return () => {
2003
1044
  entry.dataHandlers.delete(handler);
2004
1045
  };
2005
1046
  }
2006
- /** Notify a shell of terminal resize. */
2007
- resizeShell(shellId, cols, rows) {
1047
+ /** Notify a shell of terminal resize and await the sidecar response. */
1048
+ async resizeShell(shellId, cols, rows) {
2008
1049
  const entry = this._shells.get(shellId);
2009
- if (!entry)
1050
+ if (!entry || entry.closing)
2010
1051
  throw new Error(`Shell not found: ${shellId}`);
2011
- entry.handle.resize(cols, rows);
1052
+ await entry.handle.resize(cols, rows);
2012
1053
  }
2013
- /** Kill a shell process and remove it from tracking. */
2014
- closeShell(shellId) {
1054
+ /**
1055
+ * Wait for a shell to exit and return its sidecar-authoritative exit code.
1056
+ * A late wait reads the retained sidecar process snapshot.
1057
+ */
1058
+ async waitShell(shellId) {
2015
1059
  const entry = this._shells.get(shellId);
2016
- if (!entry) {
2017
- if (this._closedShellIds.has(shellId)) {
2018
- return;
1060
+ if (entry) {
1061
+ return entry.exitPromise;
1062
+ }
1063
+ const pending = this._pendingShellExitPromises.get(shellId);
1064
+ if (pending) {
1065
+ return pending;
1066
+ }
1067
+ if (this.#kernel instanceof NativeSidecarKernelProxy) {
1068
+ const process = await this.#kernel.processSnapshotById(shellId);
1069
+ if (process?.status === "exited" && process.exitCode !== null) {
1070
+ return process.exitCode;
2019
1071
  }
2020
- throw new Error(`Shell not found: ${shellId}`);
2021
1072
  }
2022
- entry.handle.kill();
2023
- this._shells.delete(shellId);
2024
- this._closedShellIds.add(shellId);
2025
- }
2026
- _resolveVmPathToHostPath(vmPath) {
2027
- const normalizedVmPath = posixPath.normalize(vmPath);
2028
- for (const mount of this._hostMounts) {
2029
- if (normalizedVmPath === mount.vmPath ||
2030
- normalizedVmPath.startsWith(`${mount.vmPath}/`)) {
2031
- const relativePath = posixPath.relative(mount.vmPath, normalizedVmPath);
2032
- if (!relativePath) {
2033
- return mount.hostPath;
1073
+ throw new Error(`Shell not found: ${shellId}`);
1074
+ }
1075
+ /** Kill a shell process, await the sidecar response, and remove it from tracking. */
1076
+ async closeShell(shellId) {
1077
+ const entry = this._shells.get(shellId);
1078
+ if (!entry) {
1079
+ if (this.#kernel instanceof NativeSidecarKernelProxy) {
1080
+ const process = await this.#kernel.processSnapshotById(shellId);
1081
+ if (process?.status === "exited") {
1082
+ return;
2034
1083
  }
2035
- return join(mount.hostPath, ...relativePath.split("/").filter(Boolean));
2036
1084
  }
1085
+ throw new Error(`Shell not found: ${shellId}`);
2037
1086
  }
2038
- return null;
1087
+ if (entry.closing) {
1088
+ return;
1089
+ }
1090
+ await entry.handle.kill();
1091
+ entry.closing = true;
2039
1092
  }
2040
- /** Returns info about all processes spawned via spawn(). */
2041
- listProcesses() {
2042
- return [...this._processes.values()].map(({ proc, command, args }) => ({
2043
- pid: proc.pid,
2044
- command,
2045
- args,
2046
- running: proc.exitCode === null,
2047
- exitCode: proc.exitCode,
2048
- }));
1093
+ /** Returns sidecar-authoritative info for processes spawned via spawn(). */
1094
+ async listProcesses() {
1095
+ const processByPid = new Map((await this.allProcesses()).map((process) => [process.pid, process]));
1096
+ return [...this._processes.keys()].map((pid) => {
1097
+ const process = processByPid.get(pid);
1098
+ if (!process) {
1099
+ throw new Error(`Sidecar process snapshot is missing tracked process: ${pid}`);
1100
+ }
1101
+ return {
1102
+ pid: process.pid,
1103
+ command: process.command,
1104
+ args: process.args.slice(1),
1105
+ running: process.status !== "exited",
1106
+ exitCode: process.exitCode,
1107
+ };
1108
+ });
2049
1109
  }
2050
1110
  /** Returns all kernel processes across all active runtimes (WASM and Node). */
2051
- allProcesses() {
1111
+ async allProcesses() {
2052
1112
  if (this.#kernel instanceof NativeSidecarKernelProxy) {
2053
- return this.#kernel.snapshotProcesses();
1113
+ return await this.#kernel.snapshotProcesses();
2054
1114
  }
2055
1115
  return [...this.#kernel.processes.values()];
2056
1116
  }
2057
- /** Returns processes organized as a tree using ppid relationships. */
2058
- processTree() {
2059
- const all = this.allProcesses();
2060
- const nodeMap = new Map();
2061
- // Index: create a tree node for each process
2062
- for (const proc of all) {
2063
- nodeMap.set(proc.pid, { ...proc, children: [] });
2064
- }
2065
- // Wire: attach each node to its parent
2066
- const roots = [];
2067
- for (const node of nodeMap.values()) {
2068
- const parent = nodeMap.get(node.ppid);
2069
- if (parent) {
2070
- parent.children.push(node);
2071
- }
2072
- else {
2073
- roots.push(node);
2074
- }
2075
- }
2076
- return roots;
2077
- }
2078
1117
  /** Returns info about a specific process by PID. Throws if not found. */
2079
- getProcess(pid) {
2080
- const entry = this._processes.get(pid);
2081
- if (!entry) {
1118
+ async getProcess(pid) {
1119
+ if (!this._processes.has(pid)) {
2082
1120
  throw new Error(`Process not found: ${pid}`);
2083
1121
  }
1122
+ const process = (await this.allProcesses()).find((candidate) => candidate.pid === pid);
1123
+ if (!process) {
1124
+ throw new Error(`Sidecar process snapshot is missing tracked process: ${pid}`);
1125
+ }
2084
1126
  return {
2085
- pid: entry.proc.pid,
2086
- command: entry.command,
2087
- args: entry.args,
2088
- running: entry.proc.exitCode === null,
2089
- exitCode: entry.proc.exitCode,
1127
+ pid: process.pid,
1128
+ command: process.command,
1129
+ args: process.args.slice(1),
1130
+ running: process.status !== "exited",
1131
+ exitCode: process.exitCode,
2090
1132
  };
2091
1133
  }
2092
1134
  /** Send SIGTERM to gracefully stop a process. No-op if already exited. */
2093
- stopProcess(pid) {
1135
+ async stopProcess(pid) {
2094
1136
  const entry = this._processes.get(pid);
2095
1137
  if (!entry) {
2096
1138
  throw new Error(`Process not found: ${pid}`);
2097
1139
  }
2098
- if (entry.proc.exitCode !== null)
1140
+ if (entry.state === "exited")
2099
1141
  return;
2100
- entry.proc.kill();
1142
+ if (entry.state === "failed")
1143
+ throw entry.error;
1144
+ await entry.proc.kill();
2101
1145
  }
2102
1146
  /** Send SIGKILL to force-kill a process. No-op if already exited. */
2103
- killProcess(pid) {
1147
+ async killProcess(pid) {
2104
1148
  const entry = this._processes.get(pid);
2105
1149
  if (!entry) {
2106
1150
  throw new Error(`Process not found: ${pid}`);
2107
1151
  }
2108
- if (entry.proc.exitCode !== null)
1152
+ if (entry.state === "exited")
2109
1153
  return;
2110
- entry.proc.kill(9);
1154
+ if (entry.state === "failed")
1155
+ throw entry.error;
1156
+ await entry.proc.kill(9);
2111
1157
  }
2112
- /** Returns all active sessions with their IDs and agent types. */
2113
- listSessions() {
2114
- return [...this._sessions.values()].map((s) => ({
2115
- sessionId: s.sessionId,
2116
- agentType: s.agentType,
2117
- }));
1158
+ /** Returns the sidecar's authoritative active-session list. */
1159
+ async listSessions() {
1160
+ const response = await this._sendAcpRequest({
1161
+ tag: "AcpListSessionsRequest",
1162
+ val: { reserved: false },
1163
+ });
1164
+ if (response.tag !== "AcpListSessionsResponse") {
1165
+ throw new Error(`unexpected response to AcpListSessionsRequest: ${response.tag}`);
1166
+ }
1167
+ return response.val.sessions.map((session) => ({ ...session }));
2118
1168
  }
2119
1169
  /** Internal helper: retrieve a session or throw. */
2120
1170
  _requireSession(sessionId) {
@@ -2124,87 +1174,49 @@ export class AgentOs {
2124
1174
  }
2125
1175
  return session;
2126
1176
  }
2127
- /** Returns all registered agents with their installation status. */
2128
- listAgents() {
2129
- // Collect all agent IDs from both package configs and hardcoded configs.
2130
- const allIds = new Set([
2131
- ...this._softwareAgentConfigs.keys(),
2132
- ...Object.keys(AGENT_CONFIGS),
2133
- ]);
2134
- return [...allIds]
2135
- .map((id) => {
2136
- const config = this._resolveAgentConfig(id);
2137
- if (!config)
2138
- return null;
2139
- let installed = false;
2140
- try {
2141
- // Check software roots first, then the host dir behind the
2142
- // `/root/node_modules` mount.
2143
- const vmPrefix = `/root/node_modules/${config.acpAdapter}`;
2144
- let hostPkgJsonPath = null;
2145
- for (const root of this._softwareRoots) {
2146
- if (root.vmPath === vmPrefix) {
2147
- hostPkgJsonPath = join(root.hostPath, "package.json");
2148
- break;
2149
- }
2150
- }
2151
- if (!hostPkgJsonPath) {
2152
- const nodeModulesRoot = this._nodeModulesHostRoot();
2153
- if (nodeModulesRoot) {
2154
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, config.acpAdapter), "package.json");
2155
- }
2156
- }
2157
- if (!hostPkgJsonPath) {
2158
- throw new Error("no package source");
2159
- }
2160
- readFileSync(hostPkgJsonPath);
2161
- installed = true;
2162
- }
2163
- catch {
2164
- // Package not installed
2165
- }
2166
- return {
2167
- id,
2168
- acpAdapter: config.acpAdapter,
2169
- agentPackage: config.agentPackage,
2170
- installed,
2171
- };
2172
- })
2173
- .filter((entry) => entry !== null);
2174
- }
2175
- _syncSessionState(session, state) {
2176
- session.processId = state.processId;
2177
- session.pid = state.pid ?? null;
2178
- session.closed = state.closed;
2179
- session.modes = toSessionModes(state.modes);
2180
- session.configOptions = toSessionConfigOptions(state.configOptions);
2181
- this._applySyntheticConfigOverrides(session);
2182
- session.capabilities = toAgentCapabilities(state.agentCapabilities);
2183
- session.agentInfo = toAgentInfo(state.agentInfo);
2184
- }
2185
- _applySessionUpdate(session, notification) {
2186
- if (notification.method !== "session/update") {
2187
- return;
2188
- }
2189
- const params = toRecord(notification.params);
2190
- const update = toRecord(params.update ?? params);
2191
- const sessionUpdate = update.sessionUpdate;
2192
- if (sessionUpdate === "current_mode_update" &&
2193
- typeof update.currentModeId === "string" &&
2194
- session.modes) {
2195
- session.modes = {
2196
- ...session.modes,
2197
- currentModeId: update.currentModeId,
2198
- };
2199
- }
2200
- if ((sessionUpdate === "config_option_update" ||
2201
- sessionUpdate === "config_options_update") &&
2202
- Array.isArray(update.configOptions)) {
2203
- session.configOptions = update.configOptions;
1177
+ /**
1178
+ * Dynamically link a software package into the RUNNING VM. The package's
1179
+ * `bin/` commands appear under `/opt/agentos/bin` (on `$PATH`) and its `share/man`
1180
+ * pages under MANPATH immediately — the `/opt/agentos` mount is host-backed, so
1181
+ * writing into its staging dir is reflected live with no reboot. An `agent`
1182
+ * block registers the package for `createSession(name)`. Persists for the VM's
1183
+ * lifetime (and across a snapshot iff the volume persists).
1184
+ */
1185
+ async linkSoftware(descriptor) {
1186
+ const ref = normalizePackageRef(descriptor);
1187
+ // Forward to the sidecar, which owns the `/opt/agentos` projection and
1188
+ // appends the package to its live host-backed staging dir; the commands
1189
+ // appear under `/opt/agentos/bin` immediately. The sidecar rejects a
1190
+ // duplicate command, surfaced here as a thrown error.
1191
+ await this._sidecarClient.linkPackage(this._sidecarSession, this._sidecarVm, { path: ref.path });
1192
+ // The client parses no manifests: an `agent` block in the linked package is
1193
+ // picked up by the sidecar (it owns the projected `/opt/agentos` and answers
1194
+ // createSession/listAgents from it). Nothing to record client-side.
1195
+ }
1196
+ async providedCommands() {
1197
+ return this._sidecarClient.providedCommands(this._sidecarSession, this._sidecarVm);
1198
+ }
1199
+ /**
1200
+ * Returns all registered agents with their installation status. Thin forwarder:
1201
+ * sends `AcpListAgentsRequest` and maps the response. The sidecar enumerates the
1202
+ * projected `/opt/agentos` packages (the client parses no manifests). Every such
1203
+ * agent is a package materialized into the VM, so `installed` is always `true`.
1204
+ */
1205
+ async listAgents() {
1206
+ const response = await this._sendAcpRequest({
1207
+ tag: "AcpListAgentsRequest",
1208
+ val: { reserved: false },
1209
+ });
1210
+ if (response.tag !== "AcpListAgentsResponse") {
1211
+ throw new Error(`unexpected list_agents response: ${response.tag}`);
2204
1212
  }
1213
+ return response.val.agents.map((agent) => ({
1214
+ id: agent.id,
1215
+ installed: agent.installed,
1216
+ adapterEntrypoint: agent.adapterEntrypoint,
1217
+ }));
2205
1218
  }
2206
1219
  _recordSessionNotification(session, notification) {
2207
- this._applySessionUpdate(session, notification);
2208
1220
  if (shouldDispatchToSessionEventHandlers(notification)) {
2209
1221
  this._dispatchSessionEvent(session, notification);
2210
1222
  }
@@ -2235,8 +1247,8 @@ export class AgentOs {
2235
1247
  try {
2236
1248
  subscriber.handler(notification);
2237
1249
  }
2238
- catch {
2239
- // Ignore subscriber callback failures and keep event delivery moving.
1250
+ catch (error) {
1251
+ console.warn(`ACP session event subscriber failed for ${session.sessionId}`, error);
2240
1252
  }
2241
1253
  }
2242
1254
  }
@@ -2249,961 +1261,492 @@ export class AgentOs {
2249
1261
  session.eventHandlers.delete(subscriber);
2250
1262
  };
2251
1263
  }
2252
- _applySyntheticConfigOverrides(session) {
2253
- if (session.configOverrides.size === 0) {
1264
+ /**
1265
+ * Warn once per session (host-visible) that a tool-permission request was
1266
+ * auto-denied because no `onPermissionRequest` handler is registered. Shared
1267
+ * by both the bare-callback and JSON-RPC permission paths so the message and
1268
+ * the once-per-session guard cannot drift between them.
1269
+ */
1270
+ _warnNoPermissionHandlerOnce(session, params) {
1271
+ if (session.warnedNoPermissionHandler) {
2254
1272
  return;
2255
1273
  }
2256
- session.configOptions = session.configOptions.map((option) => {
2257
- const override = session.configOverrides.get(option.id) ??
2258
- (typeof option.category === "string"
2259
- ? session.configOverrides.get(option.category)
2260
- : undefined);
2261
- return override === undefined
2262
- ? option
2263
- : { ...option, currentValue: override };
2264
- });
1274
+ session.warnedNoPermissionHandler = true;
1275
+ this._emitSessionWarning(session, `agentos: a tool-permission request (${this._permissionToolLabel(params)}) was ` +
1276
+ `auto-denied because no onPermissionRequest handler is registered for session ` +
1277
+ `${session.sessionId}. Register one with vm.onPermissionRequest(sessionId, ...) and ` +
1278
+ `reply via vm.respondPermission(...) to let the agent use tools.`);
2265
1279
  }
2266
- _handleSidecarEvent(event) {
2267
- if (event.payload.type === "ext") {
2268
- this._handleAcpExtEvent(event.payload.envelope);
2269
- return;
1280
+ /** Best-effort human label for the tool named in a permission request. */
1281
+ _permissionToolLabel(params) {
1282
+ if (typeof params.toolName === "string") {
1283
+ return params.toolName;
2270
1284
  }
2271
- if (event.payload.type !== "structured") {
2272
- return;
1285
+ const toolCall = params.toolCall;
1286
+ if (toolCall &&
1287
+ typeof toolCall === "object" &&
1288
+ typeof toolCall.title === "string") {
1289
+ return toolCall.title;
2273
1290
  }
2274
- if (event.payload.name !== "acp.session_event") {
2275
- return;
2276
- }
2277
- const sessionId = event.payload.detail.session_id;
2278
- const session = sessionId ? this._sessions.get(sessionId) : undefined;
2279
- if (!session) {
2280
- return;
2281
- }
2282
- const notificationText = event.payload.detail.notification;
2283
- if (typeof notificationText !== "string") {
1291
+ return "a tool";
1292
+ }
1293
+ /**
1294
+ * Emit a host-visible warning for a session through the same agent-process log
1295
+ * channel that surfaces adapter stderr (`onAgentStderr`, default: process
1296
+ * stderr). Used for agent-os-owned diagnostics — e.g. a permission request
1297
+ * that was auto-denied because no host hook is registered — so they never fire
1298
+ * silently inside the sidecar.
1299
+ */
1300
+ _emitSessionWarning(session, message) {
1301
+ const handler = this._agentStderrHandler;
1302
+ if (!handler) {
2284
1303
  return;
2285
1304
  }
2286
1305
  try {
2287
- this._recordSessionNotification(session, toJsonRpcNotification(JSON.parse(notificationText)));
1306
+ handler({
1307
+ sessionId: session.sessionId,
1308
+ agentType: session.agentType,
1309
+ processId: session.processId,
1310
+ pid: session.pid,
1311
+ chunk: new TextEncoder().encode(`${message}\n`),
1312
+ });
2288
1313
  }
2289
- catch {
2290
- // Ignore malformed event payloads from the sidecar.
1314
+ catch (error) {
1315
+ console.warn(`ACP warning handler failed for ${session.sessionId}`, error);
2291
1316
  }
2292
1317
  }
2293
- _handleAcpExtEvent(envelope) {
2294
- if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
1318
+ _recordAgentStderr(event) {
1319
+ const session = this._sessions.get(event.sessionId);
1320
+ const handler = this._agentStderrHandler;
1321
+ if (!handler) {
2295
1322
  return;
2296
1323
  }
2297
1324
  try {
2298
- const event = decodeAcpEvent(envelope.payload);
2299
- switch (event.tag) {
2300
- case "AcpSessionEvent": {
2301
- const session = this._sessions.get(event.val.sessionId);
2302
- if (!session) {
2303
- return;
2304
- }
2305
- this._recordSessionNotification(session, toJsonRpcNotification(JSON.parse(event.val.notification)));
2306
- return;
2307
- }
2308
- }
1325
+ handler({
1326
+ sessionId: event.sessionId,
1327
+ agentType: event.agentType,
1328
+ processId: event.processId,
1329
+ pid: session?.pid ?? null,
1330
+ chunk: new Uint8Array(event.chunk),
1331
+ });
2309
1332
  }
2310
- catch {
2311
- // Ignore malformed event payloads from the sidecar.
1333
+ catch (error) {
1334
+ console.warn(`ACP stderr handler failed for ${event.sessionId}`, error);
2312
1335
  }
2313
1336
  }
2314
- _unsupportedConfigResponse(agentType, category) {
2315
- const message = agentType === "opencode" && category === "model"
2316
- ? "OpenCode reports available models, but model switching must be configured before createSession() because ACP session/set_config_option is not implemented."
2317
- : `The ${category} config option is read-only for ${agentType} sessions.`;
2318
- return {
2319
- jsonrpc: "2.0",
2320
- id: null,
2321
- error: {
2322
- code: -32601,
2323
- message,
2324
- },
2325
- };
2326
- }
2327
- async _sendAcpRequest(request) {
2328
- const envelope = await this._sidecarClient.extensionRequest(this._sidecarSession, this._sidecarVm, {
2329
- namespace: ACP_EXTENSION_NAMESPACE,
2330
- payload: encodeAcpRequest(request),
2331
- });
2332
- if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
2333
- throw new Error(`unexpected ACP Ext namespace: ${envelope.namespace}`);
2334
- }
2335
- const response = decodeAcpResponse(envelope.payload);
2336
- if (response.tag === "AcpErrorResponse") {
2337
- const error = new Error(response.val.message);
2338
- error.code = response.val.code;
2339
- throw error;
1337
+ _recordAgentExit(event) {
1338
+ const session = this._sessions.get(event.sessionId);
1339
+ const handler = this._agentExitHandler;
1340
+ if (!handler) {
1341
+ return;
2340
1342
  }
2341
- return response;
2342
- }
2343
- async _sendSessionRequest(sessionId, method, params) {
2344
- const session = this._requireSession(sessionId);
2345
- const response = await new Promise((resolve, reject) => {
2346
- const resolvers = this._pendingSessionRequestResolvers.get(sessionId) ?? new Set();
2347
- const resolver = {
2348
- method,
2349
- resolve: (response) => {
2350
- resolve(response);
2351
- },
2352
- };
2353
- resolvers.add(resolver);
2354
- this._pendingSessionRequestResolvers.set(sessionId, resolvers);
2355
- void this._sendAcpRequest({
2356
- tag: "AcpSessionRequest",
2357
- val: {
2358
- sessionId,
2359
- method,
2360
- params: params === undefined ? null : JSON.stringify(params),
2361
- },
2362
- })
2363
- .then((response) => {
2364
- if (response.tag !== "AcpSessionRpcResponse") {
2365
- throw new Error(`unexpected response to AcpSessionRequest: ${response.tag}`);
2366
- }
2367
- return toJsonRpcResponse(JSON.parse(response.val.response));
2368
- })
2369
- .then(resolve, reject)
2370
- .finally(() => {
2371
- const nextResolvers = this._pendingSessionRequestResolvers.get(sessionId);
2372
- if (!nextResolvers) {
2373
- return;
2374
- }
2375
- nextResolvers.delete(resolver);
2376
- if (nextResolvers.size === 0) {
2377
- this._pendingSessionRequestResolvers.delete(sessionId);
2378
- }
1343
+ try {
1344
+ handler({
1345
+ sessionId: event.sessionId,
1346
+ agentType: event.agentType,
1347
+ processId: event.processId,
1348
+ pid: session?.pid ?? null,
1349
+ exitCode: event.exitCode,
1350
+ restart: event.restart,
1351
+ restartCount: event.restartCount,
1352
+ maxRestarts: event.maxRestarts,
2379
1353
  });
2380
- });
2381
- const liveSession = this._sessions.get(sessionId);
2382
- if (liveSession && !isLocalCancelledPromptResponse(method, response)) {
2383
- await this._hydrateSessionState(liveSession).catch(() => { });
2384
- }
2385
- if (!response.error) {
2386
- if (method === "session/set_mode" &&
2387
- typeof params?.modeId === "string" &&
2388
- session.modes) {
2389
- session.modes = {
2390
- ...session.modes,
2391
- currentModeId: params.modeId,
2392
- };
2393
- }
2394
- if (method === "session/set_config_option" &&
2395
- typeof params?.configId === "string" &&
2396
- typeof params?.value === "string") {
2397
- const nextValue = params.value;
2398
- const updatedOption = session.configOptions.find((option) => option.id === params.configId);
2399
- session.configOverrides.set(params.configId, nextValue);
2400
- if (typeof updatedOption?.category === "string") {
2401
- session.configOverrides.set(updatedOption.category, nextValue);
2402
- }
2403
- session.configOptions = session.configOptions.map((option) => option.id === params.configId
2404
- ? { ...option, currentValue: nextValue }
2405
- : option);
2406
- }
2407
1354
  }
2408
- return response;
2409
- }
2410
- async _setSessionConfigByCategory(sessionId, category, value) {
2411
- const session = this._requireSession(sessionId);
2412
- const option = session.configOptions.find((entry) => entry.category === category);
2413
- if (option?.readOnly) {
2414
- return this._unsupportedConfigResponse(session.agentType, category);
1355
+ catch (error) {
1356
+ console.warn(`ACP exit handler failed for ${event.sessionId}`, error);
2415
1357
  }
2416
- const response = await this._sendSessionRequest(sessionId, "session/set_config_option", {
2417
- configId: option?.id ?? category,
2418
- value,
2419
- });
2420
- return response;
2421
1358
  }
2422
- _removeSession(sessionId) {
2423
- this._sessions.delete(sessionId);
2424
- }
2425
- _abortPendingSessionRequests(sessionId) {
2426
- const resolvers = this._pendingSessionRequestResolvers.get(sessionId);
2427
- if (!resolvers) {
1359
+ _handleSidecarEvent(event) {
1360
+ if (event.payload.type === "ext") {
1361
+ this._handleAcpExtEvent(event.payload.envelope);
2428
1362
  return;
2429
1363
  }
2430
- this._pendingSessionRequestResolvers.delete(sessionId);
2431
- const response = {
2432
- jsonrpc: "2.0",
2433
- id: null,
2434
- error: {
2435
- code: -32_000,
2436
- message: `Session closed: ${sessionId}`,
2437
- },
2438
- };
2439
- for (const resolver of resolvers) {
2440
- resolver.resolve(response);
1364
+ if (event.payload.type === "cron_dispatch") {
1365
+ const dispatch = event.payload.dispatch;
1366
+ this._cronManager.consumeDispatch({
1367
+ alarm: {
1368
+ generation: dispatch.alarm.generation,
1369
+ ...(dispatch.alarm.next_alarm_ms === undefined
1370
+ ? {}
1371
+ : { nextAlarmMs: dispatch.alarm.next_alarm_ms }),
1372
+ },
1373
+ runs: dispatch.runs.map((run) => ({
1374
+ runId: run.run_id,
1375
+ jobId: run.job_id,
1376
+ action: run.action,
1377
+ })),
1378
+ events: dispatch.events.map((record) => ({
1379
+ kind: record.kind,
1380
+ jobId: record.job_id,
1381
+ timeMs: record.time_ms,
1382
+ ...(record.duration_ms === undefined
1383
+ ? {}
1384
+ : { durationMs: record.duration_ms }),
1385
+ ...(record.error === undefined ? {} : { error: record.error }),
1386
+ })),
1387
+ });
1388
+ return;
2441
1389
  }
2442
- }
2443
- _cancelPendingPromptRequests(sessionId) {
2444
- const resolvers = this._pendingSessionRequestResolvers.get(sessionId);
2445
- if (!resolvers) {
2446
- return false;
1390
+ if (event.payload.type !== "structured") {
1391
+ return;
2447
1392
  }
2448
- const response = {
2449
- jsonrpc: "2.0",
2450
- id: null,
2451
- result: {
2452
- stopReason: "cancelled",
2453
- },
2454
- };
2455
- let cancelledPrompt = false;
2456
- for (const resolver of [...resolvers]) {
2457
- if (resolver.method !== "session/prompt") {
2458
- continue;
2459
- }
2460
- resolvers.delete(resolver);
2461
- resolver.resolve(response);
2462
- cancelledPrompt = true;
1393
+ if (event.payload.name === "limit_warning") {
1394
+ this._handleLimitWarning(event.payload.detail);
1395
+ return;
2463
1396
  }
2464
- if (resolvers.size === 0) {
2465
- this._pendingSessionRequestResolvers.delete(sessionId);
1397
+ if (event.payload.name !== "acp.session_event") {
1398
+ return;
2466
1399
  }
2467
- return cancelledPrompt;
2468
- }
2469
- _rejectPendingPermissionReplies(sessionId) {
2470
- const session = this._sessions.get(sessionId);
1400
+ const sessionId = event.payload.detail.session_id;
1401
+ const session = sessionId ? this._sessions.get(sessionId) : undefined;
2471
1402
  if (!session) {
2472
1403
  return;
2473
1404
  }
2474
- this._rejectPendingPermissionRepliesFromSession(session);
2475
- }
2476
- _rejectPendingPermissionRepliesFromSession(session) {
2477
- for (const [permissionId, pendingReply,] of session.pendingPermissionReplies) {
2478
- clearTimeout(pendingReply.timer);
2479
- pendingReply.reject(new Error(`Session closed before permission reply: ${permissionId}`));
2480
- }
2481
- session.pendingPermissionReplies.clear();
2482
- }
2483
- async _closeSessionInternal(sessionId) {
2484
- const closing = this._sessionClosePromises.get(sessionId);
2485
- if (closing) {
2486
- return closing;
2487
- }
2488
- if (this._closedSessionIds.has(sessionId)) {
1405
+ const notificationText = event.payload.detail.notification;
1406
+ if (typeof notificationText !== "string") {
2489
1407
  return;
2490
1408
  }
2491
- this._abortPendingSessionRequests(sessionId);
2492
- this._rejectPendingPermissionReplies(sessionId);
2493
- this._requireSession(sessionId);
2494
- this._removeSession(sessionId);
2495
- this._closedSessionIds.add(sessionId);
2496
- const closePromise = this._sendAcpRequest({
2497
- tag: "AcpCloseSessionRequest",
2498
- val: { sessionId },
2499
- })
2500
- .then((response) => {
2501
- if (response.tag !== "AcpSessionClosedResponse") {
2502
- throw new Error(`unexpected response to AcpCloseSessionRequest: ${response.tag}`);
2503
- }
2504
- })
2505
- .finally(() => {
2506
- this._sessionClosePromises.delete(sessionId);
2507
- });
2508
- this._sessionClosePromises.set(sessionId, closePromise);
2509
- await closePromise;
2510
- }
2511
- async _hydrateSessionState(session) {
2512
- const response = await this._sendAcpRequest({
2513
- tag: "AcpGetSessionStateRequest",
2514
- val: { sessionId: session.sessionId },
2515
- });
2516
- if (response.tag !== "AcpSessionStateResponse") {
2517
- throw new Error(`unexpected response to AcpGetSessionStateRequest: ${response.tag}`);
2518
- }
2519
- const state = sidecarSessionStateFromAcp(response.val);
2520
- this._syncSessionState(session, state);
2521
- }
2522
- async createSession(agentType, options) {
2523
- const config = this._resolveAgentConfig(agentType);
2524
- if (!config) {
2525
- throw new Error(`Unknown agent type: ${agentType}`);
2526
- }
2527
- // System-prompt assembly and injection (launch args / OPENCODE_CONTEXTPATHS) are owned by
2528
- // the sidecar at AcpCreateSessionRequest. The host only forwards additionalInstructions /
2529
- // skipOsInstructions plus the agent's static launch args and env.
2530
- const launchArgs = [...(config.launchArgs ?? [])];
2531
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2532
- const sessionCwd = options?.cwd ?? "/workspace";
2533
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2534
- if ((agentType === "pi" || agentType === "pi-cli") &&
2535
- !launchEnv.PI_ACP_PI_COMMAND) {
2536
- launchEnv = {
2537
- ...launchEnv,
2538
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2539
- };
2540
- }
2541
- const response = await this._sendAcpRequest({
2542
- tag: "AcpCreateSessionRequest",
2543
- val: {
2544
- agentType: String(agentType),
2545
- runtime: AcpRuntimeKind.JavaScript,
2546
- adapterEntrypoint,
2547
- args: launchArgs,
2548
- env: new Map(Object.entries(launchEnv)),
2549
- cwd: sessionCwd,
2550
- mcpServers: JSON.stringify(options?.mcpServers ?? []),
2551
- protocolVersion: ACP_PROTOCOL_VERSION,
2552
- clientCapabilities: JSON.stringify(defaultAcpClientCapabilities()),
2553
- additionalInstructions: combineInstructions(options?.additionalInstructions, this._toolReference),
2554
- skipOsInstructions: options?.skipOsInstructions ?? false,
2555
- },
2556
- });
2557
- if (response.tag !== "AcpSessionCreatedResponse") {
2558
- throw new Error(`unexpected create_session response: ${response.tag}`);
2559
- }
2560
- const created = sidecarSessionCreatedFromAcp(response.val);
2561
- // The sessionId is chosen by the (untrusted/buggy) ACP adapter or sidecar. If it collides
2562
- // with a live session already in `_sessions`, blindly overwriting the entry would orphan the
2563
- // first session's event/permission handlers and pending permission replies. Fail closed:
2564
- // reject the colliding create and leave the original session intact. (A previously-closed,
2565
- // evicted id may still be re-used; only a live, non-closed entry is a collision.)
2566
- const existing = this._sessions.get(created.sessionId);
2567
- if (existing !== undefined && !existing.closed) {
2568
- throw new Error(`session id collision: ${created.sessionId}`);
2569
- }
2570
- const initData = {
2571
- modes: toSessionModes(created.modes) ?? undefined,
2572
- configOptions: toSessionConfigOptions(created.configOptions),
2573
- capabilities: toAgentCapabilities(created.agentCapabilities),
2574
- agentInfo: toAgentInfo(created.agentInfo) ?? undefined,
2575
- };
2576
- const session = sessionEntryFromInit(created.sessionId, String(agentType), initData);
2577
- this._closedSessionIds.delete(created.sessionId);
2578
- this._sessions.set(created.sessionId, session);
2579
1409
  try {
2580
- await this._hydrateSessionState(session);
1410
+ this._recordSessionNotification(session, toJsonRpcNotification(JSON.parse(notificationText)));
2581
1411
  }
2582
1412
  catch (error) {
2583
- this._removeSession(created.sessionId);
2584
- throw error;
1413
+ console.warn("invalid ACP session event from sidecar", error);
2585
1414
  }
2586
- return { sessionId: created.sessionId };
2587
1415
  }
2588
- /**
2589
- * Resume a session that exists in durable storage but is not live in this VM
2590
- * (e.g. after a Rivet actor slept and woke with a fresh VM). Thin forwarder:
2591
- * resolves the agent config + adapter entrypoint exactly as {@link createSession}
2592
- * does, then forwards a single `AcpResumeSessionRequest` to the sidecar, which
2593
- * owns the resume state machine (native `session/load` when the agent supports
2594
- * it, else `session/new` + a transcript-continuation preamble). The returned
2595
- * `sessionId` is the live id in this VM (equal to the requested id for native
2596
- * loads, freshly assigned for the fallback); the caller remaps `external -> live`.
2597
- * The new live session is registered + hydrated locally so subsequent prompts
2598
- * route to it.
2599
- *
2600
- * Resume depends on a durable root; on a non-durable (default in-memory) root
2601
- * there is no surviving store and the fallback tier always runs.
2602
- */
2603
- async resumeSession(sessionId, agentType, options) {
2604
- const config = this._resolveAgentConfig(agentType);
2605
- if (!config) {
2606
- throw new Error(`Unknown agent type: ${agentType}`);
2607
- }
2608
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2609
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2610
- const sessionCwd = options?.cwd ?? "/workspace";
2611
- if ((agentType === "pi" || agentType === "pi-cli") &&
2612
- !launchEnv.PI_ACP_PI_COMMAND) {
2613
- launchEnv = {
2614
- ...launchEnv,
2615
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
1416
+ _handleLimitWarning(detail) {
1417
+ if (!this._limitWarningHandler) {
1418
+ return;
1419
+ }
1420
+ let warning;
1421
+ try {
1422
+ const requireString = (name) => {
1423
+ const value = detail[name];
1424
+ if (value === undefined) {
1425
+ throw new Error(`missing ${name}`);
1426
+ }
1427
+ return value;
1428
+ };
1429
+ const requireNumber = (name) => {
1430
+ const value = Number(requireString(name));
1431
+ if (!Number.isFinite(value)) {
1432
+ throw new Error(`invalid ${name}`);
1433
+ }
1434
+ return value;
1435
+ };
1436
+ warning = {
1437
+ limit: requireString("limit"),
1438
+ category: requireString("category"),
1439
+ observed: requireNumber("observed"),
1440
+ capacity: requireNumber("capacity"),
1441
+ fillPercent: requireNumber("fillPercent"),
2616
1442
  };
2617
1443
  }
2618
- // The resume wire request has no dedicated `adapterEntrypoint` field; carry
2619
- // the resolved entrypoint through env under the sidecar's reserved key. The
2620
- // sidecar reads it and strips it before launching the adapter.
2621
- launchEnv = {
2622
- ...launchEnv,
2623
- [RESUME_ADAPTER_ENTRYPOINT_ENV]: adapterEntrypoint,
2624
- };
2625
- const response = await this._sendAcpRequest({
2626
- tag: "AcpResumeSessionRequest",
2627
- val: {
2628
- sessionId,
2629
- agentType: String(agentType),
2630
- transcriptPath: options?.transcriptPath ?? null,
2631
- cwd: sessionCwd,
2632
- env: new Map(Object.entries(launchEnv)),
2633
- },
2634
- });
2635
- if (response.tag !== "AcpSessionResumedResponse") {
2636
- throw new Error(`unexpected resume_session response: ${response.tag}`);
1444
+ catch (error) {
1445
+ console.warn("invalid limit warning from sidecar", error);
1446
+ return;
2637
1447
  }
2638
- const { sessionId: liveSessionId, mode } = response.val;
2639
- // Register + hydrate the live session so subsequent prompts route to it.
2640
- const session = sessionEntryFromInit(liveSessionId, String(agentType), {});
2641
- this._closedSessionIds.delete(liveSessionId);
2642
- this._sessions.set(liveSessionId, session);
2643
1448
  try {
2644
- await this._hydrateSessionState(session);
1449
+ this._limitWarningHandler(warning);
2645
1450
  }
2646
1451
  catch (error) {
2647
- this._removeSession(liveSessionId);
2648
- throw error;
1452
+ console.warn("limit warning handler failed", error);
2649
1453
  }
2650
- return { sessionId: liveSessionId, mode };
2651
1454
  }
2652
- /**
2653
- * Resolve the VM bin entry point of an ACP adapter package.
2654
- * Reads from the host filesystem since kernel.readFile() resolves through
2655
- * mounts; adapter package.json is read directly off the host dir backing
2656
- * the relevant mount.
2657
- */
2658
- _resolveAdapterBin(adapterPackage) {
2659
- return this._resolvePackageBin(adapterPackage);
2660
- }
2661
- /**
2662
- * Parent of the host directory backing the `/root/node_modules` mount, if
2663
- * the caller supplied one (e.g. via `nodeModulesMount(...)`). Used as the
2664
- * `resolvePackageDir` start dir for adapter/agent package resolution when no
2665
- * software root matches. `nodeModulesMount` mounts `<dir>/node_modules`, so
2666
- * the start dir is `<dir>` (one level above the node_modules tree).
2667
- */
2668
- _nodeModulesHostRoot() {
2669
- for (const mount of this._hostMounts) {
2670
- if (mount.vmPath === "/root/node_modules") {
2671
- return dirname(mount.hostPath);
2672
- }
2673
- }
2674
- return null;
2675
- }
2676
- _resolvePackageBin(packageName, binName) {
2677
- const vmPrefix = `/root/node_modules/${packageName}`;
2678
- let hostPkgJsonPath = null;
2679
- for (const root of this._softwareRoots) {
2680
- if (root.vmPath === vmPrefix) {
2681
- hostPkgJsonPath = join(root.hostPath, "package.json");
2682
- break;
2683
- }
1455
+ _handleAcpExtEvent(envelope) {
1456
+ if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
1457
+ return;
2684
1458
  }
2685
- // Fall back to the host dir behind the `/root/node_modules` mount.
2686
- if (!hostPkgJsonPath) {
2687
- const nodeModulesRoot = this._nodeModulesHostRoot();
2688
- if (!nodeModulesRoot) {
2689
- throw new Error(`Cannot resolve package "${packageName}": no software root provides it and ` +
2690
- `no /root/node_modules mount was supplied. Pass ` +
2691
- `mounts: [nodeModulesMount("<host>/node_modules")] to AgentOs.create().`);
1459
+ try {
1460
+ const event = decodeAcpEvent(envelope.payload);
1461
+ switch (event.tag) {
1462
+ case "AcpSessionEvent": {
1463
+ const session = this._sessions.get(event.val.sessionId);
1464
+ if (!session) {
1465
+ return;
1466
+ }
1467
+ this._recordSessionNotification(session, toJsonRpcNotification(JSON.parse(event.val.notification)));
1468
+ return;
1469
+ }
1470
+ case "AcpAgentStderrEvent": {
1471
+ this._recordAgentStderr(event.val);
1472
+ return;
1473
+ }
1474
+ case "AcpAgentExitedEvent": {
1475
+ this._recordAgentExit(event.val);
1476
+ return;
1477
+ }
2692
1478
  }
2693
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, packageName), "package.json");
2694
- }
2695
- const pkg = JSON.parse(readFileSync(hostPkgJsonPath, "utf-8"));
2696
- let binEntry;
2697
- if (typeof pkg.bin === "string") {
2698
- binEntry = pkg.bin;
2699
1479
  }
2700
- else if (typeof pkg.bin === "object" && pkg.bin !== null) {
2701
- binEntry =
2702
- (binName ? pkg.bin[binName] : undefined) ??
2703
- pkg.bin[packageName] ??
2704
- Object.values(pkg.bin)[0];
2705
- }
2706
- if (!binEntry) {
2707
- throw new Error(`No bin entry found in ${packageName}/package.json`);
1480
+ catch (error) {
1481
+ console.warn("invalid ACP extension event from sidecar", error);
2708
1482
  }
2709
- return `${vmPrefix}/${binEntry}`;
2710
1483
  }
2711
- _installSidecarRequestHandler() {
2712
- const context = {
2713
- toolKits: this._toolKits,
2714
- toolMap: buildToolMap(this._toolKits),
2715
- permissions: this._permissions,
2716
- readFile: (path) => this.readFile(path),
2717
- };
2718
- this._sidecarClient.setSidecarRequestHandler((request) => {
2719
- switch (request.payload.type) {
2720
- case "host_callback":
2721
- return handleHostCallback(request, context);
2722
- case "js_bridge_call":
2723
- return Promise.resolve({
2724
- type: "js_bridge_result",
2725
- call_id: request.payload.call_id,
2726
- error: `unsupported sidecar request type: ${request.payload.type}`,
2727
- });
2728
- case "ext":
2729
- return this._handleAcpExtSidecarRequest(request.payload.envelope);
2730
- }
2731
- });
2732
- }
2733
- async _handleAcpExtSidecarRequest(envelope) {
1484
+ _decodeAcpResponseEnvelope(envelope) {
2734
1485
  if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
2735
- return {
2736
- type: "ext_result",
2737
- envelope: {
2738
- namespace: envelope.namespace,
2739
- payload: Buffer.from("unknown extension namespace", "utf8"),
2740
- },
2741
- };
1486
+ throw new Error(`unexpected ACP Ext namespace: ${envelope.namespace}`);
2742
1487
  }
2743
- const callback = decodeAcpCallback(envelope.payload);
2744
- switch (callback.tag) {
2745
- case "AcpPermissionCallback": {
2746
- const reply = await this._handleAcpPermissionCallback(callback.val.sessionId, callback.val.permissionId, {
2747
- ...toRecord(JSON.parse(callback.val.params)),
2748
- _acpMethod: ACP_PERMISSION_METHOD,
2749
- });
2750
- return {
2751
- type: "ext_result",
2752
- envelope: {
2753
- namespace: ACP_EXTENSION_NAMESPACE,
2754
- payload: encodeAcpCallbackResponse({
2755
- tag: "AcpPermissionCallbackResponse",
2756
- val: {
2757
- permissionId: callback.val.permissionId,
2758
- reply,
2759
- },
2760
- }),
2761
- },
2762
- };
2763
- }
2764
- case "AcpHostRequestCallback": {
2765
- const response = await this._dispatchAcpSidecarRequest(toJsonRpcRequest(JSON.parse(callback.val.request)));
2766
- return {
2767
- type: "ext_result",
2768
- envelope: {
2769
- namespace: ACP_EXTENSION_NAMESPACE,
2770
- payload: encodeAcpCallbackResponse({
2771
- tag: "AcpHostRequestCallbackResponse",
2772
- val: {
2773
- response: JSON.stringify(response),
2774
- },
2775
- }),
2776
- },
2777
- };
2778
- }
1488
+ const response = decodeAcpResponse(envelope.payload);
1489
+ if (response.tag === "AcpErrorResponse") {
1490
+ const error = new Error(response.val.message);
1491
+ error.code = response.val.code;
1492
+ throw error;
2779
1493
  }
1494
+ return response;
2780
1495
  }
2781
- async _dispatchAcpSidecarRequest(request) {
2782
- try {
2783
- const result = await this._handleSupportedAcpSidecarRequest(request);
2784
- return {
2785
- jsonrpc: "2.0",
2786
- id: request.id,
2787
- result,
2788
- };
2789
- }
2790
- catch (error) {
2791
- if (error instanceof AcpDispatchError) {
2792
- return {
2793
- jsonrpc: "2.0",
2794
- id: request.id,
2795
- error: {
2796
- code: error.code,
2797
- message: error.message,
2798
- ...(error.data ? { data: error.data } : {}),
2799
- },
2800
- };
2801
- }
2802
- return {
2803
- jsonrpc: "2.0",
2804
- id: request.id,
2805
- error: {
2806
- code: -32603,
2807
- message: error instanceof Error ? error.message : String(error),
1496
+ async _sendAcpRequest(request, onResponse) {
1497
+ let hookResponse;
1498
+ const envelope = await this._sidecarClient.extensionRequest(this._sidecarSession, this._sidecarVm, {
1499
+ namespace: ACP_EXTENSION_NAMESPACE,
1500
+ payload: encodeAcpRequest(request),
1501
+ }, onResponse
1502
+ ? {
1503
+ onResponse: (responseEnvelope) => {
1504
+ hookResponse =
1505
+ this._decodeAcpResponseEnvelope(responseEnvelope);
1506
+ onResponse(hookResponse);
2808
1507
  },
2809
- };
1508
+ }
1509
+ : undefined);
1510
+ if (hookResponse) {
1511
+ return hookResponse;
2810
1512
  }
1513
+ const response = this._decodeAcpResponseEnvelope(envelope);
1514
+ onResponse?.(response);
1515
+ return response;
2811
1516
  }
2812
- async _handleSupportedAcpSidecarRequest(request) {
2813
- const params = this._acpParams(request);
2814
- switch (request.method) {
2815
- case ACP_PERMISSION_METHOD:
2816
- return this._handleAcpPermissionRequest(request, params);
2817
- case "fs/read":
2818
- case "fs/read_text_file":
2819
- return this._handleAcpReadFile(params);
2820
- case "fs/write":
2821
- case "fs/write_text_file":
2822
- return this._handleAcpWriteFile(params);
2823
- case "fs/readDir":
2824
- case "fs/read_dir":
2825
- return this._handleAcpReadDir(params);
2826
- case "terminal/create":
2827
- return this._handleAcpCreateTerminal(params);
2828
- case "terminal/write":
2829
- return this._handleAcpWriteTerminal(params);
2830
- case "terminal/output":
2831
- case "terminal/read":
2832
- return this._handleAcpReadTerminal(params);
2833
- case "terminal/wait_for_exit":
2834
- case "terminal/waitForExit":
2835
- return this._handleAcpWaitForTerminalExit(params);
2836
- case "terminal/kill":
2837
- return this._handleAcpKillTerminal(params);
2838
- case "terminal/release":
2839
- case "terminal/close":
2840
- return this._handleAcpReleaseTerminal(params);
2841
- case "terminal/resize":
2842
- return this._handleAcpResizeTerminal(params);
2843
- default:
2844
- throw new AcpDispatchError(-32601, `Method not found: ${request.method}`, {
2845
- method: request.method,
2846
- });
2847
- }
1517
+ async _sendSessionRequest(sessionId, method, params) {
1518
+ return (await this._sendSessionRequestWithText(sessionId, method, params))
1519
+ .response;
2848
1520
  }
2849
- _normalizeAcpPermissionOptionId(options, reply) {
2850
- const optionTargets = reply === "always"
2851
- ? {
2852
- optionIds: new Set(["always", "allow_always"]),
2853
- kinds: new Set(["allow_always"]),
2854
- }
2855
- : reply === "once"
2856
- ? {
2857
- optionIds: new Set(["once", "allow_once"]),
2858
- kinds: new Set(["allow_once"]),
2859
- }
2860
- : {
2861
- optionIds: new Set(["reject", "reject_once"]),
2862
- kinds: new Set(["reject_once"]),
2863
- };
2864
- const matched = options?.find((option) => {
2865
- const optionId = typeof option.optionId === "string" ? option.optionId : undefined;
2866
- const kind = typeof option.kind === "string" ? option.kind : undefined;
2867
- return ((optionId !== undefined && optionTargets.optionIds.has(optionId)) ||
2868
- (kind !== undefined && optionTargets.kinds.has(kind)));
1521
+ async _sendSessionRequestWithText(sessionId, method, params) {
1522
+ const acpResponse = await this._sendAcpRequest({
1523
+ tag: "AcpSessionRequest",
1524
+ val: {
1525
+ sessionId,
1526
+ method,
1527
+ params: params === undefined ? null : JSON.stringify(params),
1528
+ },
2869
1529
  });
2870
- if (matched && typeof matched.optionId === "string") {
2871
- return matched.optionId;
2872
- }
2873
- if (reply === "always") {
2874
- return "allow_always";
2875
- }
2876
- if (reply === "once") {
2877
- return "allow_once";
1530
+ if (acpResponse.tag !== "AcpSessionRpcResponse") {
1531
+ throw new Error(`unexpected response to AcpSessionRequest: ${acpResponse.tag}`);
2878
1532
  }
2879
- return "reject_once";
2880
- }
2881
- _buildAcpPermissionResult(reply, params) {
2882
- const options = Array.isArray(params.options)
2883
- ? params.options.filter((option) => typeof option === "object" && option !== null)
2884
- : undefined;
2885
- const optionId = this._normalizeAcpPermissionOptionId(options, reply);
2886
1533
  return {
2887
- outcome: optionId
2888
- ? {
2889
- outcome: "selected",
2890
- optionId,
2891
- }
2892
- : {
2893
- outcome: "cancelled",
2894
- },
1534
+ response: toJsonRpcResponse(JSON.parse(acpResponse.val.response)),
1535
+ text: acpResponse.val.text,
2895
1536
  };
2896
1537
  }
2897
- async _handleAcpPermissionRequest(request, params) {
2898
- const sessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
2899
- if (!sessionId) {
2900
- throw new AcpDispatchError(-32602, `${ACP_PERMISSION_METHOD} requires a sessionId`);
2901
- }
2902
- const session = this._sessions.get(sessionId);
2903
- if (!session) {
2904
- throw new AcpDispatchError(-32602, `Session not found: ${sessionId}`);
2905
- }
2906
- const permissionId = String(request.id);
2907
- const permissionParams = {
2908
- ...params,
2909
- permissionId,
2910
- _acpMethod: request.method,
2911
- };
2912
- if (session.permissionHandlers.size === 0) {
2913
- return this._buildAcpPermissionResult("reject", permissionParams);
2914
- }
2915
- const reply = await new Promise((resolve, reject) => {
2916
- const timer = setTimeout(() => {
2917
- session.pendingPermissionReplies.delete(permissionId);
2918
- reject(new Error(`Timed out waiting for permission reply: ${permissionId}`));
2919
- }, 120_000);
2920
- session.pendingPermissionReplies.set(permissionId, {
2921
- resolve,
2922
- reject,
2923
- timer,
2924
- });
2925
- const permissionRequest = {
2926
- permissionId,
2927
- description: typeof permissionParams["description"] === "string"
2928
- ? permissionParams["description"]
2929
- : undefined,
2930
- params: permissionParams,
2931
- };
2932
- for (const handler of session.permissionHandlers) {
2933
- handler(permissionRequest);
2934
- }
1538
+ async _setSessionConfigByCategory(sessionId, category, value) {
1539
+ const response = await this._sendAcpRequest({
1540
+ tag: "AcpSetSessionConfigRequest",
1541
+ val: { sessionId, category, value },
2935
1542
  });
2936
- return this._buildAcpPermissionResult(reply, permissionParams);
2937
- }
2938
- _acpParams(request) {
2939
- if (!request.params) {
2940
- return {};
2941
- }
2942
- if (typeof request.params !== "object" ||
2943
- request.params === null ||
2944
- Array.isArray(request.params)) {
2945
- throw new AcpDispatchError(-32602, `${request.method} requires object params`);
1543
+ if (response.tag !== "AcpSessionRpcResponse") {
1544
+ throw new Error(`unexpected response to AcpSetSessionConfigRequest: ${response.tag}`);
2946
1545
  }
2947
- return request.params;
1546
+ return toJsonRpcResponse(JSON.parse(response.val.response));
2948
1547
  }
2949
- _requireAcpStringParam(params, name, method) {
2950
- const value = params[name];
2951
- if (typeof value !== "string") {
2952
- throw new AcpDispatchError(-32602, `${method} requires a string ${name}`);
2953
- }
2954
- return value;
1548
+ _removeSession(sessionId) {
1549
+ this._sessions.delete(sessionId);
2955
1550
  }
2956
- _optionalAcpStringParam(params, name, method) {
2957
- const value = params[name];
2958
- if (value === undefined || value === null) {
2959
- return undefined;
2960
- }
2961
- if (typeof value !== "string") {
2962
- throw new AcpDispatchError(-32602, `${method} requires ${name} to be a string when provided`);
1551
+ _rejectPendingPermissionReplies(sessionId) {
1552
+ const session = this._sessions.get(sessionId);
1553
+ if (!session) {
1554
+ return;
2963
1555
  }
2964
- return value;
1556
+ this._rejectPendingPermissionRepliesFromSession(session);
2965
1557
  }
2966
- _optionalAcpNumberParam(params, name, method) {
2967
- const value = params[name];
2968
- if (value === undefined || value === null) {
2969
- return undefined;
2970
- }
2971
- if (typeof value !== "number" || !Number.isFinite(value)) {
2972
- throw new AcpDispatchError(-32602, `${method} requires ${name} to be a number when provided`);
1558
+ _rejectPendingPermissionRepliesFromSession(session) {
1559
+ for (const [permissionId, pendingReply,] of session.pendingPermissionReplies) {
1560
+ clearTimeout(pendingReply.cleanupTimer);
1561
+ pendingReply.reject(new Error(`Session closed before permission reply: ${permissionId}`));
2973
1562
  }
2974
- return value;
1563
+ session.pendingPermissionReplies.clear();
2975
1564
  }
2976
- _optionalAcpStringArrayParam(params, name, method) {
2977
- const value = params[name];
2978
- if (value === undefined || value === null) {
2979
- return undefined;
1565
+ async _closeSessionInternal(sessionId) {
1566
+ const response = await this._sendAcpRequest({
1567
+ tag: "AcpCloseSessionRequest",
1568
+ val: { sessionId },
1569
+ });
1570
+ if (response.tag !== "AcpSessionClosedResponse") {
1571
+ throw new Error(`unexpected response to AcpCloseSessionRequest: ${response.tag}`);
2980
1572
  }
2981
- if (!Array.isArray(value) ||
2982
- value.some((entry) => typeof entry !== "string")) {
2983
- throw new AcpDispatchError(-32602, `${method} requires ${name} to be an array of strings when provided`);
1573
+ if (response.val.sessionId !== sessionId) {
1574
+ throw new Error(`unexpected session id in AcpSessionClosedResponse: expected ${sessionId}, received ${response.val.sessionId}`);
2984
1575
  }
2985
- return [...value];
1576
+ this._rejectPendingPermissionReplies(sessionId);
1577
+ this._removeSession(sessionId);
2986
1578
  }
2987
- _optionalAcpEnvParam(params, name, method) {
2988
- const value = params[name];
2989
- if (value === undefined || value === null) {
2990
- return undefined;
2991
- }
2992
- if (Array.isArray(value)) {
2993
- const env = {};
2994
- for (const entry of value) {
2995
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
2996
- throw new AcpDispatchError(-32602, `${method} requires ${name} entries to be { name, value } objects`);
2997
- }
2998
- const record = entry;
2999
- if (typeof record.name !== "string" ||
3000
- typeof record.value !== "string") {
3001
- throw new AcpDispatchError(-32602, `${method} requires ${name} entries to be { name, value } objects`);
3002
- }
3003
- env[record.name] = record.value;
3004
- }
3005
- return env;
3006
- }
3007
- if (typeof value !== "object") {
3008
- throw new AcpDispatchError(-32602, `${method} requires ${name} to be an object or name/value array`);
3009
- }
3010
- const env = {};
3011
- for (const [key, entryValue] of Object.entries(value)) {
3012
- if (typeof entryValue !== "string") {
3013
- throw new AcpDispatchError(-32602, `${method} requires ${name} values to be strings`);
3014
- }
3015
- env[key] = entryValue;
1579
+ async _getSessionState(sessionId) {
1580
+ const response = await this._sendAcpRequest({
1581
+ tag: "AcpGetSessionStateRequest",
1582
+ val: { sessionId },
1583
+ });
1584
+ if (response.tag !== "AcpSessionStateResponse") {
1585
+ throw new Error(`unexpected response to AcpGetSessionStateRequest: ${response.tag}`);
3016
1586
  }
3017
- return env;
1587
+ return sidecarSessionStateFromAcp(response.val);
3018
1588
  }
3019
- _requireAcpTerminal(params, method) {
3020
- const terminalId = this._requireAcpStringParam(params, "terminalId", method);
3021
- const terminal = this._acpTerminals.get(terminalId);
3022
- if (!terminal) {
3023
- throw new AcpDispatchError(-32602, `ACP terminal not found: ${terminalId}`);
1589
+ async createSession(agentType, options) {
1590
+ // The client is npm-agnostic: it sends only the agent NAME. The sidecar
1591
+ // resolves the name -> package -> entrypoint/env/launchArgs from the
1592
+ // projected `/opt/agentos/<name>/current/agentos-package.json` and spawns
1593
+ // (including the agent's static launch args and manifest env defaults).
1594
+ // System-prompt assembly/injection (launch args / OPENCODE_CONTEXTPATHS) is
1595
+ // owned by the sidecar; the host only forwards additionalInstructions /
1596
+ // skipOsInstructions plus the caller's env.
1597
+ const response = await this._sendAcpRequest({
1598
+ tag: "AcpCreateSessionRequest",
1599
+ val: {
1600
+ agentType: String(agentType),
1601
+ runtime: null,
1602
+ args: null,
1603
+ env: options?.env ? new Map(Object.entries(options.env)) : null,
1604
+ cwd: options?.cwd ?? null,
1605
+ mcpServers: options?.mcpServers
1606
+ ? JSON.stringify(options.mcpServers)
1607
+ : null,
1608
+ protocolVersion: null,
1609
+ clientCapabilities: null,
1610
+ additionalInstructions: options?.additionalInstructions ?? null,
1611
+ skipOsInstructions: options?.skipOsInstructions === true ? true : null,
1612
+ },
1613
+ }, (created) => {
1614
+ if (created.tag !== "AcpSessionCreatedResponse") {
1615
+ throw new Error(`unexpected create_session response: ${created.tag}`);
1616
+ }
1617
+ this._sessions.set(created.val.sessionId, sessionEntryFromRoute(created.val));
1618
+ });
1619
+ if (response.tag !== "AcpSessionCreatedResponse") {
1620
+ throw new Error(`unexpected create_session response: ${response.tag}`);
3024
1621
  }
3025
- return terminal;
1622
+ return { sessionId: response.val.sessionId };
3026
1623
  }
3027
- _appendAcpTerminalOutput(terminal, data) {
3028
- const chunk = Buffer.from(data).toString("utf8");
3029
- if (!chunk) {
3030
- return;
1624
+ /**
1625
+ * Resume a session that exists in durable storage but is not live in this VM
1626
+ * (e.g. after a Rivet actor slept and woke with a fresh VM). Thin forwarder:
1627
+ * resolves the agent config + adapter entrypoint exactly as {@link createSession}
1628
+ * does, then forwards a single `AcpResumeSessionRequest` to the sidecar, which
1629
+ * owns the resume state machine (native `session/load` when the agent supports
1630
+ * it, else `session/new` + a transcript-continuation preamble). The returned
1631
+ * `sessionId` is the live id in this VM (equal to the requested id for native
1632
+ * loads, freshly assigned for the fallback); the caller remaps `external -> live`.
1633
+ * The new live session is registered locally only for host callbacks/events;
1634
+ * authoritative state remains in the sidecar.
1635
+ *
1636
+ * Resume depends on a durable root; on a non-durable (default in-memory) root
1637
+ * there is no surviving store and the fallback tier always runs.
1638
+ */
1639
+ async resumeSession(sessionId, agentType, options) {
1640
+ // The client is npm-agnostic: it sends only the agent NAME. The sidecar
1641
+ // resolves the name -> package -> entrypoint/env/launchArgs from the
1642
+ // projected manifest, exactly as createSession does.
1643
+ const response = await this._sendAcpRequest({
1644
+ tag: "AcpResumeSessionRequest",
1645
+ val: {
1646
+ sessionId,
1647
+ agentType: String(agentType),
1648
+ transcriptPath: options?.transcriptPath ?? null,
1649
+ cwd: options?.cwd ?? null,
1650
+ env: options?.env ? new Map(Object.entries(options.env)) : null,
1651
+ },
1652
+ }, (resumed) => {
1653
+ if (resumed.tag !== "AcpSessionResumedResponse") {
1654
+ throw new Error(`unexpected resume_session response: ${resumed.tag}`);
1655
+ }
1656
+ this._sessions.set(resumed.val.sessionId, sessionEntryFromRoute(resumed.val));
1657
+ });
1658
+ if (response.tag !== "AcpSessionResumedResponse") {
1659
+ throw new Error(`unexpected resume_session response: ${response.tag}`);
3031
1660
  }
3032
- terminal.output += chunk;
3033
- if (Number.isFinite(terminal.outputByteLimit) &&
3034
- terminal.outputByteLimit >= 0 &&
3035
- terminal.output.length > terminal.outputByteLimit) {
3036
- terminal.output = terminal.output.slice(terminal.output.length - terminal.outputByteLimit);
3037
- terminal.truncated = true;
3038
- }
3039
- }
3040
- async _handleAcpReadFile(params) {
3041
- const method = "fs/read";
3042
- const path = this._requireAcpStringParam(params, "path", method);
3043
- const line = this._optionalAcpNumberParam(params, "line", method);
3044
- const limit = this._optionalAcpNumberParam(params, "limit", method);
3045
- const encoding = this._optionalAcpStringParam(params, "encoding", method);
3046
- const bytes = await this.readFile(path);
3047
- if (encoding === "base64") {
3048
- return { content: Buffer.from(bytes).toString("base64") };
3049
- }
3050
- const text = new TextDecoder().decode(bytes);
3051
- if (line === undefined && limit === undefined) {
3052
- return { content: text };
3053
- }
3054
- const startLine = Math.max(1, Math.trunc(line ?? 1));
3055
- const lineLimit = limit === undefined
3056
- ? Number.POSITIVE_INFINITY
3057
- : Math.max(0, Math.trunc(limit));
3058
- return {
3059
- content: text
3060
- .split("\n")
3061
- .slice(startLine - 1, startLine - 1 + lineLimit)
3062
- .join("\n"),
3063
- };
3064
- }
3065
- async _handleAcpWriteFile(params) {
3066
- const method = "fs/write";
3067
- const path = this._requireAcpStringParam(params, "path", method);
3068
- const content = this._requireAcpStringParam(params, "content", method);
3069
- const encoding = this._optionalAcpStringParam(params, "encoding", method);
3070
- await this.writeFile(path, encoding === "base64" ? Buffer.from(content, "base64") : content);
3071
- return null;
1661
+ const { sessionId: liveSessionId, mode } = response.val;
1662
+ return { sessionId: liveSessionId, mode };
3072
1663
  }
3073
- async _handleAcpReadDir(params) {
3074
- const method = "fs/readDir";
3075
- const path = this._requireAcpStringParam(params, "path", method);
3076
- const entries = await this._vfs().readDirWithTypes(path);
3077
- return {
3078
- entries: entries
3079
- .filter((entry) => entry.name !== "." && entry.name !== "..")
3080
- .map((entry) => ({
3081
- name: entry.name,
3082
- path: path === "/" ? `/${entry.name}` : `${path}/${entry.name}`,
3083
- type: entry.isSymbolicLink
3084
- ? "symlink"
3085
- : entry.isDirectory
3086
- ? "directory"
3087
- : "file",
3088
- })),
1664
+ _installSidecarRequestHandler() {
1665
+ const context = {
1666
+ toolMap: buildToolMap(this._toolKits),
3089
1667
  };
1668
+ this._disposeSidecarRequestHandler?.();
1669
+ this._disposeSidecarRequestHandler =
1670
+ this._sidecarClient.registerSidecarRequestHandler({
1671
+ scope: "vm",
1672
+ connection_id: this._sidecarSession.connectionId,
1673
+ session_id: this._sidecarSession.sessionId,
1674
+ vm_id: this._sidecarVm.vmId,
1675
+ }, (request) => {
1676
+ switch (request.payload.type) {
1677
+ case "host_callback":
1678
+ return handleHostCallback(request, context);
1679
+ case "js_bridge_call":
1680
+ return handleJsBridgeCall(request.payload, {
1681
+ resolveTarget: (mountId) => {
1682
+ const hostMountResolver = this.#kernel.hostFilesystemForMount;
1683
+ if (hostMountResolver) {
1684
+ const filesystem = hostMountResolver.call(this.#kernel, mountId);
1685
+ return filesystem
1686
+ ? { filesystem, rootPath: "/" }
1687
+ : undefined;
1688
+ }
1689
+ return { filesystem: this.#kernel.vfs, rootPath: mountId };
1690
+ },
1691
+ });
1692
+ case "ext":
1693
+ return this._handleAcpExtSidecarRequest(request.payload.envelope);
1694
+ }
1695
+ });
3090
1696
  }
3091
- _handleAcpCreateTerminal(params) {
3092
- const method = "terminal/create";
3093
- const command = this._requireAcpStringParam(params, "command", method);
3094
- const args = this._optionalAcpStringArrayParam(params, "args", method);
3095
- const env = this._optionalAcpEnvParam(params, "env", method);
3096
- const cwd = this._optionalAcpStringParam(params, "cwd", method);
3097
- const cols = this._optionalAcpNumberParam(params, "cols", method);
3098
- const rows = this._optionalAcpNumberParam(params, "rows", method);
3099
- const outputByteLimit = Math.max(0, Math.trunc(this._optionalAcpNumberParam(params, "outputByteLimit", method) ??
3100
- 1_048_576));
3101
- const terminalId = `acp-terminal-${++this._acpTerminalCounter}`;
3102
- const terminal = {
3103
- handle: this.#kernel.openShell({
3104
- command,
3105
- ...(args ? { args } : {}),
3106
- ...(env ? { env } : {}),
3107
- ...(cwd ? { cwd } : {}),
3108
- ...(cols !== undefined ? { cols: Math.trunc(cols) } : {}),
3109
- ...(rows !== undefined ? { rows: Math.trunc(rows) } : {}),
3110
- onStderr: (data) => {
3111
- this._appendAcpTerminalOutput(terminal, data);
1697
+ async _handleAcpExtSidecarRequest(envelope) {
1698
+ if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
1699
+ return {
1700
+ type: "ext_result",
1701
+ envelope: {
1702
+ namespace: envelope.namespace,
1703
+ payload: Buffer.from("unknown extension namespace", "utf8"),
3112
1704
  },
3113
- }),
3114
- output: "",
3115
- truncated: false,
3116
- outputByteLimit,
3117
- exitCode: null,
3118
- waitPromise: Promise.resolve(0),
3119
- };
3120
- terminal.handle.onData = (data) => {
3121
- this._appendAcpTerminalOutput(terminal, data);
3122
- };
3123
- terminal.waitPromise = terminal.handle.wait().then((exitCode) => {
3124
- terminal.exitCode = exitCode;
3125
- return exitCode;
3126
- });
3127
- this._acpTerminals.set(terminalId, terminal);
3128
- return { terminalId };
3129
- }
3130
- _handleAcpWriteTerminal(params) {
3131
- const method = "terminal/write";
3132
- const terminal = this._requireAcpTerminal(params, method);
3133
- const data = this._requireAcpStringParam(params, "data", method);
3134
- const encoding = this._optionalAcpStringParam(params, "encoding", method);
3135
- terminal.handle.write(encoding === "base64" ? Buffer.from(data, "base64") : data);
3136
- return null;
3137
- }
3138
- _handleAcpReadTerminal(params) {
3139
- const terminal = this._requireAcpTerminal(params, "terminal/output");
1705
+ };
1706
+ }
1707
+ const callback = decodeAcpCallback(envelope.payload);
1708
+ if (callback.val.cleanupAfterMs > BigInt(Number.MAX_SAFE_INTEGER)) {
1709
+ throw new Error("ACP permission callback cleanup deadline exceeds JS range");
1710
+ }
1711
+ const reply = await this._handleAcpPermissionCallback(callback.val.sessionId, callback.val.permissionId, {
1712
+ ...toRecord(JSON.parse(callback.val.params)),
1713
+ _acpMethod: ACP_PERMISSION_METHOD,
1714
+ }, Number(callback.val.cleanupAfterMs));
3140
1715
  return {
3141
- output: terminal.output,
3142
- truncated: terminal.truncated,
3143
- ...(terminal.exitCode !== null
3144
- ? {
3145
- exitStatus: {
3146
- exitCode: terminal.exitCode,
3147
- signal: null,
1716
+ type: "ext_result",
1717
+ envelope: {
1718
+ namespace: ACP_EXTENSION_NAMESPACE,
1719
+ payload: encodeAcpCallbackResponse({
1720
+ tag: "AcpPermissionCallbackResponse",
1721
+ val: {
1722
+ permissionId: callback.val.permissionId,
1723
+ reply: reply ?? null,
3148
1724
  },
3149
- }
3150
- : {}),
1725
+ }),
1726
+ },
3151
1727
  };
3152
1728
  }
3153
- async _handleAcpWaitForTerminalExit(params) {
3154
- const terminal = this._requireAcpTerminal(params, "terminal/wait_for_exit");
3155
- const exitCode = await terminal.waitPromise;
3156
- return { exitCode, signal: null };
3157
- }
3158
- _handleAcpKillTerminal(params) {
3159
- const method = "terminal/kill";
3160
- const terminal = this._requireAcpTerminal(params, method);
3161
- const signal = this._optionalAcpNumberParam(params, "signal", method) ?? 15;
3162
- terminal.handle.kill(Math.trunc(signal));
3163
- return null;
3164
- }
3165
- _handleAcpReleaseTerminal(params) {
3166
- const method = "terminal/release";
3167
- const terminalId = this._requireAcpStringParam(params, "terminalId", method);
3168
- const terminal = this._acpTerminals.get(terminalId);
3169
- if (!terminal) {
3170
- throw new AcpDispatchError(-32602, `ACP terminal not found: ${terminalId}`);
3171
- }
3172
- if (terminal.exitCode === null) {
3173
- terminal.handle.kill();
3174
- }
3175
- this._acpTerminals.delete(terminalId);
3176
- return null;
3177
- }
3178
- _handleAcpResizeTerminal(params) {
3179
- const method = "terminal/resize";
3180
- const terminal = this._requireAcpTerminal(params, method);
3181
- const cols = this._optionalAcpNumberParam(params, "cols", method);
3182
- const rows = this._optionalAcpNumberParam(params, "rows", method);
3183
- if (cols === undefined || rows === undefined) {
3184
- throw new AcpDispatchError(-32602, `${method} requires numeric cols and rows`);
3185
- }
3186
- terminal.handle.resize(Math.trunc(cols), Math.trunc(rows));
3187
- return null;
3188
- }
3189
- async _handleAcpPermissionCallback(sessionId, permissionId, params) {
1729
+ async _handleAcpPermissionCallback(sessionId, permissionId, params, cleanupAfterMs) {
3190
1730
  const session = this._sessions.get(sessionId);
3191
1731
  if (!session) {
3192
- return "reject";
1732
+ return undefined;
3193
1733
  }
3194
1734
  if (session.permissionHandlers.size === 0) {
3195
- return "reject";
1735
+ // Surface the absent host route, then let the sidecar apply its ACP
1736
+ // permission default. The client does not manufacture a policy answer.
1737
+ this._warnNoPermissionHandlerOnce(session, params);
1738
+ return undefined;
3196
1739
  }
3197
1740
  try {
3198
1741
  return await new Promise((resolve, reject) => {
3199
- const timer = setTimeout(() => {
1742
+ const cleanupTimer = setTimeout(() => {
3200
1743
  session.pendingPermissionReplies.delete(permissionId);
3201
- reject(new Error(`Timed out waiting for permission reply: ${permissionId}`));
3202
- }, 120_000);
1744
+ reject(new Error(`Permission reply route expired after the sidecar deadline: ${permissionId}`));
1745
+ }, cleanupAfterMs);
3203
1746
  session.pendingPermissionReplies.set(permissionId, {
3204
1747
  resolve,
3205
1748
  reject,
3206
- timer,
1749
+ cleanupTimer,
3207
1750
  });
3208
1751
  const permissionRequest = {
3209
1752
  permissionId,
@@ -3217,111 +1760,42 @@ export class AgentOs {
3217
1760
  }
3218
1761
  });
3219
1762
  }
3220
- catch {
3221
- return "reject";
1763
+ catch (error) {
1764
+ console.warn(`ACP permission callback route closed for ${sessionId}/${permissionId}; the sidecar owns the outcome`, error);
1765
+ return undefined;
3222
1766
  }
3223
1767
  }
3224
- /**
3225
- * Resolve an agent config by ID. Package-provided configs take
3226
- * precedence over the hardcoded AGENT_CONFIGS.
3227
- */
3228
- _resolveAgentConfig(agentType) {
3229
- return (this._softwareAgentConfigs.get(agentType) ??
3230
- AGENT_CONFIGS[agentType]);
3231
- }
3232
1768
  /**
3233
1769
  * Gracefully destroy a session: cancel any pending work, close the client,
3234
1770
  * and remove from tracking. Unlike close() which is abrupt, this attempts
3235
1771
  * a graceful shutdown sequence.
3236
1772
  */
3237
1773
  async destroySession(sessionId) {
3238
- this._requireSession(sessionId);
3239
- try {
3240
- await this.cancelSession(sessionId);
3241
- }
3242
- catch {
3243
- // Ignore cancellation failures during teardown.
3244
- }
3245
1774
  await this._closeSessionInternal(sessionId);
3246
1775
  }
3247
1776
  // ── Flat session API (ID-based) ───────────────────────────────
3248
1777
  async prompt(sessionId, text) {
3249
- const session = this._requireSession(sessionId);
3250
- let agentText = "";
3251
- const handler = (event) => {
3252
- const params = toRecord(event.params);
3253
- const update = toRecord(params.update);
3254
- if (update?.sessionUpdate === "agent_message_chunk") {
3255
- const content = toRecord(update.content);
3256
- if (typeof content.text === "string") {
3257
- agentText += content.text;
3258
- }
3259
- }
3260
- };
3261
- const unsubscribe = this._subscribeSessionEvents(session, handler);
3262
- try {
3263
- const response = await this._sendSessionRequest(sessionId, "session/prompt", {
3264
- prompt: [{ type: "text", text }],
3265
- });
3266
- return { response, text: agentText };
3267
- }
3268
- finally {
3269
- unsubscribe();
1778
+ const result = await this._sendSessionRequestWithText(sessionId, "session/prompt", {
1779
+ prompt: [{ type: "text", text }],
1780
+ });
1781
+ if (result.text === null) {
1782
+ throw new Error("sidecar prompt response is missing accumulated text");
3270
1783
  }
1784
+ return { response: result.response, text: result.text };
3271
1785
  }
3272
1786
  /** Cancel ongoing agent work for a session. */
3273
1787
  async cancelSession(sessionId) {
3274
- this._requireSession(sessionId);
3275
- const cancelledPendingPrompt = this._cancelPendingPromptRequests(sessionId);
3276
- if (cancelledPendingPrompt) {
3277
- // Session control requests share the same framed sidecar transport as an
3278
- // in-flight prompt request. If the adapter is blocked in prompt I/O, a
3279
- // synchronous cancel RPC can wedge behind that prompt until the transport
3280
- // timeout fires. Resolve the local prompt immediately, then let the sidecar
3281
- // cancellation continue in the background as best effort.
3282
- void this._sendSessionRequest(sessionId, "session/cancel").catch(() => { });
3283
- return {
3284
- jsonrpc: "2.0",
3285
- id: null,
3286
- result: {
3287
- cancelled: true,
3288
- requested: true,
3289
- via: "prompt-fallback",
3290
- },
3291
- };
3292
- }
3293
- const response = await this._sendSessionRequest(sessionId, "session/cancel");
3294
- if (response.error?.code === -32601) {
3295
- return {
3296
- jsonrpc: "2.0",
3297
- id: null,
3298
- result: {
3299
- cancelled: false,
3300
- requested: true,
3301
- via: "unsupported",
3302
- },
3303
- };
3304
- }
3305
- return response;
1788
+ return this._sendSessionRequest(sessionId, "session/cancel");
3306
1789
  }
3307
- closeSession(sessionId) {
3308
- if (!this._sessions.has(sessionId) &&
3309
- !this._closedSessionIds.has(sessionId) &&
3310
- !this._sessionClosePromises.has(sessionId)) {
3311
- throw new Error(`Session not found: ${sessionId}`);
3312
- }
3313
- const closePromise = this._closeSessionInternal(sessionId);
3314
- // `closeSession()` is intentionally fire-and-forget; suppress unhandled
3315
- // rejections here and let tracked close promises surface errors to any
3316
- // internal/test callers awaiting `_sessionClosePromises`.
3317
- void closePromise.catch(() => { });
1790
+ async closeSession(sessionId) {
1791
+ await this._closeSessionInternal(sessionId);
3318
1792
  }
3319
1793
  async respondPermission(sessionId, permissionId, reply) {
3320
- const session = this._requireSession(sessionId);
3321
- const pendingReply = session.pendingPermissionReplies.get(permissionId);
1794
+ const session = this._sessions.get(sessionId);
1795
+ const pendingReply = session?.pendingPermissionReplies.get(permissionId);
3322
1796
  if (pendingReply) {
3323
- session.pendingPermissionReplies.delete(permissionId);
3324
- clearTimeout(pendingReply.timer);
1797
+ session?.pendingPermissionReplies.delete(permissionId);
1798
+ clearTimeout(pendingReply.cleanupTimer);
3325
1799
  pendingReply.resolve(reply);
3326
1800
  return {
3327
1801
  jsonrpc: "2.0",
@@ -3333,18 +1807,15 @@ export class AgentOs {
3333
1807
  },
3334
1808
  };
3335
1809
  }
3336
- return this._sendSessionRequest(sessionId, LEGACY_PERMISSION_METHOD, {
3337
- permissionId,
3338
- reply,
3339
- });
1810
+ throw new Error(`Permission request is not pending: ${permissionId}`);
3340
1811
  }
3341
1812
  async setSessionMode(sessionId, modeId) {
3342
1813
  return this._sendSessionRequest(sessionId, "session/set_mode", {
3343
1814
  modeId,
3344
1815
  });
3345
1816
  }
3346
- getSessionModes(sessionId) {
3347
- return this._requireSession(sessionId).modes;
1817
+ async getSessionModes(sessionId) {
1818
+ return toSessionModes((await this._getSessionState(sessionId)).modes);
3348
1819
  }
3349
1820
  async setSessionModel(sessionId, model) {
3350
1821
  return this._setSessionConfigByCategory(sessionId, "model", model);
@@ -3352,15 +1823,15 @@ export class AgentOs {
3352
1823
  async setSessionThoughtLevel(sessionId, level) {
3353
1824
  return this._setSessionConfigByCategory(sessionId, "thought_level", level);
3354
1825
  }
3355
- getSessionConfigOptions(sessionId) {
3356
- return [...this._requireSession(sessionId).configOptions];
1826
+ async getSessionConfigOptions(sessionId) {
1827
+ return toSessionConfigOptions((await this._getSessionState(sessionId)).configOptions);
3357
1828
  }
3358
- getSessionCapabilities(sessionId) {
3359
- const caps = this._requireSession(sessionId).capabilities;
1829
+ async getSessionCapabilities(sessionId) {
1830
+ const caps = toAgentCapabilities((await this._getSessionState(sessionId)).agentCapabilities);
3360
1831
  return Object.keys(caps).length > 0 ? caps : null;
3361
1832
  }
3362
- getSessionAgentInfo(sessionId) {
3363
- return this._requireSession(sessionId).agentInfo;
1833
+ async getSessionAgentInfo(sessionId) {
1834
+ return toAgentInfo((await this._getSessionState(sessionId)).agentInfo);
3364
1835
  }
3365
1836
  async rawSessionSend(sessionId, method, params) {
3366
1837
  return this._sendSessionRequest(sessionId, method, params);
@@ -3381,45 +1852,79 @@ export class AgentOs {
3381
1852
  }
3382
1853
  // ── Cron ────────────────────────────────────────────────────
3383
1854
  /** Schedule a cron job. Returns a handle with the job ID and a cancel method. */
3384
- scheduleCron(options) {
1855
+ async scheduleCron(options) {
3385
1856
  return this._cronManager.schedule(options);
3386
1857
  }
3387
1858
  /** List all registered cron jobs. */
3388
- listCronJobs() {
1859
+ async listCronJobs() {
3389
1860
  return this._cronManager.list();
3390
1861
  }
3391
1862
  /** Cancel a cron job by ID. */
3392
- cancelCronJob(id) {
3393
- this._cronManager.cancel(id);
1863
+ async cancelCronJob(id) {
1864
+ await this._cronManager.cancel(id);
3394
1865
  }
3395
1866
  /** Subscribe to cron lifecycle events (fire, complete, error). */
3396
1867
  onCronEvent(handler) {
3397
1868
  this._cronManager.onEvent(handler);
3398
1869
  }
3399
1870
  async dispose() {
3400
- this._cronManager.dispose();
3401
- for (const sessionId of [...this._sessions.keys()]) {
3402
- await this._closeSessionInternal(sessionId).catch(() => { });
1871
+ if (this._disposed) {
1872
+ return;
3403
1873
  }
3404
- for (const [id, entry] of this._shells) {
3405
- entry.handle.kill();
1874
+ if (this._disposePromise) {
1875
+ return this._disposePromise;
3406
1876
  }
3407
- const shellExitPromises = [...this._pendingShellExitPromises];
3408
- this._shells.clear();
3409
- const terminalExitPromises = [];
3410
- for (const terminal of this._acpTerminals.values()) {
3411
- terminal.handle.kill();
3412
- terminalExitPromises.push(terminal.waitPromise.then(() => undefined, () => undefined));
1877
+ const attempt = this._disposeOnce();
1878
+ this._disposePromise = attempt;
1879
+ try {
1880
+ await attempt;
3413
1881
  }
3414
- this._acpTerminals.clear();
3415
- await waitForTrackedExitPromises([...shellExitPromises, ...terminalExitPromises], SHELL_DISPOSE_TIMEOUT_MS);
3416
- this._disposeSidecarEventListener();
1882
+ finally {
1883
+ if (this._disposePromise === attempt) {
1884
+ this._disposePromise = null;
1885
+ }
1886
+ }
1887
+ }
1888
+ async _disposeOnce() {
1889
+ for (const sessionId of [...this._sessions.keys()]) {
1890
+ try {
1891
+ await this._closeSessionInternal(sessionId);
1892
+ }
1893
+ catch (error) {
1894
+ console.warn(`failed to close ACP session ${sessionId} during dispose`, error);
1895
+ }
1896
+ }
1897
+ const shellKillResults = await Promise.allSettled([...this._shells.values()].map((entry) => entry.handle.kill()));
1898
+ for (const result of shellKillResults) {
1899
+ if (result.status === "rejected") {
1900
+ console.warn("failed to close shell during dispose", result.reason);
1901
+ }
1902
+ }
1903
+ const shellExitPromises = [...this._pendingShellExitPromises.values()];
1904
+ await waitForTrackedExitPromises(shellExitPromises, SHELL_DISPOSE_TIMEOUT_MS);
3417
1905
  const sidecarLease = this._sidecarLease;
3418
- this._sidecarLease = null;
3419
1906
  if (sidecarLease) {
3420
- return sidecarLease.dispose();
1907
+ await sidecarLease.dispose();
3421
1908
  }
3422
- return this.#kernel.dispose();
1909
+ else {
1910
+ await this.#kernel.dispose();
1911
+ }
1912
+ // Only release local correlation after authoritative remote teardown. A
1913
+ // failed close leaves these routes and the lease intact for a safe retry.
1914
+ for (const session of this._sessions.values()) {
1915
+ this._rejectPendingPermissionRepliesFromSession(session);
1916
+ }
1917
+ this._sessions.clear();
1918
+ this._cronManager.dispose();
1919
+ this._shells.clear();
1920
+ this._processes.clear();
1921
+ this._disposeSidecarEventListener();
1922
+ this._disposeSidecarRequestHandler?.();
1923
+ this._disposeSidecarRequestHandler = null;
1924
+ if (this._sidecarLease === sidecarLease) {
1925
+ this._sidecarLease = null;
1926
+ }
1927
+ this._disposed = true;
3423
1928
  }
3424
1929
  }
3425
1930
  const agentOsRuntimeAdmins = new WeakMap();
@@ -3441,6 +1946,162 @@ function resolveAgentOsSidecar(config) {
3441
1946
  }
3442
1947
  const sidecarStates = new WeakMap();
3443
1948
  const sharedSidecars = new Map();
1949
+ let sidecarProcessExitHookInstalled = false;
1950
+ /**
1951
+ * Install a one-time, synchronous `process.on("exit")` hook that SIGKILLs any
1952
+ * pooled shared sidecar child. Once a one-shot host process is allowed to exit
1953
+ * (its sidecar handles are unref'd at 0 leases), this reaps the sidecar
1954
+ * immediately instead of waiting for its stdin-EOF grace window — no orphan, no
1955
+ * delay. We deliberately do NOT install SIGINT/SIGTERM handlers: a library
1956
+ * should not hijack the host's signal handling. SIGINT still reaches the sidecar
1957
+ * via the process group, and SIGTERM-driven exit still closes its stdin.
1958
+ */
1959
+ function ensureSidecarProcessExitCleanup() {
1960
+ if (sidecarProcessExitHookInstalled)
1961
+ return;
1962
+ sidecarProcessExitHookInstalled = true;
1963
+ process.on("exit", () => {
1964
+ for (const sidecar of sharedSidecars.values()) {
1965
+ try {
1966
+ sidecarStates.get(sidecar)?.sharedChild?.kill?.("SIGKILL");
1967
+ }
1968
+ catch {
1969
+ // best-effort reap; the process is exiting regardless
1970
+ }
1971
+ }
1972
+ });
1973
+ }
1974
+ function sidecarChildHandle(client) {
1975
+ // SidecarProcess -> StdioSidecarProtocolClient.child (the spawned ChildProcess).
1976
+ const protocolClient = client?.protocolClient;
1977
+ return protocolClient?.child ?? undefined;
1978
+ }
1979
+ /**
1980
+ * Apply the current hold state to the shared sidecar's child + stdio: ref them
1981
+ * while ≥1 hold is live so in-flight VM work keeps the host process alive; unref
1982
+ * them at 0 so a one-shot script exits on its own after `dispose()`. The sidecar
1983
+ * process itself stays running (reusable) and self-exits on stdin EOF when the
1984
+ * host finally goes away. Best-effort: never let ref/unref break VM lifecycle.
1985
+ */
1986
+ function applySharedSidecarHold(state) {
1987
+ const child = state.sharedChild;
1988
+ if (!child)
1989
+ return;
1990
+ const hold = (state.eventLoopHolds ?? 0) > 0;
1991
+ for (const handle of [child, child.stdin, child.stdout, child.stderr]) {
1992
+ if (!handle)
1993
+ continue;
1994
+ try {
1995
+ if (hold)
1996
+ handle.ref?.();
1997
+ else
1998
+ handle.unref?.();
1999
+ }
2000
+ catch {
2001
+ // ref/unref is an optimization, not correctness-critical
2002
+ }
2003
+ }
2004
+ }
2005
+ /**
2006
+ * Take a hold for the entire create→use→dispose lifetime of one VM lease. Taken
2007
+ * BEFORE VM creation starts (not when the lease lands in `activeLeases`) so a VM
2008
+ * that is still mid-creation keeps the sidecar ref'd and a concurrent dispose
2009
+ * cannot unref it out from under the in-flight create.
2010
+ */
2011
+ function acquireSharedSidecarHold(state) {
2012
+ state.eventLoopHolds = (state.eventLoopHolds ?? 0) + 1;
2013
+ if (state.eventLoopHolds === 1)
2014
+ applySharedSidecarHold(state);
2015
+ }
2016
+ /** Release a hold taken by {@link acquireSharedSidecarHold}; unref at 0. */
2017
+ function releaseSharedSidecarHold(state) {
2018
+ const current = state.eventLoopHolds ?? 0;
2019
+ if (current <= 0) {
2020
+ // The `holdReleased` guard makes each lease release exactly once, so this
2021
+ // should be unreachable. Warn rather than silently floor, per the repo's
2022
+ // no-silent-masking rule, so an accounting bug surfaces instead of hiding.
2023
+ state.eventLoopHolds = 0;
2024
+ console.warn("[agentos] shared sidecar event-loop hold released more than acquired");
2025
+ return;
2026
+ }
2027
+ state.eventLoopHolds = current - 1;
2028
+ if (state.eventLoopHolds === 0)
2029
+ applySharedSidecarHold(state);
2030
+ }
2031
+ /**
2032
+ * Spawn-once accessor for a sidecar handle's shared native process. Concurrent
2033
+ * callers await the same promise, so one `AgentOsSidecar` maps to exactly one
2034
+ * `agent-os-sidecar` OS process for its whole lifetime.
2035
+ */
2036
+ function ensureSharedSidecarNativeProcess(sidecar) {
2037
+ const state = getSidecarState(sidecar);
2038
+ if (!state.nativeProcess) {
2039
+ ensureSidecarProcessExitCleanup();
2040
+ state.nativeProcess = (async () => {
2041
+ const client = SidecarProcess.spawn({
2042
+ command: resolvePublishedSidecarBinary(),
2043
+ args: [],
2044
+ });
2045
+ // Track the child immediately — BEFORE the handshake await — so a
2046
+ // failed `authenticateAndOpenSession()` can still reap it (otherwise
2047
+ // the spawned child is untracked, unreapable, and pins the loop).
2048
+ state.sharedChild = sidecarChildHandle(client);
2049
+ if (!state.sharedChild) {
2050
+ // We reached into @rivet-dev/agentos-runtime-core internals to get the child for
2051
+ // idle-unref. If that shape ever changes this returns undefined and
2052
+ // the optimization silently stops working (one-shot scripts would
2053
+ // hang again). Make it loud rather than a silent regression.
2054
+ console.warn("[agentos] could not resolve the shared sidecar child handle; " +
2055
+ "standalone scripts may not exit cleanly after dispose(). " +
2056
+ "This usually means @rivet-dev/agentos-runtime-core internals changed.");
2057
+ }
2058
+ // Apply the current hold state to the just-spawned child.
2059
+ applySharedSidecarHold(state);
2060
+ try {
2061
+ const session = await client.authenticateAndOpenSession();
2062
+ return { client, session };
2063
+ }
2064
+ catch (error) {
2065
+ // Spawn/handshake failed: reap the child, drop the cached handle,
2066
+ // and CLEAR the rejected promise so the next create() retries
2067
+ // instead of permanently wedging on a rejected `nativeProcess`.
2068
+ try {
2069
+ state.sharedChild?.kill?.("SIGKILL");
2070
+ }
2071
+ catch {
2072
+ // already gone
2073
+ }
2074
+ state.sharedChild = undefined;
2075
+ state.nativeProcess = undefined;
2076
+ throw error;
2077
+ }
2078
+ })();
2079
+ }
2080
+ return state.nativeProcess;
2081
+ }
2082
+ /** Dispose a sidecar handle's shared native process, if one was spawned. */
2083
+ async function disposeSharedSidecarNativeProcess(state) {
2084
+ const pending = state.nativeProcess;
2085
+ if (!pending) {
2086
+ return;
2087
+ }
2088
+ const { client, session } = await pending;
2089
+ // The TS handle shares one real wire session across all leased VMs. Close it
2090
+ // only after every VM lease has confirmed disposal, then terminate transport.
2091
+ // A rejected close retains the live process/session handle so the caller can
2092
+ // retry the idempotent sidecar operation.
2093
+ await client.closeSession(session);
2094
+ await client.dispose();
2095
+ state.nativeProcess = undefined;
2096
+ // The cached child is now dead; drop it (symmetric with the assignment in
2097
+ // ensureSharedSidecarNativeProcess). We deliberately do NOT zero
2098
+ // `eventLoopHolds` here: this runs only from `AgentOsSidecar.dispose()`, which
2099
+ // has already set the handle to `disposing` (so no new lease can acquire) and
2100
+ // drained `activeLeases`; the disposed handle's state is then abandoned. Force-
2101
+ // zeroing a shared counter could clobber a hold on a freshly re-acquired
2102
+ // process generation, so it is left to the balanced acquire/release pairs.
2103
+ state.sharedChild = undefined;
2104
+ }
3444
2105
  export class AgentOsSidecar {
3445
2106
  constructor(sidecarId, placement, sharedPool) {
3446
2107
  sidecarStates.set(this, {
@@ -3463,6 +2124,21 @@ export class AgentOsSidecar {
3463
2124
  if (state.description.state === "disposed") {
3464
2125
  return;
3465
2126
  }
2127
+ if (state.disposePromise) {
2128
+ return state.disposePromise;
2129
+ }
2130
+ const attempt = this.disposeOnce(state);
2131
+ state.disposePromise = attempt;
2132
+ try {
2133
+ await attempt;
2134
+ }
2135
+ finally {
2136
+ if (state.disposePromise === attempt) {
2137
+ state.disposePromise = undefined;
2138
+ }
2139
+ }
2140
+ }
2141
+ async disposeOnce(state) {
3466
2142
  state.description.state = "disposing";
3467
2143
  const errors = [];
3468
2144
  for (const lease of [...state.activeLeases]) {
@@ -3473,15 +2149,15 @@ export class AgentOsSidecar {
3473
2149
  errors.push(error instanceof Error ? error : new Error(String(error)));
3474
2150
  }
3475
2151
  }
3476
- state.activeLeases.clear();
3477
- state.description.activeVmCount = 0;
2152
+ if (errors.length > 0) {
2153
+ throw new AggregateError(errors, `failed to dispose sidecar ${state.description.sidecarId}`);
2154
+ }
2155
+ // Tear down the shared native process after all leased VMs are gone.
2156
+ await disposeSharedSidecarNativeProcess(state);
3478
2157
  state.description.state = "disposed";
3479
2158
  if (state.sharedPool && sharedSidecars.get(state.sharedPool) === this) {
3480
2159
  sharedSidecars.delete(state.sharedPool);
3481
2160
  }
3482
- if (errors.length > 0) {
3483
- throw new Error(errors.map((error) => error.message).join("; "));
3484
- }
3485
2161
  }
3486
2162
  }
3487
2163
  function createAgentOsSidecarInternal(options = {}) {
@@ -3536,7 +2212,20 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3536
2212
  return transport;
3537
2213
  },
3538
2214
  });
2215
+ // Hold the shared sidecar's event-loop ref for this lease's WHOLE lifetime —
2216
+ // taken now, before VM creation, so a concurrent dispose cannot unref the
2217
+ // sidecar while this create is still in flight. Released exactly once on
2218
+ // dispose or on a failed create.
2219
+ acquireSharedSidecarHold(state);
2220
+ let holdReleased = false;
2221
+ const releaseHold = () => {
2222
+ if (holdReleased)
2223
+ return;
2224
+ holdReleased = true;
2225
+ releaseSharedSidecarHold(state);
2226
+ };
3539
2227
  let disposed = false;
2228
+ let disposePromise = null;
3540
2229
  let leaseRecord;
3541
2230
  try {
3542
2231
  const session = await client.createSession({
@@ -3556,10 +2245,27 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3556
2245
  if (disposed) {
3557
2246
  return;
3558
2247
  }
3559
- disposed = true;
3560
- state.activeLeases.delete(leaseRecord);
3561
- state.description.activeVmCount = state.activeLeases.size;
3562
- await client.dispose();
2248
+ if (!disposePromise) {
2249
+ disposePromise = (async () => {
2250
+ await client.dispose();
2251
+ disposed = true;
2252
+ state.activeLeases.delete(leaseRecord);
2253
+ state.description.activeVmCount = state.activeLeases.size;
2254
+ // Release this lease's hold; the shared sidecar is unref'd only
2255
+ // once the last hold (across all in-flight + active leases) drops,
2256
+ // so a one-shot host process can then exit on its own.
2257
+ releaseHold();
2258
+ })();
2259
+ }
2260
+ const attempt = disposePromise;
2261
+ try {
2262
+ await attempt;
2263
+ }
2264
+ finally {
2265
+ if (disposePromise === attempt) {
2266
+ disposePromise = null;
2267
+ }
2268
+ }
3563
2269
  },
3564
2270
  };
3565
2271
  leaseRecord = {
@@ -3570,20 +2276,39 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3570
2276
  return lease;
3571
2277
  }
3572
2278
  catch (error) {
3573
- await client.dispose().catch(() => { });
2279
+ await client.dispose().catch((cleanupError) => {
2280
+ console.warn("failed to dispose sidecar client after lease creation failure", cleanupError);
2281
+ });
2282
+ releaseHold();
3574
2283
  throw error;
3575
2284
  }
3576
2285
  }
3577
2286
  async function createInProcessSidecarTransport(sessionBootstrap, options) {
3578
2287
  const vmAdmins = new Map();
3579
2288
  let disposed = false;
2289
+ let disposePromise = null;
3580
2290
  async function disposeVmAdmin(vmId) {
3581
2291
  const admin = vmAdmins.get(vmId);
3582
2292
  if (!admin) {
3583
2293
  return;
3584
2294
  }
3585
- vmAdmins.delete(vmId);
3586
2295
  await admin.dispose();
2296
+ vmAdmins.delete(vmId);
2297
+ }
2298
+ async function disposeTransport() {
2299
+ const errors = [];
2300
+ for (const vmId of [...vmAdmins.keys()]) {
2301
+ try {
2302
+ await disposeVmAdmin(vmId);
2303
+ }
2304
+ catch (error) {
2305
+ errors.push(error instanceof Error ? error : new Error(String(error)));
2306
+ }
2307
+ }
2308
+ if (errors.length > 0) {
2309
+ throw new AggregateError(errors, "failed to dispose sidecar session");
2310
+ }
2311
+ disposed = true;
3587
2312
  }
3588
2313
  return {
3589
2314
  async createVm(vmBootstrap) {
@@ -3600,18 +2325,17 @@ async function createInProcessSidecarTransport(sessionBootstrap, options) {
3600
2325
  if (disposed) {
3601
2326
  return;
3602
2327
  }
3603
- disposed = true;
3604
- const errors = [];
3605
- for (const vmId of [...vmAdmins.keys()]) {
3606
- try {
3607
- await disposeVmAdmin(vmId);
3608
- }
3609
- catch (error) {
3610
- errors.push(error instanceof Error ? error : new Error(String(error)));
3611
- }
2328
+ if (!disposePromise) {
2329
+ disposePromise = disposeTransport();
2330
+ }
2331
+ const attempt = disposePromise;
2332
+ try {
2333
+ await attempt;
3612
2334
  }
3613
- if (errors.length > 0) {
3614
- throw new Error(errors.map((error) => error.message).join("; "));
2335
+ finally {
2336
+ if (disposePromise === attempt) {
2337
+ disposePromise = null;
2338
+ }
3615
2339
  }
3616
2340
  },
3617
2341
  getVmAdmin(vmId) {