@rivet-dev/agentos-core 0.0.0-integrate-dylib-into-main.815fcda → 0.0.0-main.29b0797

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 (125) hide show
  1. package/README.md +43 -43
  2. package/dist/agent-os.d.ts +313 -226
  3. package/dist/agent-os.js +1418 -1545
  4. package/dist/agentos-package.d.ts +31 -0
  5. package/dist/agentos-package.js +91 -0
  6. package/dist/base-filesystem.d.ts +2 -15
  7. package/dist/base-filesystem.js +23 -171
  8. package/dist/{host-tools-zod.d.ts → bindings-zod.d.ts} +1 -1
  9. package/dist/{host-tools-zod.js → bindings-zod.js} +52 -25
  10. package/dist/bindings.d.ts +43 -0
  11. package/dist/bindings.js +34 -0
  12. package/dist/cron/cron-manager.js +43 -10
  13. package/dist/cron/index.d.ts +1 -1
  14. package/dist/cron/types.d.ts +26 -10
  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 -2
  18. package/dist/filesystem-snapshot.js +0 -43
  19. package/dist/generated/AcpLimitsConfig.d.ts +20 -0
  20. package/dist/generated/AcpLimitsConfig.js +2 -0
  21. package/dist/generated/BindingLimitsConfig.d.ts +10 -0
  22. package/dist/generated/BindingLimitsConfig.js +2 -0
  23. package/dist/generated/CreateVmConfig.d.ts +28 -0
  24. package/dist/generated/FsPermissionRule.d.ts +6 -0
  25. package/dist/generated/FsPermissionRule.js +1 -0
  26. package/dist/generated/FsPermissionRuleSet.d.ts +6 -0
  27. package/dist/generated/FsPermissionRuleSet.js +1 -0
  28. package/dist/generated/FsPermissionScope.d.ts +3 -0
  29. package/dist/generated/FsPermissionScope.js +1 -0
  30. package/dist/generated/HttpLimitsConfig.d.ts +3 -0
  31. package/dist/generated/HttpLimitsConfig.js +2 -0
  32. package/dist/generated/JsModuleResolution.d.ts +1 -0
  33. package/dist/generated/JsModuleResolution.js +2 -0
  34. package/dist/generated/JsRuntimeConfig.d.ts +31 -0
  35. package/dist/generated/JsRuntimeConfig.js +1 -0
  36. package/dist/generated/JsRuntimeLimitsConfig.d.ts +11 -0
  37. package/dist/generated/JsRuntimeLimitsConfig.js +2 -0
  38. package/dist/generated/JsRuntimePlatform.d.ts +1 -0
  39. package/dist/generated/JsRuntimePlatform.js +2 -0
  40. package/dist/generated/MountPluginDescriptor.d.ts +4 -0
  41. package/dist/generated/MountPluginDescriptor.js +2 -0
  42. package/dist/generated/NativeRootFilesystemConfig.d.ts +5 -0
  43. package/dist/generated/NativeRootFilesystemConfig.js +1 -0
  44. package/dist/generated/PatternPermissionRule.d.ts +6 -0
  45. package/dist/generated/PatternPermissionRule.js +1 -0
  46. package/dist/generated/PatternPermissionRuleSet.d.ts +6 -0
  47. package/dist/generated/PatternPermissionRuleSet.js +1 -0
  48. package/dist/generated/PatternPermissionScope.d.ts +3 -0
  49. package/dist/generated/PatternPermissionScope.js +1 -0
  50. package/dist/generated/PermissionMode.d.ts +1 -0
  51. package/dist/generated/PermissionMode.js +2 -0
  52. package/dist/generated/PermissionsPolicy.d.ts +10 -0
  53. package/dist/generated/PermissionsPolicy.js +1 -0
  54. package/dist/generated/PluginLimitsConfig.d.ts +4 -0
  55. package/dist/generated/PluginLimitsConfig.js +2 -0
  56. package/dist/generated/ProcessLimitsConfig.d.ts +7 -0
  57. package/dist/generated/ProcessLimitsConfig.js +2 -0
  58. package/dist/generated/PythonLimitsConfig.d.ts +6 -0
  59. package/dist/generated/PythonLimitsConfig.js +2 -0
  60. package/dist/generated/ResourceLimitsConfig.d.ts +24 -0
  61. package/dist/generated/ResourceLimitsConfig.js +2 -0
  62. package/dist/generated/RootFilesystemConfig.d.ts +9 -0
  63. package/dist/generated/RootFilesystemConfig.js +1 -0
  64. package/dist/generated/RootFilesystemEntry.d.ts +13 -0
  65. package/dist/generated/RootFilesystemEntry.js +1 -0
  66. package/dist/generated/RootFilesystemEntryEncoding.d.ts +1 -0
  67. package/dist/generated/RootFilesystemEntryEncoding.js +2 -0
  68. package/dist/generated/RootFilesystemEntryKind.d.ts +1 -0
  69. package/dist/generated/RootFilesystemEntryKind.js +2 -0
  70. package/dist/generated/RootFilesystemLowerDescriptor.d.ts +7 -0
  71. package/dist/generated/RootFilesystemLowerDescriptor.js +1 -0
  72. package/dist/generated/RootFilesystemMode.d.ts +1 -0
  73. package/dist/generated/RootFilesystemMode.js +2 -0
  74. package/dist/generated/VmDnsConfig.d.ts +6 -0
  75. package/dist/generated/VmDnsConfig.js +2 -0
  76. package/dist/generated/VmGroupConfig.d.ts +5 -0
  77. package/dist/generated/VmGroupConfig.js +2 -0
  78. package/dist/generated/VmLimitsConfig.d.ts +20 -0
  79. package/dist/generated/VmLimitsConfig.js +1 -0
  80. package/dist/generated/VmListenPolicyConfig.d.ts +5 -0
  81. package/dist/generated/VmListenPolicyConfig.js +2 -0
  82. package/dist/generated/VmUserAccountConfig.d.ts +9 -0
  83. package/dist/generated/VmUserAccountConfig.js +2 -0
  84. package/dist/generated/VmUserConfig.d.ts +19 -0
  85. package/dist/generated/VmUserConfig.js +1 -0
  86. package/dist/generated/WasmLimitsConfig.d.ts +8 -0
  87. package/dist/generated/WasmLimitsConfig.js +2 -0
  88. package/dist/host-dir-mount.d.ts +1 -1
  89. package/dist/index.d.ts +6 -5
  90. package/dist/index.js +5 -5
  91. package/dist/layers.d.ts +7 -4
  92. package/dist/layers.js +0 -176
  93. package/dist/options-schema.d.ts +556 -0
  94. package/dist/options-schema.js +386 -0
  95. package/dist/packages.d.ts +17 -116
  96. package/dist/packages.js +35 -322
  97. package/dist/runtime-compat.d.ts +33 -46
  98. package/dist/runtime-compat.js +197 -511
  99. package/dist/runtime.d.ts +42 -8
  100. package/dist/sandbox.d.ts +112 -0
  101. package/dist/sandbox.js +264 -0
  102. package/dist/session-api.d.ts +180 -0
  103. package/dist/session-api.js +1 -0
  104. package/dist/sidecar/agentos-protocol.d.ts +509 -20
  105. package/dist/sidecar/agentos-protocol.js +808 -81
  106. package/dist/sidecar/native-process-client.d.ts +6 -2
  107. package/dist/sidecar/native-process-client.js +8 -1
  108. package/dist/sidecar/permissions.d.ts +1 -1
  109. package/dist/sidecar/permissions.js +4 -4
  110. package/dist/sidecar/rpc-client.d.ts +100 -9
  111. package/dist/sidecar/rpc-client.js +445 -110
  112. package/dist/test/runtime.d.ts +2 -1
  113. package/dist/test/runtime.js +2 -1
  114. package/dist/test/terminal-harness.js +0 -3
  115. package/dist/types.d.ts +13 -7
  116. package/package.json +46 -35
  117. package/dist/agent-session-types.d.ts +0 -67
  118. package/dist/agents.d.ts +0 -63
  119. package/dist/agents.js +0 -42
  120. package/dist/host-tools.d.ts +0 -45
  121. package/dist/host-tools.js +0 -26
  122. package/dist/overlay-filesystem.d.ts +0 -19
  123. package/dist/overlay-filesystem.js +0 -645
  124. package/fixtures/AGENTOS_SYSTEM_PROMPT.md +0 -21
  125. /package/dist/{agent-session-types.js → generated/CreateVmConfig.js} +0 -0
package/dist/agent-os.js CHANGED
@@ -1,32 +1,141 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  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";
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
4
+ import { join, posix as posixPath, resolve as resolveHostPath, } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
- import { validateToolkits } from "./host-tools.js";
8
- import { zodToJsonSchema } from "./host-tools-zod.js";
6
+ import { validateBindings } from "./bindings.js";
7
+ import { zodToJsonSchema } from "./bindings-zod.js";
8
+ import { parseAgentOsOptions } from "./options-schema.js";
9
9
  import { resolvePublishedSidecarBinary } from "./sidecar/binary.js";
10
10
  import { findCargoBinary, resolveCargoBinary } from "./sidecar/cargo.js";
11
- export { isAcpTimeoutErrorData } from "./json-rpc.js";
12
11
  const ACP_PROTOCOL_VERSION = 1;
13
12
  const ACP_EXTENSION_NAMESPACE = "dev.rivet.agent-os.acp";
14
13
  const SHELL_DISPOSE_TIMEOUT_MS = 5_000;
15
- /**
16
- * Reserved `env` key on `AcpResumeSessionRequest` carrying the resolved adapter
17
- * bin entrypoint. The resume wire request omits a dedicated `adapterEntrypoint`
18
- * field; the sidecar reads the entrypoint from this key and strips it before
19
- * launching the adapter. Must stay in sync with the sidecar constant of the same
20
- * name in `crates/agentos-sidecar/src/acp_extension.rs`.
21
- */
22
- const RESUME_ADAPTER_ENTRYPOINT_ENV = "AGENT_OS_RESUME_ADAPTER_ENTRYPOINT";
23
- function defaultAcpClientCapabilities() {
14
+ function safeWireU64(value) {
15
+ const result = Number(value);
16
+ if (!Number.isSafeInteger(result) || result < 0) {
17
+ throw new RangeError(`wire integer ${value} exceeds JavaScript's safe range`);
18
+ }
19
+ return result;
20
+ }
21
+ function decodeDurableSessionInfo(value) {
24
22
  return {
25
- fs: {
26
- readTextFile: true,
27
- writeTextFile: true,
28
- },
29
- terminal: true,
23
+ sessionId: value.sessionId,
24
+ agent: value.agent,
25
+ cwd: value.cwd,
26
+ additionalDirectories: JSON.parse(value.additionalDirectories),
27
+ state: JSON.parse(value.state),
28
+ latestSequence: safeWireU64(value.latestSequence),
29
+ title: value.title ?? undefined,
30
+ metadata: value.metadata === null ? undefined : JSON.parse(value.metadata),
31
+ createdAt: value.createdAt,
32
+ updatedAt: value.updatedAt,
33
+ };
34
+ }
35
+ const PERMISSION_TERMINAL_REASONS = new Set([
36
+ "already_resolved",
37
+ "prompt_cancelled",
38
+ "adapter_exited",
39
+ "session_deleted",
40
+ "vm_shutdown",
41
+ "request_not_found",
42
+ ]);
43
+ function permissionTerminalReason(value) {
44
+ if (value !== null &&
45
+ PERMISSION_TERMINAL_REASONS.has(value)) {
46
+ return value;
47
+ }
48
+ throw new Error(`invalid permission terminal reason: ${value ?? "missing"}`);
49
+ }
50
+ function decodeDurableSessionEvent(value) {
51
+ const envelope = {
52
+ durability: "durable",
53
+ sessionId: value.sessionId,
54
+ sequence: safeWireU64(value.sequence),
55
+ timestamp: value.timestamp,
56
+ };
57
+ switch (value.event.tag) {
58
+ case "AcpDurableSessionUpdate": {
59
+ const update = JSON.parse(value.event.val.update);
60
+ const { sessionUpdate: type, ...payload } = update;
61
+ return {
62
+ ...envelope,
63
+ type,
64
+ ...payload,
65
+ };
66
+ }
67
+ case "AcpDurablePermissionRequest": {
68
+ const request = JSON.parse(value.event.val.request);
69
+ const { sessionId: _sessionId, ...payload } = request;
70
+ return {
71
+ ...envelope,
72
+ type: "permission_request",
73
+ requestId: value.event.val.requestId,
74
+ ...payload,
75
+ };
76
+ }
77
+ case "AcpDurablePermissionResponse": {
78
+ const status = value.event.val.status;
79
+ if (status !== "accepted" && status !== "not_pending") {
80
+ throw new Error(`invalid permission response event status: ${status}`);
81
+ }
82
+ const response = JSON.parse(value.event.val.response);
83
+ return {
84
+ ...envelope,
85
+ type: "permission_response",
86
+ requestId: value.event.val.requestId,
87
+ ...response,
88
+ status,
89
+ ...(value.event.val.reason === null
90
+ ? {}
91
+ : { reason: permissionTerminalReason(value.event.val.reason) }),
92
+ };
93
+ }
94
+ }
95
+ }
96
+ function normalizeSessionCapabilities(value) {
97
+ const capabilities = toRecord(value);
98
+ const prompt = toRecord(capabilities.promptCapabilities);
99
+ const mcp = toRecord(capabilities.mcpCapabilities);
100
+ const session = toRecord(capabilities.sessionCapabilities);
101
+ const extensions = Object.fromEntries(Object.entries(capabilities).filter(([key]) => ![
102
+ "loadSession",
103
+ "promptCapabilities",
104
+ "mcpCapabilities",
105
+ "sessionCapabilities",
106
+ ].includes(key)));
107
+ return {
108
+ protocolVersion: ACP_PROTOCOL_VERSION,
109
+ loadSession: capabilities.loadSession === true,
110
+ ...(Object.keys(prompt).length > 0
111
+ ? {
112
+ prompt: {
113
+ audio: prompt.audio === true || undefined,
114
+ embeddedContext: prompt.embeddedContext === true || undefined,
115
+ image: prompt.image === true || undefined,
116
+ },
117
+ }
118
+ : {}),
119
+ ...(Object.keys(mcp).length > 0
120
+ ? {
121
+ mcp: {
122
+ http: mcp.http === true || undefined,
123
+ sse: mcp.sse === true || undefined,
124
+ },
125
+ }
126
+ : {}),
127
+ ...(Object.keys(session).length > 0
128
+ ? {
129
+ session: {
130
+ list: typeof session.list === "object" || undefined,
131
+ resume: typeof session.resume === "object" || undefined,
132
+ close: typeof session.close === "object" || undefined,
133
+ delete: typeof session.delete === "object" || undefined,
134
+ additionalDirectories: typeof session.additionalDirectories === "object" || undefined,
135
+ },
136
+ }
137
+ : {}),
138
+ ...(Object.keys(extensions).length > 0 ? { extensions } : {}),
30
139
  };
31
140
  }
32
141
  async function waitForTrackedExitPromises(promises, timeoutMs) {
@@ -40,21 +149,24 @@ async function waitForTrackedExitPromises(promises, timeoutMs) {
40
149
  }),
41
150
  ]);
42
151
  }
43
- import { AGENT_CONFIGS } from "./agents.js";
44
- import { getBaseEnvironment, getBaseFilesystemEntries, } from "./base-filesystem.js";
152
+ import { OPT_AGENTOS_ROOT, } from "./agentos-package.js";
153
+ import { getBaseEnvironment } from "./base-filesystem.js";
45
154
  import { CronManager } from "./cron/cron-manager.js";
46
155
  import { TimerScheduleDriver } from "./cron/timer-driver.js";
156
+ import { resolveDefaultSoftware } from "./default-software.js";
47
157
  import { snapshotVirtualFilesystem, sortFilesystemEntries, } from "./filesystem-snapshot.js";
48
- import { createHostDirBackend } from "./host-dir-mount.js";
49
158
  import { serializeMountConfigForSidecar, } from "./js-bridge.js";
50
159
  import { createSnapshotExport, } from "./layers.js";
51
- import { processSoftware, resolvePackageDir, } from "./packages.js";
52
160
  import { allowAll } from "./runtime-compat.js";
53
- import { AcpRuntimeKind, decodeAcpCallback, decodeAcpEvent, decodeAcpResponse, encodeAcpCallbackResponse, encodeAcpRequest, } from "./sidecar/agentos-protocol.js";
161
+ import { decodeAcpCallback, decodeAcpEvent, decodeAcpResponse, encodeAcpCallbackResponse, encodeAcpRequest, } from "./sidecar/agentos-protocol.js";
54
162
  import { serializePermissionsForSidecar } from "./sidecar/permissions.js";
55
- import { createAgentOsSidecarClient, NATIVE_SIDECAR_FRAME_TIMEOUT_MS, NativeSidecarKernelProxy, SidecarProcess, serializeRootFilesystemForSidecar, } from "./sidecar/rpc-client.js";
56
- const LEGACY_PERMISSION_METHOD = "request/permission";
57
- const ACP_PERMISSION_METHOD = "session/request_permission";
163
+ import { createAgentOsSidecarClient, NativeSidecarKernelProxy, SidecarProcess, serializeRootFilesystemForSidecar, } from "./sidecar/rpc-client.js";
164
+ function defaultAgentStderrHandler(event) {
165
+ process.stderr.write(event.chunk);
166
+ }
167
+ function defaultAgentExitHandler(event) {
168
+ process.stderr.write(`[agentos] agent adapter exited unexpectedly: session=${event.sessionId} agent=${event.agentType} exitCode=${event.exitCode ?? "unknown"}; restore explicitly before retrying\n`);
169
+ }
58
170
  class AcpDispatchError extends Error {
59
171
  code;
60
172
  data;
@@ -105,51 +217,28 @@ function toRecord(value) {
105
217
  ? value
106
218
  : {};
107
219
  }
108
- function parseAcpJson(value, context) {
109
- if (value === null) {
110
- return undefined;
220
+ function normalizePackageRef(value) {
221
+ // The single package reference is `packagePath`: the packed `.aospkg` file
222
+ // (registry-built packages export `{ packagePath }`), or a package dir for
223
+ // local transition fixtures. A raw string is shorthand for the same path.
224
+ if (typeof value === "string") {
225
+ return { path: value };
111
226
  }
112
- try {
113
- return JSON.parse(value);
227
+ const record = toRecord(value);
228
+ if (typeof record.packagePath === "string") {
229
+ return { path: record.packagePath };
114
230
  }
115
- catch (error) {
116
- throw new Error(`invalid ACP ${context} JSON: ${error instanceof Error ? error.message : String(error)}`);
231
+ // Recognizably-legacy shapes fail loudly: silently dropping a software
232
+ // entry boots a VM with missing packages and no diagnostic.
233
+ for (const legacy of ["packageTar", "packageDir", "dir"]) {
234
+ if (typeof record[legacy] === "string") {
235
+ throw new Error(`agentOS package ref uses removed field "${legacy}" (value: ${JSON.stringify(record[legacy])}); ` +
236
+ "packages are referenced by a single `packagePath` — update the package " +
237
+ "(rebuild @agentos-software/* dependencies) or pass { packagePath }");
238
+ }
117
239
  }
240
+ return undefined;
118
241
  }
119
- function parseAcpJsonList(values, context) {
120
- return values.map((value, index) => parseAcpJson(value, `${context}[${index}]`));
121
- }
122
- function sidecarSessionCreatedFromAcp(response) {
123
- return {
124
- sessionId: response.sessionId,
125
- ...(response.pid !== null ? { pid: response.pid } : {}),
126
- modes: parseAcpJson(response.modes, "modes"),
127
- configOptions: parseAcpJsonList(response.configOptions, "configOptions"),
128
- agentCapabilities: parseAcpJson(response.agentCapabilities, "agentCapabilities"),
129
- agentInfo: parseAcpJson(response.agentInfo, "agentInfo"),
130
- };
131
- }
132
- function sidecarSessionStateFromAcp(response) {
133
- return {
134
- sessionId: response.sessionId,
135
- agentType: response.agentType,
136
- processId: response.processId,
137
- ...(response.pid !== null ? { pid: response.pid } : {}),
138
- closed: response.closed,
139
- modes: parseAcpJson(response.modes, "modes"),
140
- configOptions: parseAcpJsonList(response.configOptions, "configOptions"),
141
- agentCapabilities: parseAcpJson(response.agentCapabilities, "agentCapabilities"),
142
- agentInfo: parseAcpJson(response.agentInfo, "agentInfo"),
143
- };
144
- }
145
- function isLocalCancelledPromptResponse(method, response) {
146
- const result = toRecord(response.result);
147
- return (method === "session/prompt" &&
148
- response.id === null &&
149
- response.error === undefined &&
150
- result.stopReason === "cancelled");
151
- }
152
- const CLOSED_SESSION_ID_RETENTION_LIMIT = 2048;
153
242
  const CLOSED_SHELL_ID_RETENTION_LIMIT = 2048;
154
243
  class BoundedSet {
155
244
  limit;
@@ -160,11 +249,11 @@ class BoundedSet {
160
249
  }
161
250
  this.limit = limit;
162
251
  }
163
- add(value) {
252
+ add(value, associated) {
164
253
  if (this.#entries.has(value)) {
165
254
  this.#entries.delete(value);
166
255
  }
167
- this.#entries.set(value, undefined);
256
+ this.#entries.set(value, associated);
168
257
  if (this.#entries.size <= this.limit) {
169
258
  return;
170
259
  }
@@ -176,6 +265,9 @@ class BoundedSet {
176
265
  has(value) {
177
266
  return this.#entries.has(value);
178
267
  }
268
+ get(value) {
269
+ return this.#entries.get(value);
270
+ }
179
271
  delete(value) {
180
272
  return this.#entries.delete(value);
181
273
  }
@@ -183,50 +275,6 @@ class BoundedSet {
183
275
  return this.#entries.size;
184
276
  }
185
277
  }
186
- function shouldDispatchToSessionEventHandlers(notification) {
187
- return notification.method === "session/update";
188
- }
189
- function toSessionModes(value) {
190
- if (!value || typeof value !== "object" || Array.isArray(value)) {
191
- return null;
192
- }
193
- return value;
194
- }
195
- function toSessionConfigOptions(value) {
196
- return Array.isArray(value) ? value : [];
197
- }
198
- function toAgentCapabilities(value) {
199
- if (!value || typeof value !== "object" || Array.isArray(value)) {
200
- return {};
201
- }
202
- return value;
203
- }
204
- function toAgentInfo(value) {
205
- if (!value || typeof value !== "object" || Array.isArray(value)) {
206
- return null;
207
- }
208
- if (typeof value.name !== "string") {
209
- return null;
210
- }
211
- return value;
212
- }
213
- function sessionEntryFromInit(sessionId, agentType, initData) {
214
- return {
215
- sessionId,
216
- agentType,
217
- processId: "",
218
- pid: null,
219
- closed: false,
220
- modes: initData.modes ?? null,
221
- configOptions: initData.configOptions ?? [],
222
- capabilities: initData.capabilities ?? {},
223
- agentInfo: initData.agentInfo ?? null,
224
- eventHandlers: new Set(),
225
- permissionHandlers: new Set(),
226
- configOverrides: new Map(),
227
- pendingPermissionReplies: new Map(),
228
- };
229
- }
230
278
  function isOverlayMountConfig(config) {
231
279
  return "filesystem" in config;
232
280
  }
@@ -366,7 +414,8 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [
366
414
  "/mnt",
367
415
  "/media",
368
416
  "/home",
369
- "/home/user",
417
+ "/home/agentos",
418
+ "/workspace",
370
419
  "/usr",
371
420
  "/usr/bin",
372
421
  "/usr/games",
@@ -390,130 +439,59 @@ const KERNEL_POSIX_BOOTSTRAP_DIRS = [
390
439
  "/var/tmp",
391
440
  "/etc/agentos",
392
441
  ];
393
- const NODE_RUNTIME_BOOTSTRAP_COMMANDS = ["node", "npm", "npx"];
394
- const KERNEL_COMMAND_STUB = "#!/bin/sh\n# kernel command stub\n";
442
+ // Standard POSIX metadata for the bootstrap dirs whose mode/owner is NOT the
443
+ // default (`755`, root:root). Replicated as a constant so building the no-base
444
+ // bootstrap layer needs no `base-filesystem.json` read — when a base IS present
445
+ // the sidecar's embedded base layer is authoritative and these are never emitted.
446
+ const KERNEL_POSIX_BOOTSTRAP_DIR_METADATA = {
447
+ "/tmp": { mode: "1777", uid: 0, gid: 0 },
448
+ "/root": { mode: "700", uid: 0, gid: 0 },
449
+ "/sys": { mode: "555", uid: 0, gid: 0 },
450
+ "/home/agentos": { mode: "2755", uid: 1000, gid: 1000 },
451
+ "/workspace": { mode: "755", uid: 1000, gid: 1000 },
452
+ "/var/empty": { mode: "555", uid: 0, gid: 0 },
453
+ "/var/lock": { mode: "777", uid: 0, gid: 0 },
454
+ "/var/run": { mode: "777", uid: 0, gid: 0 },
455
+ "/var/tmp": { mode: "1777", uid: 0, gid: 0 },
456
+ };
457
+ // Runtime commands that get a `/bin/<cmd>` stub at bootstrap so the guest shell
458
+ // resolves them on PATH (e.g. `sh -c "python ..."`, pipelines). The sidecar
459
+ // intercepts these by name and routes them to the embedded V8 / Pyodide runtime.
460
+ const RUNTIME_BOOTSTRAP_COMMANDS = [
461
+ "node",
462
+ "npm",
463
+ "npx",
464
+ "python",
465
+ "python3",
466
+ ];
395
467
  const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
396
468
  const SIDECAR_BINARY = join(REPO_ROOT, "target/debug/agentos-sidecar");
397
469
  const SIDECAR_BUILD_INPUTS = [
398
470
  join(REPO_ROOT, "Cargo.toml"),
399
471
  join(REPO_ROOT, "Cargo.lock"),
400
472
  join(REPO_ROOT, "crates/bridge"),
473
+ join(REPO_ROOT, "crates/build-support"),
401
474
  join(REPO_ROOT, "crates/execution"),
402
475
  join(REPO_ROOT, "crates/kernel"),
403
- join(REPO_ROOT, "crates/sidecar"),
476
+ join(REPO_ROOT, "crates/agentos-protocol"),
477
+ join(REPO_ROOT, "crates/agentos-sidecar"),
478
+ join(REPO_ROOT, "crates/native-sidecar"),
479
+ join(REPO_ROOT, "crates/native-sidecar-core"),
480
+ join(REPO_ROOT, "crates/sidecar-protocol"),
481
+ join(REPO_ROOT, "crates/v8-runtime"),
482
+ join(REPO_ROOT, "crates/vfs"),
483
+ join(REPO_ROOT, "crates/vm-config"),
484
+ join(REPO_ROOT, "packages/build-tools/bridge-src"),
485
+ join(REPO_ROOT, "packages/build-tools/package.json"),
486
+ join(REPO_ROOT, "packages/build-tools/scripts/build-v8-bridge.mjs"),
487
+ join(REPO_ROOT, "packages/core/fixtures/base-filesystem.json"),
488
+ join(REPO_ROOT, "packages/runtime-core/fixtures/base-filesystem.json"),
489
+ join(REPO_ROOT, "pnpm-lock.yaml"),
404
490
  ];
405
491
  let ensuredSidecarBinary = null;
406
- function isWasmBinaryFile(path) {
407
- try {
408
- const header = readFileSync(path);
409
- return (header.length >= 4 &&
410
- header[0] === 0x00 &&
411
- header[1] === 0x61 &&
412
- header[2] === 0x73 &&
413
- header[3] === 0x6d);
414
- }
415
- catch {
416
- return false;
417
- }
418
- }
419
- function collectBootstrapWasmCommands(commandDirs) {
420
- const commands = [];
421
- const seen = new Set();
422
- for (const dir of commandDirs) {
423
- let entries;
424
- try {
425
- entries = readdirSync(dir).sort((a, b) => a.localeCompare(b));
426
- }
427
- catch {
428
- continue;
429
- }
430
- for (const entry of entries) {
431
- if (entry.startsWith(".")) {
432
- continue;
433
- }
434
- const fullPath = join(dir, entry);
435
- try {
436
- if (statSync(fullPath).isDirectory()) {
437
- continue;
438
- }
439
- }
440
- catch {
441
- continue;
442
- }
443
- if (!isWasmBinaryFile(fullPath) || seen.has(entry)) {
444
- continue;
445
- }
446
- seen.add(entry);
447
- commands.push(entry);
448
- }
449
- }
450
- return commands;
451
- }
452
- function resolveDeclaredCommandSource(commandDir, commandName, aliases) {
453
- let current = commandName;
454
- const visited = new Set();
455
- while (!visited.has(current)) {
456
- visited.add(current);
457
- const candidatePath = join(commandDir, current);
458
- if (isWasmBinaryFile(candidatePath)) {
459
- return candidatePath;
460
- }
461
- const next = aliases[current];
462
- if (!next) {
463
- return null;
464
- }
465
- current = next;
466
- }
467
- return null;
468
- }
469
- function prepareCommandDirs(commandPackages) {
470
- const commandDirs = [];
471
- const tempDirs = [];
472
- try {
473
- for (const commandPackage of commandPackages) {
474
- commandDirs.push(commandPackage.commandDir);
475
- const aliasEntries = Object.entries(commandPackage.aliases)
476
- .sort(([leftAlias], [rightAlias]) => leftAlias.localeCompare(rightAlias))
477
- .flatMap(([aliasName]) => {
478
- const aliasPath = join(commandPackage.commandDir, aliasName);
479
- if (isWasmBinaryFile(aliasPath)) {
480
- return [];
481
- }
482
- const sourcePath = resolveDeclaredCommandSource(commandPackage.commandDir, aliasName, commandPackage.aliases);
483
- if (!sourcePath) {
484
- return [];
485
- }
486
- return [[aliasName, sourcePath]];
487
- });
488
- if (aliasEntries.length === 0) {
489
- continue;
490
- }
491
- const aliasDir = mkdtempSync(join(tmpdir(), "agentos-command-aliases-"));
492
- for (const [aliasName, sourcePath] of aliasEntries) {
493
- writeFileSync(join(aliasDir, aliasName), readFileSync(sourcePath));
494
- }
495
- tempDirs.push(aliasDir);
496
- commandDirs.push(aliasDir);
497
- }
498
- }
499
- catch (error) {
500
- for (const tempDir of tempDirs) {
501
- rmSync(tempDir, { recursive: true, force: true });
502
- }
503
- throw error;
504
- }
505
- return {
506
- commandDirs,
507
- dispose() {
508
- for (const tempDir of tempDirs) {
509
- rmSync(tempDir, { recursive: true, force: true });
510
- }
511
- },
512
- };
513
- }
514
492
  function collectConfiguredLowerPaths(config) {
515
493
  const paths = new Set();
516
- for (const lower of config?.lowers ?? []) {
494
+ for (const lower of config?.type === "native" ? [] : (config?.lowers ?? [])) {
517
495
  if (lower.kind !== "snapshot-export") {
518
496
  continue;
519
497
  }
@@ -524,7 +502,7 @@ function collectConfiguredLowerPaths(config) {
524
502
  return paths;
525
503
  }
526
504
  function findBootstrapSeedEntry(config, path) {
527
- for (const lower of config?.lowers ?? []) {
505
+ for (const lower of config?.type === "native" ? [] : (config?.lowers ?? [])) {
528
506
  if (lower.kind !== "snapshot-export") {
529
507
  continue;
530
508
  }
@@ -533,16 +511,18 @@ function findBootstrapSeedEntry(config, path) {
533
511
  return entry;
534
512
  }
535
513
  }
536
- return getBaseFilesystemEntries().find((entry) => entry.path === path);
514
+ // No base-filesystem JSON read: standard non-default dir metadata comes from
515
+ // the constant table. When a base layer IS present these dirs are never
516
+ // emitted (see createKernelBootstrapLower), so this only seeds the no-base case.
517
+ const meta = KERNEL_POSIX_BOOTSTRAP_DIR_METADATA[path];
518
+ return meta ? { path, type: "directory", ...meta } : undefined;
537
519
  }
538
- function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
520
+ function createKernelBootstrapLower(config, extraEntries = []) {
521
+ if (config?.type === "native") {
522
+ return null;
523
+ }
539
524
  const includesBundledBaseLayer = !(config?.disableDefaultBaseLayer ?? false);
540
525
  const existingPaths = collectConfiguredLowerPaths(config);
541
- if (includesBundledBaseLayer) {
542
- for (const entry of getBaseFilesystemEntries()) {
543
- existingPaths.add(entry.path);
544
- }
545
- }
546
526
  const entries = [
547
527
  {
548
528
  path: "/",
@@ -552,18 +532,24 @@ function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
552
532
  gid: 0,
553
533
  },
554
534
  ];
555
- for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
556
- if (existingPaths.has(dir)) {
557
- continue;
535
+ // Only run the FS bootstrap (creating the POSIX dir tree) when there is NO
536
+ // base layer. When the bundled base IS present, the sidecar's embedded base
537
+ // layer already provides every POSIX dir with the correct mode/owner, so we
538
+ // emit nothing here and never read its filesystem table.
539
+ if (!includesBundledBaseLayer) {
540
+ for (const dir of KERNEL_POSIX_BOOTSTRAP_DIRS) {
541
+ if (existingPaths.has(dir)) {
542
+ continue;
543
+ }
544
+ const seed = findBootstrapSeedEntry(config, dir);
545
+ entries.push({
546
+ path: dir,
547
+ type: "directory",
548
+ mode: seed?.type === "directory" ? seed.mode : "755",
549
+ uid: seed?.uid ?? 0,
550
+ gid: seed?.gid ?? 0,
551
+ });
558
552
  }
559
- const seed = findBootstrapSeedEntry(config, dir);
560
- entries.push({
561
- path: dir,
562
- type: "directory",
563
- mode: seed?.type === "directory" ? seed.mode : "755",
564
- uid: seed?.uid ?? 0,
565
- gid: seed?.gid ?? 0,
566
- });
567
553
  }
568
554
  if (!includesBundledBaseLayer && !existingPaths.has("/usr/bin/env")) {
569
555
  entries.push({
@@ -576,22 +562,6 @@ function createKernelBootstrapLower(config, commandNames, extraEntries = []) {
576
562
  encoding: "base64",
577
563
  });
578
564
  }
579
- const uniqueCommands = [...new Set(commandNames)].sort((a, b) => a.localeCompare(b));
580
- for (const command of uniqueCommands) {
581
- const stubPath = `/bin/${command}`;
582
- if (existingPaths.has(stubPath)) {
583
- continue;
584
- }
585
- entries.push({
586
- path: stubPath,
587
- type: "file",
588
- mode: "755",
589
- uid: 0,
590
- gid: 0,
591
- content: KERNEL_COMMAND_STUB,
592
- encoding: "utf8",
593
- });
594
- }
595
565
  for (const entry of sortFilesystemEntries(extraEntries)) {
596
566
  if (existingPaths.has(entry.path)) {
597
567
  continue;
@@ -619,7 +589,7 @@ function buildLiveBootstrapDirectoryEntries(existingPaths, config) {
619
589
  return entries;
620
590
  }
621
591
  async function bootstrapLiveBootstrapDirectories(client, session, vm, config) {
622
- const existingPaths = new Set((await client.snapshotRootFilesystem(session, vm)).map((entry) => entry.path));
592
+ const existingPaths = new Set((await client.snapshotRootFilesystem(session, vm, Number.MAX_SAFE_INTEGER)).map((entry) => entry.path));
623
593
  const entries = buildLiveBootstrapDirectoryEntries(existingPaths, config);
624
594
  if (entries.length === 0) {
625
595
  return;
@@ -706,28 +676,6 @@ function latestMtimeMs(path) {
706
676
  }
707
677
  return latest;
708
678
  }
709
- function collectGuestCommandPaths(commandDirs) {
710
- const guestPaths = new Map();
711
- for (const [index, commandDir] of commandDirs.entries()) {
712
- let entries;
713
- try {
714
- entries = readdirSync(commandDir).sort((left, right) => left.localeCompare(right));
715
- }
716
- catch {
717
- continue;
718
- }
719
- for (const entry of entries) {
720
- if (entry.startsWith(".")) {
721
- continue;
722
- }
723
- if (!isWasmBinaryFile(join(commandDir, entry)) || guestPaths.has(entry)) {
724
- continue;
725
- }
726
- guestPaths.set(entry, `/__secure_exec/commands/${index}/${entry}`);
727
- }
728
- }
729
- return guestPaths;
730
- }
731
679
  async function resolveCompatLocalMounts(mounts) {
732
680
  if (!mounts) {
733
681
  return [];
@@ -796,57 +744,32 @@ function collectSidecarMountPlan(options) {
796
744
  }
797
745
  for (const mount of options.mounts ?? []) {
798
746
  if (!isNativeMountConfig(mount)) {
747
+ sidecarMounts.push({
748
+ guestPath: mount.path,
749
+ readOnly: isOverlayMountConfig(mount)
750
+ ? (mount.filesystem.mode ?? "ephemeral") === "read-only"
751
+ : (mount.readOnly ?? false),
752
+ plugin: {
753
+ id: "js_bridge",
754
+ config: {},
755
+ },
756
+ });
799
757
  continue;
800
758
  }
801
759
  pushMount(mount);
802
760
  }
803
- for (const root of options.softwareRoots) {
804
- pushMount({
805
- path: root.vmPath,
806
- plugin: createHostDirBackend({
807
- hostPath: root.hostPath,
808
- readOnly: true,
809
- }),
810
- readOnly: true,
811
- });
812
- }
813
- for (const [index, commandDir] of options.commandDirs.entries()) {
814
- pushMount({
815
- path: `/__secure_exec/commands/${index}`,
816
- plugin: createHostDirBackend({
817
- hostPath: commandDir,
818
- readOnly: true,
819
- }),
820
- readOnly: true,
821
- });
822
- }
823
- if (options.shimDir) {
824
- pushMount({
825
- path: "/usr/local/bin",
826
- plugin: createHostDirBackend({
827
- hostPath: options.shimDir,
828
- readOnly: true,
829
- }),
830
- readOnly: true,
831
- });
832
- }
833
761
  hostMounts.sort((left, right) => right.vmPath.length - left.vmPath.length);
834
762
  hostPathMappings.sort((left, right) => right.vmPath.length - left.vmPath.length);
835
763
  return { sidecarMounts, hostMounts, hostPathMappings };
836
764
  }
837
- function materializeToolShimDir(toolKits) {
838
- const shimDir = mkdtempSync(join(tmpdir(), "agentos-host-tools-shims-"));
839
- writeFileSync(join(shimDir, "agentos"), KERNEL_COMMAND_STUB, { mode: 0o755 });
840
- for (const toolKit of toolKits) {
841
- writeFileSync(join(shimDir, `agentos-${toolKit.name}`), KERNEL_COMMAND_STUB, { mode: 0o755 });
842
- }
843
- return shimDir;
844
- }
845
- function collectToolkitBootstrapCommands(toolKits) {
846
- if (toolKits.length === 0) {
765
+ function collectBindingBootstrapCommands(bindings) {
766
+ if (bindings.length === 0) {
847
767
  return [];
848
768
  }
849
- return ["agentos", ...toolKits.map((toolKit) => `agentos-${toolKit.name}`)];
769
+ return [
770
+ "agentos",
771
+ ...bindings.map((bindingCollection) => `agentos-${bindingCollection.name}`),
772
+ ];
850
773
  }
851
774
  function validationMessage(error) {
852
775
  if (typeof error === "object" &&
@@ -864,14 +787,16 @@ function validationMessage(error) {
864
787
  }
865
788
  return error instanceof Error ? error.message : String(error);
866
789
  }
867
- function toolToSidecarDefinition(tool) {
790
+ function bindingToSidecarDefinition(definition) {
868
791
  return {
869
- description: tool.description,
870
- inputSchema: zodToJsonSchema(tool.inputSchema),
871
- ...(tool.timeout !== undefined ? { timeoutMs: tool.timeout } : {}),
872
- ...(tool.examples && tool.examples.length > 0
792
+ description: definition.description,
793
+ inputSchema: zodToJsonSchema(definition.inputSchema),
794
+ ...(definition.timeout !== undefined
795
+ ? { timeoutMs: definition.timeout }
796
+ : {}),
797
+ ...(definition.examples && definition.examples.length > 0
873
798
  ? {
874
- examples: tool.examples.map((example) => ({
799
+ examples: definition.examples.map((example) => ({
875
800
  description: example.description,
876
801
  input: example.input,
877
802
  })),
@@ -879,8 +804,8 @@ function toolToSidecarDefinition(tool) {
879
804
  : {}),
880
805
  };
881
806
  }
882
- function combineInstructions(additionalInstructions, toolReference) {
883
- const parts = [additionalInstructions, toolReference]
807
+ function combineInstructions(additionalInstructions, bindingReference) {
808
+ const parts = [additionalInstructions, bindingReference]
884
809
  .map((part) => part?.trim())
885
810
  .filter((part) => Boolean(part));
886
811
  if (parts.length === 0) {
@@ -888,48 +813,48 @@ function combineInstructions(additionalInstructions, toolReference) {
888
813
  }
889
814
  return parts.join("\n\n");
890
815
  }
891
- function buildHostToolReference(toolKits) {
892
- if (toolKits.length === 0) {
816
+ function buildBindingReference(bindings) {
817
+ if (bindings.length === 0) {
893
818
  return "";
894
819
  }
895
820
  const lines = [
896
- "## Available Host Tools",
821
+ "## Available Host Bindings",
897
822
  "",
898
- "Run `agentos list-tools` to see all available tools.",
823
+ "Run `agentos list-bindings` to see all available bindings.",
899
824
  "",
900
825
  ];
901
- for (const toolKit of toolKits) {
902
- lines.push(`### ${toolKit.name}`);
826
+ for (const bindingCollection of bindings) {
827
+ lines.push(`### ${bindingCollection.name}`);
903
828
  lines.push("");
904
- lines.push(toolKit.description);
829
+ lines.push(bindingCollection.description);
905
830
  lines.push("");
906
- for (const [toolName, tool] of Object.entries(toolKit.tools)) {
907
- const sidecarTool = toolToSidecarDefinition(tool);
908
- const signature = buildToolFlagSignature(sidecarTool.inputSchema);
831
+ for (const [bindingName, definition] of Object.entries(bindingCollection.bindings)) {
832
+ const sidecarBinding = bindingToSidecarDefinition(definition);
833
+ const signature = buildBindingFlagSignature(sidecarBinding.inputSchema);
909
834
  const suffix = signature.length > 0 ? ` ${signature}` : "";
910
- lines.push(`- \`agentos-${toolKit.name} ${toolName}${suffix}\` — ${tool.description}`);
835
+ lines.push(`- \`agentos-${bindingCollection.name} ${bindingName}${suffix}\` — ${definition.description}`);
911
836
  }
912
837
  lines.push("");
913
- const toolsWithExamples = Object.entries(toolKit.tools).filter(([, tool]) => tool.examples && tool.examples.length > 0);
914
- if (toolsWithExamples.length > 0) {
838
+ const bindingsWithExamples = Object.entries(bindingCollection.bindings).filter(([, definition]) => definition.examples && definition.examples.length > 0);
839
+ if (bindingsWithExamples.length > 0) {
915
840
  lines.push("**Examples:**");
916
841
  lines.push("");
917
- for (const [toolName, tool] of toolsWithExamples) {
918
- for (const example of tool.examples ?? []) {
919
- const args = inputToToolFlags(example.input);
842
+ for (const [bindingName, definition] of bindingsWithExamples) {
843
+ for (const example of definition.examples ?? []) {
844
+ const args = inputToBindingFlags(example.input);
920
845
  const suffix = args.length > 0 ? ` ${args}` : "";
921
- lines.push(`- ${example.description}: \`agentos-${toolKit.name} ${toolName}${suffix}\``);
846
+ lines.push(`- ${example.description}: \`agentos-${bindingCollection.name} ${bindingName}${suffix}\``);
922
847
  }
923
848
  }
924
849
  lines.push("");
925
850
  }
926
- lines.push(`Run \`agentos-${toolKit.name} <tool> --help\` for details.`);
851
+ lines.push(`Run \`agentos-${bindingCollection.name} <binding> --help\` for details.`);
927
852
  lines.push("");
928
853
  }
929
854
  return lines.join("\n");
930
855
  }
931
- function buildToolFlagSignature(schema) {
932
- return describeToolFlags(schema)
856
+ function buildBindingFlagSignature(schema) {
857
+ return describeBindingFlags(schema)
933
858
  .map((flag) => {
934
859
  if (flag.required) {
935
860
  return `${flag.name} <${flag.type}>`;
@@ -938,7 +863,7 @@ function buildToolFlagSignature(schema) {
938
863
  })
939
864
  .join(" ");
940
865
  }
941
- function describeToolFlags(schema) {
866
+ function describeBindingFlags(schema) {
942
867
  const schemaObject = asRecord(schema);
943
868
  const properties = asRecord(schemaObject.properties);
944
869
  const required = Array.isArray(schemaObject.required)
@@ -946,11 +871,11 @@ function describeToolFlags(schema) {
946
871
  : new Set();
947
872
  return Object.entries(properties).map(([fieldName, fieldSchema]) => ({
948
873
  name: `--${camelToKebab(fieldName)}`,
949
- type: describeToolFlagType(fieldSchema),
874
+ type: describeBindingFlagType(fieldSchema),
950
875
  required: required.has(fieldName),
951
876
  }));
952
877
  }
953
- function describeToolFlagType(schema) {
878
+ function describeBindingFlagType(schema) {
954
879
  const schemaObject = asRecord(schema);
955
880
  const type = typeof schemaObject.type === "string" ? schemaObject.type : undefined;
956
881
  if (type === "array") {
@@ -969,7 +894,7 @@ function describeJsonSchemaScalarType(schema) {
969
894
  const schemaObject = asRecord(schema);
970
895
  return typeof schemaObject.type === "string" ? schemaObject.type : "string";
971
896
  }
972
- function inputToToolFlags(input) {
897
+ function inputToBindingFlags(input) {
973
898
  const inputObject = asRecord(input);
974
899
  return Object.entries(inputObject)
975
900
  .flatMap(([key, value]) => {
@@ -981,13 +906,13 @@ function inputToToolFlags(input) {
981
906
  return [`--no-${camelToKebab(key)}`];
982
907
  }
983
908
  if (Array.isArray(value)) {
984
- return value.map((item) => `${flag} ${toolCliString(item)}`);
909
+ return value.map((item) => `${flag} ${bindingCliString(item)}`);
985
910
  }
986
- return [`${flag} ${toolCliString(value)}`];
911
+ return [`${flag} ${bindingCliString(value)}`];
987
912
  })
988
913
  .join(" ");
989
914
  }
990
- function toolCliString(value) {
915
+ function bindingCliString(value) {
991
916
  return typeof value === "string" ? value : (JSON.stringify(value) ?? "null");
992
917
  }
993
918
  function camelToKebab(value) {
@@ -1025,23 +950,23 @@ async function handleHostCallback(request, context) {
1025
950
  };
1026
951
  }
1027
952
  }
1028
- const tool = context.toolMap.get(payload.callback_key);
1029
- if (!tool) {
953
+ const definition = context.bindingMap.get(payload.callback_key);
954
+ if (!definition) {
1030
955
  return {
1031
956
  type: "host_callback_result",
1032
957
  invocation_id: payload.invocation_id,
1033
- error: `Unknown tool "${payload.callback_key}"`,
958
+ error: `Unknown binding "${payload.callback_key}"`,
1034
959
  };
1035
960
  }
1036
- const permissionMode = toolPermissionMode(context.permissions, payload.callback_key);
961
+ const permissionMode = bindingPermissionMode(context.permissions, payload.callback_key);
1037
962
  if (permissionMode !== "allow") {
1038
963
  return {
1039
964
  type: "host_callback_result",
1040
965
  invocation_id: payload.invocation_id,
1041
- error: `EACCES: blocked by tool.invoke policy for ${payload.callback_key}`,
966
+ error: `EACCES: blocked by binding.invoke policy for ${payload.callback_key}`,
1042
967
  };
1043
968
  }
1044
- const parsed = tool.inputSchema.safeParse(payload.input);
969
+ const parsed = definition.inputSchema.safeParse(payload.input);
1045
970
  if (!parsed.success) {
1046
971
  return {
1047
972
  type: "host_callback_result",
@@ -1053,7 +978,7 @@ async function handleHostCallback(request, context) {
1053
978
  return {
1054
979
  type: "host_callback_result",
1055
980
  invocation_id: payload.invocation_id,
1056
- result: await executeHostTool(tool, payload.callback_key, parsed.data),
981
+ result: await executeBinding(definition, payload.callback_key, parsed.data),
1057
982
  };
1058
983
  }
1059
984
  catch (error) {
@@ -1064,14 +989,142 @@ async function handleHostCallback(request, context) {
1064
989
  };
1065
990
  }
1066
991
  }
1067
- function buildToolMap(toolKits) {
1068
- const toolMap = new Map();
1069
- for (const toolKit of toolKits) {
1070
- for (const [toolName, tool] of Object.entries(toolKit.tools)) {
1071
- toolMap.set(`${toolKit.name}:${toolName}`, tool);
992
+ function buildBindingMap(bindings) {
993
+ const bindingMap = new Map();
994
+ for (const bindingCollection of bindings) {
995
+ for (const [bindingName, definition] of Object.entries(bindingCollection.bindings)) {
996
+ bindingMap.set(`${bindingCollection.name}:${bindingName}`, definition);
997
+ }
998
+ }
999
+ return bindingMap;
1000
+ }
1001
+ function bridgeErrorMessage(error) {
1002
+ return error instanceof Error ? error.message : String(error);
1003
+ }
1004
+ function toBridgeArgs(value) {
1005
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1006
+ throw new Error("js_bridge args must be an object");
1007
+ }
1008
+ return value;
1009
+ }
1010
+ function bridgePath(mountId, value) {
1011
+ if (!mountId.startsWith("/")) {
1012
+ throw new Error(`Unsupported js_bridge mount id: ${mountId}`);
1013
+ }
1014
+ if (typeof value !== "string") {
1015
+ throw new Error("js_bridge path argument must be a string");
1016
+ }
1017
+ return posixPath.normalize(posixPath.join(mountId, value));
1018
+ }
1019
+ function requireBridgeNumber(value, field) {
1020
+ if (typeof value !== "number" || !Number.isFinite(value)) {
1021
+ throw new Error(`js_bridge args.${field} must be a number`);
1022
+ }
1023
+ return value;
1024
+ }
1025
+ function decodeBridgeBytes(value, field) {
1026
+ if (typeof value === "string") {
1027
+ return new Uint8Array(Buffer.from(value, "base64"));
1028
+ }
1029
+ if (Array.isArray(value) &&
1030
+ value.every((entry) => Number.isInteger(entry) && entry >= 0 && entry <= 255)) {
1031
+ return new Uint8Array(value);
1032
+ }
1033
+ throw new Error(`js_bridge args.${field} must be base64 bytes`);
1034
+ }
1035
+ async function handleJsBridgeCall(request, context) {
1036
+ try {
1037
+ const args = toBridgeArgs(request.args);
1038
+ const fs = context.filesystem;
1039
+ const path = () => bridgePath(request.mount_id, args.path);
1040
+ let result;
1041
+ switch (request.operation) {
1042
+ case "readFile":
1043
+ result = Buffer.from(await fs.readFile(path())).toString("base64");
1044
+ break;
1045
+ case "readDir":
1046
+ result = await fs.readDir(path());
1047
+ break;
1048
+ case "readDirWithTypes":
1049
+ result = await fs.readDirWithTypes(path());
1050
+ break;
1051
+ case "writeFile":
1052
+ await fs.writeFile(path(), decodeBridgeBytes(args.content, "content"));
1053
+ break;
1054
+ case "createDir":
1055
+ await fs.createDir(path());
1056
+ break;
1057
+ case "mkdir":
1058
+ await fs.mkdir(path(), { recursive: args.recursive !== false });
1059
+ break;
1060
+ case "exists":
1061
+ result = await fs.exists(path());
1062
+ break;
1063
+ case "stat":
1064
+ result = await fs.stat(path());
1065
+ break;
1066
+ case "removeFile":
1067
+ await fs.removeFile(path());
1068
+ break;
1069
+ case "removeDir":
1070
+ await fs.removeDir(path());
1071
+ break;
1072
+ case "rename":
1073
+ await fs.rename(bridgePath(request.mount_id, args.oldPath), bridgePath(request.mount_id, args.newPath));
1074
+ break;
1075
+ case "realpath":
1076
+ result = await fs.realpath(path());
1077
+ break;
1078
+ case "symlink": {
1079
+ if (typeof args.target !== "string") {
1080
+ throw new Error("js_bridge args.target must be a string");
1081
+ }
1082
+ await fs.symlink(args.target, bridgePath(request.mount_id, args.linkPath));
1083
+ break;
1084
+ }
1085
+ case "readlink":
1086
+ result = await fs.readlink(path());
1087
+ break;
1088
+ case "lstat":
1089
+ result = await fs.lstat(path());
1090
+ break;
1091
+ case "link":
1092
+ await fs.link(bridgePath(request.mount_id, args.oldPath), bridgePath(request.mount_id, args.newPath));
1093
+ break;
1094
+ case "chmod":
1095
+ await fs.chmod(path(), requireBridgeNumber(args.mode, "mode"));
1096
+ break;
1097
+ case "chown":
1098
+ await fs.chown(path(), requireBridgeNumber(args.uid, "uid"), requireBridgeNumber(args.gid, "gid"), { followSymlinks: args.followSymlinks !== false });
1099
+ break;
1100
+ case "utimes":
1101
+ await fs.utimes(path(), requireBridgeNumber(args.atimeMs, "atimeMs"), requireBridgeNumber(args.mtimeMs, "mtimeMs"));
1102
+ break;
1103
+ case "truncate":
1104
+ await fs.truncate(path(), requireBridgeNumber(args.length, "length"));
1105
+ break;
1106
+ case "pread":
1107
+ result = Buffer.from(await fs.pread(path(), requireBridgeNumber(args.offset, "offset"), requireBridgeNumber(args.length, "length"))).toString("base64");
1108
+ break;
1109
+ case "pwrite":
1110
+ await fs.pwrite(path(), requireBridgeNumber(args.offset, "offset"), decodeBridgeBytes(args.content, "content"));
1111
+ break;
1112
+ default:
1113
+ throw new Error(`Unsupported js_bridge operation: ${request.operation}`);
1072
1114
  }
1115
+ return {
1116
+ type: "js_bridge_result",
1117
+ call_id: request.call_id,
1118
+ ...(result === undefined ? {} : { result }),
1119
+ };
1120
+ }
1121
+ catch (error) {
1122
+ return {
1123
+ type: "js_bridge_result",
1124
+ call_id: request.call_id,
1125
+ error: bridgeErrorMessage(error),
1126
+ };
1073
1127
  }
1074
- return toolMap;
1075
1128
  }
1076
1129
  function parseHostCommandCallbackInput(input) {
1077
1130
  const value = asRecord(input);
@@ -1090,90 +1143,90 @@ function parseHostCommandCallbackInput(input) {
1090
1143
  };
1091
1144
  }
1092
1145
  async function handleHostCommandCallback(command, context) {
1093
- const directToolKit = context.toolKits.find((toolKit) => `agentos-${toolKit.name}` === command.command);
1146
+ const directBindings = context.bindings.find((bindingCollection) => `agentos-${bindingCollection.name}` === command.command);
1094
1147
  if (command.command === "agentos") {
1095
1148
  return handleAgentOsRegistryCommand(command, context);
1096
1149
  }
1097
- if (directToolKit) {
1098
- return handleAgentOsToolkitCommand(command, context, directToolKit);
1150
+ if (directBindings) {
1151
+ return handleAgentOsBindingCommand(command, context, directBindings);
1099
1152
  }
1100
1153
  throw new Error(`Unknown host callback command "${command.command}"`);
1101
1154
  }
1102
1155
  async function handleAgentOsRegistryCommand(command, context) {
1103
- const [subcommand, toolkitName, toolName, ...toolArgs] = command.args;
1156
+ const [subcommand, collectionName, bindingName, ...bindingArgs] = command.args;
1104
1157
  if (!subcommand || isHelpFlag(subcommand)) {
1105
1158
  return {
1106
- usage: "agentos <command>: list-tools [toolkit], <toolkit> --help, or <toolkit> <tool> ...",
1159
+ usage: "agentos <command>: list-bindings [collection], <collection> --help, or <collection> <binding> ...",
1107
1160
  };
1108
1161
  }
1109
- if (subcommand === "list-tools") {
1110
- return toolkitName
1111
- ? describeToolkitPayload(context.toolKits, toolkitName)
1112
- : listToolkitsPayload(context.toolKits);
1162
+ if (subcommand === "list-bindings") {
1163
+ return collectionName
1164
+ ? describeBindingsPayload(context.bindings, collectionName)
1165
+ : listBindingsPayload(context.bindings);
1113
1166
  }
1114
- const toolKit = context.toolKits.find((kit) => kit.name === subcommand);
1115
- if (!toolKit) {
1116
- throw new Error(`No toolkit "${subcommand}". Available: ${toolkitNames(context.toolKits)}`);
1167
+ const bindingCollection = context.bindings.find((collection) => collection.name === subcommand);
1168
+ if (!bindingCollection) {
1169
+ throw new Error(`No binding collection "${subcommand}". Available: ${bindingsNames(context.bindings)}`);
1117
1170
  }
1118
- if (!toolkitName || isHelpFlag(toolkitName)) {
1119
- return describeToolkitPayload(context.toolKits, subcommand);
1171
+ if (!collectionName || isHelpFlag(collectionName)) {
1172
+ return describeBindingsPayload(context.bindings, subcommand);
1120
1173
  }
1121
- if (toolName && isHelpFlag(toolName)) {
1122
- return describeToolPayload(toolKit, toolkitName);
1174
+ if (bindingName && isHelpFlag(bindingName)) {
1175
+ return describeBindingPayload(bindingCollection, collectionName);
1123
1176
  }
1124
- return invokeHostTool({
1125
- toolKit,
1126
- toolName: toolkitName,
1127
- args: [toolName, ...toolArgs].filter((value) => typeof value === "string"),
1177
+ return invokeBinding({
1178
+ bindingCollection,
1179
+ bindingName: collectionName,
1180
+ args: [bindingName, ...bindingArgs].filter((value) => typeof value === "string"),
1128
1181
  cwd: command.cwd,
1129
1182
  context,
1130
1183
  });
1131
1184
  }
1132
- async function handleAgentOsToolkitCommand(command, context, toolKit) {
1133
- const [toolName, helpOrFirstArg, ...rest] = command.args;
1134
- if (!toolName || isHelpFlag(toolName)) {
1135
- return describeToolkitPayload(context.toolKits, toolKit.name);
1185
+ async function handleAgentOsBindingCommand(command, context, bindingCollection) {
1186
+ const [bindingName, helpOrFirstArg, ...rest] = command.args;
1187
+ if (!bindingName || isHelpFlag(bindingName)) {
1188
+ return describeBindingsPayload(context.bindings, bindingCollection.name);
1136
1189
  }
1137
1190
  if (helpOrFirstArg && isHelpFlag(helpOrFirstArg)) {
1138
- return describeToolPayload(toolKit, toolName);
1191
+ return describeBindingPayload(bindingCollection, bindingName);
1139
1192
  }
1140
- return invokeHostTool({
1141
- toolKit,
1142
- toolName,
1193
+ return invokeBinding({
1194
+ bindingCollection,
1195
+ bindingName,
1143
1196
  args: [helpOrFirstArg, ...rest].filter((value) => typeof value === "string"),
1144
1197
  cwd: command.cwd,
1145
1198
  context,
1146
1199
  });
1147
1200
  }
1148
- async function invokeHostTool({ toolKit, toolName, args, cwd, context, }) {
1149
- const tool = toolKit.tools[toolName];
1150
- if (!tool) {
1151
- throw new Error(`No tool "${toolName}" in toolkit "${toolKit.name}". Available: ${toolNames(toolKit)}`);
1201
+ async function invokeBinding({ bindingCollection, bindingName, args, cwd, context, }) {
1202
+ const definition = bindingCollection.bindings[bindingName];
1203
+ if (!definition) {
1204
+ throw new Error(`No binding "${bindingName}" in collection "${bindingCollection.name}". Available: ${bindingNames(bindingCollection)}`);
1152
1205
  }
1153
- const callbackKey = `${toolKit.name}:${toolName}`;
1154
- const permissionMode = toolPermissionMode(context.permissions, callbackKey);
1206
+ const callbackKey = `${bindingCollection.name}:${bindingName}`;
1207
+ const permissionMode = bindingPermissionMode(context.permissions, callbackKey);
1155
1208
  if (permissionMode !== "allow") {
1156
- throw new Error(`EACCES: blocked by tool.invoke policy for ${callbackKey}`);
1209
+ throw new Error(`EACCES: blocked by binding.invoke policy for ${callbackKey}`);
1157
1210
  }
1158
- const input = await parseHostToolInput(tool, args, cwd, context.readFile);
1159
- return executeHostTool(tool, callbackKey, input);
1211
+ const input = await parseBindingInput(definition, args, cwd, context.readFile);
1212
+ return executeBinding(definition, callbackKey, input);
1160
1213
  }
1161
- async function executeHostTool(tool, callbackKey, input) {
1162
- const parsed = tool.inputSchema.safeParse(input);
1214
+ async function executeBinding(definition, callbackKey, input) {
1215
+ const parsed = definition.inputSchema.safeParse(input);
1163
1216
  if (!parsed.success) {
1164
1217
  throw new Error(validationMessage(parsed.error));
1165
1218
  }
1166
1219
  return Promise.race([
1167
- Promise.resolve(tool.execute(parsed.data)),
1220
+ Promise.resolve(definition.execute(parsed.data)),
1168
1221
  new Promise((_, reject) => {
1169
- if (tool.timeout === undefined) {
1222
+ if (definition.timeout === undefined) {
1170
1223
  return;
1171
1224
  }
1172
- setTimeout(() => reject(new Error(`Tool "${callbackKey}" timed out after ${tool.timeout}ms`)), tool.timeout);
1225
+ setTimeout(() => reject(new Error(`Binding "${callbackKey}" timed out after ${definition.timeout}ms`)), definition.timeout);
1173
1226
  }),
1174
1227
  ]);
1175
1228
  }
1176
- async function parseHostToolInput(tool, args, cwd, readFile) {
1229
+ async function parseBindingInput(definition, args, cwd, readFile) {
1177
1230
  if (args[0] === "--json") {
1178
1231
  const value = args[1];
1179
1232
  if (value === undefined) {
@@ -1192,9 +1245,9 @@ async function parseHostToolInput(tool, args, cwd, readFile) {
1192
1245
  const text = new TextDecoder().decode(await readFile(guestPath));
1193
1246
  return JSON.parse(text);
1194
1247
  }
1195
- return parseToolArgv(toolToSidecarDefinition(tool).inputSchema, args);
1248
+ return parseBindingArgv(bindingToSidecarDefinition(definition).inputSchema, args);
1196
1249
  }
1197
- function parseToolArgv(schema, argv) {
1250
+ function parseBindingArgv(schema, argv) {
1198
1251
  const schemaObject = asRecord(schema);
1199
1252
  const properties = asRecord(schemaObject.properties);
1200
1253
  const required = Array.isArray(schemaObject.required)
@@ -1266,50 +1319,50 @@ function parseToolArgv(schema, argv) {
1266
1319
  }
1267
1320
  return input;
1268
1321
  }
1269
- function listToolkitsPayload(toolKits) {
1322
+ function listBindingsPayload(bindings) {
1270
1323
  return {
1271
- toolkits: toolKits.map((toolKit) => ({
1272
- name: toolKit.name,
1273
- description: toolKit.description,
1274
- tools: Object.keys(toolKit.tools),
1324
+ bindings: bindings.map((bindingCollection) => ({
1325
+ name: bindingCollection.name,
1326
+ description: bindingCollection.description,
1327
+ bindings: Object.keys(bindingCollection.bindings),
1275
1328
  })),
1276
1329
  };
1277
1330
  }
1278
- function describeToolkitPayload(toolKits, toolkitName) {
1279
- const toolKit = toolKits.find((kit) => kit.name === toolkitName);
1280
- if (!toolKit) {
1281
- throw new Error(`No toolkit "${toolkitName}". Available: ${toolkitNames(toolKits)}`);
1331
+ function describeBindingsPayload(bindings, collectionName) {
1332
+ const bindingCollection = bindings.find((collection) => collection.name === collectionName);
1333
+ if (!bindingCollection) {
1334
+ throw new Error(`No binding collection "${collectionName}". Available: ${bindingsNames(bindings)}`);
1282
1335
  }
1283
1336
  return {
1284
- name: toolKit.name,
1285
- description: toolKit.description,
1286
- tools: Object.fromEntries(Object.entries(toolKit.tools).map(([toolName, tool]) => [
1287
- toolName,
1337
+ name: bindingCollection.name,
1338
+ description: bindingCollection.description,
1339
+ bindings: Object.fromEntries(Object.entries(bindingCollection.bindings).map(([bindingName, definition]) => [
1340
+ bindingName,
1288
1341
  {
1289
- description: tool.description,
1290
- flags: describeToolFlags(toolToSidecarDefinition(tool).inputSchema),
1342
+ description: definition.description,
1343
+ flags: describeBindingFlags(bindingToSidecarDefinition(definition).inputSchema),
1291
1344
  },
1292
1345
  ])),
1293
1346
  };
1294
1347
  }
1295
- function describeToolPayload(toolKit, toolName) {
1296
- const tool = toolKit.tools[toolName];
1297
- if (!tool) {
1298
- throw new Error(`No tool "${toolName}" in toolkit "${toolKit.name}". Available: ${toolNames(toolKit)}`);
1348
+ function describeBindingPayload(bindingCollection, bindingName) {
1349
+ const definition = bindingCollection.bindings[bindingName];
1350
+ if (!definition) {
1351
+ throw new Error(`No binding "${bindingName}" in collection "${bindingCollection.name}". Available: ${bindingNames(bindingCollection)}`);
1299
1352
  }
1300
1353
  return {
1301
- toolkit: toolKit.name,
1302
- tool: toolName,
1303
- description: tool.description,
1304
- flags: describeToolFlags(toolToSidecarDefinition(tool).inputSchema),
1305
- examples: tool.examples?.map((example) => ({
1354
+ collection: bindingCollection.name,
1355
+ binding: bindingName,
1356
+ description: definition.description,
1357
+ flags: describeBindingFlags(bindingToSidecarDefinition(definition).inputSchema),
1358
+ examples: definition.examples?.map((example) => ({
1306
1359
  description: example.description,
1307
1360
  input: example.input,
1308
1361
  })) ?? [],
1309
1362
  };
1310
1363
  }
1311
- function toolPermissionMode(permissions, callbackKey) {
1312
- const scope = permissions.tool;
1364
+ function bindingPermissionMode(permissions, callbackKey) {
1365
+ const scope = permissions.binding;
1313
1366
  if (!scope) {
1314
1367
  return "deny";
1315
1368
  }
@@ -1343,11 +1396,11 @@ function permissionPatternMatches(pattern, value) {
1343
1396
  .join("");
1344
1397
  return new RegExp(`^${source}$`).test(value);
1345
1398
  }
1346
- function toolkitNames(toolKits) {
1347
- return toolKits.map((toolKit) => toolKit.name).join(", ");
1399
+ function bindingsNames(bindings) {
1400
+ return bindings.map((bindingCollection) => bindingCollection.name).join(", ");
1348
1401
  }
1349
- function toolNames(toolKit) {
1350
- return Object.keys(toolKit.tools).join(", ");
1402
+ function bindingNames(bindingCollection) {
1403
+ return Object.keys(bindingCollection.bindings).join(", ");
1351
1404
  }
1352
1405
  function isHelpFlag(value) {
1353
1406
  return value === "--help" || value === "-h";
@@ -1356,43 +1409,42 @@ function jsonSchemaType(schema) {
1356
1409
  const schemaObject = asRecord(schema);
1357
1410
  return typeof schemaObject.type === "string" ? schemaObject.type : undefined;
1358
1411
  }
1359
- async function registerToolkitsOnSidecar(client, session, vm, toolKits) {
1360
- if (toolKits.length === 0) {
1412
+ async function registerBindingsOnSidecar(client, session, vm, bindings) {
1413
+ if (bindings.length === 0) {
1361
1414
  return "";
1362
1415
  }
1363
- for (const toolKit of toolKits) {
1416
+ for (const bindingCollection of bindings) {
1364
1417
  await client.registerHostCallbacks(session, vm, {
1365
- name: toolKit.name,
1366
- description: toolKit.description,
1367
- commandAliases: [`agentos-${toolKit.name}`],
1418
+ name: bindingCollection.name,
1419
+ description: bindingCollection.description,
1420
+ commandAliases: [`agentos-${bindingCollection.name}`],
1368
1421
  registryCommandAliases: ["agentos"],
1369
- callbacks: Object.fromEntries(Object.entries(toolKit.tools).map(([toolName, tool]) => [
1370
- toolName,
1371
- toolToSidecarDefinition(tool),
1422
+ callbacks: Object.fromEntries(Object.entries(bindingCollection.bindings).map(([bindingName, definition]) => [
1423
+ bindingName,
1424
+ bindingToSidecarDefinition(definition),
1372
1425
  ])),
1373
1426
  });
1374
1427
  }
1375
- return buildHostToolReference(toolKits);
1428
+ return buildBindingReference(bindings);
1376
1429
  }
1377
1430
  export class AgentOs {
1378
1431
  #kernel;
1379
1432
  sidecar;
1380
- _sessions = new Map();
1381
- _closedSessionIds = new BoundedSet(CLOSED_SESSION_ID_RETENTION_LIMIT);
1382
- _sessionClosePromises = new Map();
1383
- _pendingSessionRequestResolvers = new Map();
1433
+ _durableSessionEventHandlers = new Map();
1434
+ _agentExitHandlers = new Map();
1384
1435
  _processes = new Map();
1385
1436
  _shells = new Map();
1437
+ // Value is the recorded exit code (undefined until/unless the exit
1438
+ // resolves) so waitShell can still report it after the entry is dropped.
1386
1439
  _closedShellIds = new BoundedSet(CLOSED_SHELL_ID_RETENTION_LIMIT);
1387
1440
  _pendingShellExitPromises = new Set();
1388
1441
  _shellCounter = 0;
1389
1442
  _acpTerminals = new Map();
1390
1443
  _acpTerminalCounter = 0;
1391
1444
  _softwareRoots;
1392
- _softwareAgentConfigs;
1393
1445
  _cronManager;
1394
- _toolKits = [];
1395
- _toolReference = "";
1446
+ _bindings = [];
1447
+ _bindingReference = "";
1396
1448
  _permissions = allowAll;
1397
1449
  _hostMounts;
1398
1450
  _env;
@@ -1402,17 +1454,22 @@ export class AgentOs {
1402
1454
  _sidecarSession;
1403
1455
  _sidecarVm;
1404
1456
  _disposeSidecarEventListener;
1405
- constructor(kernel, sidecar, softwareRoots, softwareAgentConfigs, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm) {
1457
+ _agentStderrHandler;
1458
+ _agentExitHandler;
1459
+ _limitWarningHandler;
1460
+ constructor(kernel, sidecar, softwareRoots, hostMounts, env, rootFilesystem, sidecarClient, sidecarSession, sidecarVm, agentStderrHandler, agentExitHandler, limitWarningHandler) {
1406
1461
  this.#kernel = kernel;
1407
1462
  this.sidecar = sidecar;
1408
1463
  this._softwareRoots = softwareRoots;
1409
- this._softwareAgentConfigs = softwareAgentConfigs;
1410
1464
  this._hostMounts = hostMounts;
1411
1465
  this._env = env;
1412
1466
  this._rootFilesystem = rootFilesystem;
1413
1467
  this._sidecarClient = sidecarClient;
1414
1468
  this._sidecarSession = sidecarSession;
1415
1469
  this._sidecarVm = sidecarVm;
1470
+ this._agentStderrHandler = agentStderrHandler;
1471
+ this._agentExitHandler = agentExitHandler;
1472
+ this._limitWarningHandler = limitWarningHandler;
1416
1473
  this._disposeSidecarEventListener = this._sidecarClient.onEvent((event) => {
1417
1474
  this._handleSidecarEvent(event);
1418
1475
  });
@@ -1430,92 +1487,130 @@ export class AgentOs {
1430
1487
  return getSharedAgentOsSidecarInternal(options);
1431
1488
  }
1432
1489
  static async create(options) {
1433
- const processed = processSoftware(options?.software ?? []);
1490
+ options = parseAgentOsOptions(options);
1491
+ // Default software is FULLY DYNAMIC: this package's own NON-agent
1492
+ // @agentos-software/* dependencies (e.g. common), each default-exporting
1493
+ // its registry-built descriptor. Agent packages are NOT projected here —
1494
+ // openSession({ agent: id }) links the matching agent dependency into the running
1495
+ // VM on first use, so agent closures (and pi's V8 snapshot bundle) only
1496
+ // enter VMs that run them. Unbuilt packages throw with build
1497
+ // instructions; opt out via defaultSoftware: false.
1498
+ const defaultSoftware = options?.defaultSoftware === false ? [] : resolveDefaultSoftware();
1499
+ const software = options?.defaultSoftware === false
1500
+ ? (options.software ?? [])
1501
+ : [...defaultSoftware, ...(options?.software ?? [])];
1502
+ // Packages are projected by the SIDECAR: the client forwards only the
1503
+ // package `path` over `configureVm` and the sidecar reads metadata from
1504
+ // the packed vbare manifest (chunk1 of the `.aospkg`).
1505
+ const flatSoftware = software.flat();
1506
+ // Honor the AgentOsOptions.defaultSoftware contract ("entries already present
1507
+ // in `software` are not duplicated"): the default bundle and an explicitly
1508
+ // passed one resolve to the same package paths, so dedup by path. Without
1509
+ // this the sidecar rejects the second projection with a duplicate-command
1510
+ // error (e.g. coreutils' `[`).
1511
+ const seenPackagePaths = new Set();
1512
+ const sidecarPackages = flatSoftware.flatMap((entry) => {
1513
+ const ref = normalizePackageRef(entry);
1514
+ if (!ref || seenPackagePaths.has(ref.path)) {
1515
+ return [];
1516
+ }
1517
+ seenPackagePaths.add(ref.path);
1518
+ return [{ path: ref.path }];
1519
+ });
1520
+ // All package software is projected into `/opt/agentos` by the sidecar. The
1521
+ // client stages nothing host-side and parses NO package manifests: the
1522
+ // sidecar owns agent resolution, agent enumeration, and agent snapshot
1523
+ // bundle loading from the projected package dirs.
1434
1524
  const localMounts = await resolveCompatLocalMounts(options?.mounts);
1435
- const toolKits = options?.toolKits;
1436
- if (toolKits && toolKits.length > 0) {
1437
- validateToolkits(toolKits);
1525
+ const bindings = options?.bindings;
1526
+ if (bindings && bindings.length > 0) {
1527
+ validateBindings(bindings);
1438
1528
  }
1529
+ // Resolve the sidecar handle up front so every VM created here leases the
1530
+ // one shared native sidecar process owned by that handle.
1531
+ const sidecar = resolveAgentOsSidecar(options?.sidecar);
1439
1532
  const createVmAdmin = async () => {
1440
- const preparedCommandDirs = prepareCommandDirs(processed.commandPackages);
1441
- const toolBootstrapCommands = collectToolkitBootstrapCommands(toolKits ?? []);
1442
- const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem, [
1443
- ...collectBootstrapWasmCommands(preparedCommandDirs.commandDirs),
1444
- ...NODE_RUNTIME_BOOTSTRAP_COMMANDS,
1445
- ...toolBootstrapCommands,
1446
- ]);
1447
- let toolReference = "";
1533
+ // The `/opt/agentos` projection is built by the sidecar from the
1534
+ // forwarded `packages` (it owns the staging dir + read-only mount, and
1535
+ // runtime `linkSoftware` appends to that live dir). The client no longer
1536
+ // stages packages host-side.
1537
+ const bindingBootstrapCommands = collectBindingBootstrapCommands(bindings ?? []);
1538
+ const bootstrapCommands = [
1539
+ ...RUNTIME_BOOTSTRAP_COMMANDS,
1540
+ ...bindingBootstrapCommands,
1541
+ ];
1542
+ const bootstrapLower = createKernelBootstrapLower(options?.rootFilesystem);
1543
+ let bindingReference = "";
1448
1544
  let rootBridge = null;
1449
1545
  let kernel = null;
1450
1546
  let client = null;
1451
- let toolShimDir = null;
1547
+ let createdNativeVm = null;
1548
+ let nativeSession = null;
1452
1549
  let cleanedUp = false;
1453
1550
  const cleanup = async () => {
1454
1551
  if (cleanedUp) {
1455
1552
  return;
1456
1553
  }
1457
1554
  cleanedUp = true;
1458
- if (toolShimDir) {
1459
- rmSync(toolShimDir, { recursive: true, force: true });
1460
- toolShimDir = null;
1461
- }
1462
- preparedCommandDirs.dispose();
1463
1555
  };
1464
1556
  try {
1465
1557
  const env = getBaseEnvironment();
1466
- if (toolKits && toolKits.length > 0) {
1467
- toolShimDir = materializeToolShimDir(toolKits);
1468
- }
1469
- const commandGuestPaths = collectGuestCommandPaths(preparedCommandDirs.commandDirs);
1470
- const requestedMounts = options?.moduleAccessCwd
1471
- ? [
1472
- ...(options.mounts ?? []),
1473
- {
1474
- path: "/root/node_modules",
1475
- plugin: createHostDirBackend({
1476
- hostPath: join(options.moduleAccessCwd, "node_modules"),
1477
- readOnly: true,
1478
- }),
1479
- readOnly: true,
1480
- },
1481
- ]
1482
- : options?.mounts;
1558
+ // Guest command paths. The sidecar owns the `/opt/agentos` projection and
1559
+ // reports the exact projected package commands after `configureVm`.
1560
+ // Binding-shim commands are added below.
1561
+ const commandGuestPaths = new Map();
1483
1562
  const { sidecarMounts, hostMounts, hostPathMappings } = collectSidecarMountPlan({
1484
- mounts: requestedMounts,
1485
- softwareRoots: processed.softwareRoots,
1486
- commandDirs: preparedCommandDirs.commandDirs,
1487
- shimDir: toolShimDir,
1488
- });
1489
- client = SidecarProcess.spawn({
1490
- cwd: REPO_ROOT,
1491
- command: ensureNativeSidecarBinary(),
1492
- args: [],
1493
- frameTimeoutMs: NATIVE_SIDECAR_FRAME_TIMEOUT_MS,
1563
+ mounts: options?.mounts,
1494
1564
  });
1495
- const session = await client.authenticateAndOpenSession();
1565
+ // Reuse the sidecar handle's single shared native process; this VM
1566
+ // becomes another tenant of it rather than spawning its own process.
1567
+ const shared = await ensureSharedSidecarNativeProcess(sidecar);
1568
+ client = shared.client;
1569
+ const session = shared.session;
1570
+ nativeSession = session;
1496
1571
  const hostPermissions = options?.permissions ?? {
1497
1572
  ...allowAll,
1498
- tool: "allow",
1573
+ binding: "allow",
1499
1574
  };
1500
1575
  const sidecarPermissions = serializePermissionsForSidecar(hostPermissions);
1501
1576
  const createVmConfig = {
1502
1577
  env,
1578
+ database: options?.database,
1579
+ ...(options?.user ? { user: options.user } : {}),
1503
1580
  rootFilesystem: serializeRootFilesystemForSidecar(options?.rootFilesystem, bootstrapLower),
1504
1581
  permissions: sidecarPermissions,
1505
1582
  limits: options?.limits,
1506
1583
  loopbackExemptPorts: options?.loopbackExemptPorts ?? [],
1584
+ bootstrapCommands,
1585
+ ...(options?.rootFilesystem?.type === "native"
1586
+ ? {
1587
+ nativeRoot: {
1588
+ plugin: {
1589
+ id: options.rootFilesystem.plugin.id,
1590
+ config: options.rootFilesystem.plugin.config ?? {},
1591
+ },
1592
+ readOnly: options.rootFilesystem.readOnly ?? false,
1593
+ },
1594
+ }
1595
+ : {}),
1507
1596
  // 0.3: the Node builtin allow-list moved from configureVm to
1508
1597
  // VM creation. `undefined` => engine default allow-list;
1509
1598
  // `[]` => deny all; `[..]` => exactly those. Platform and
1510
1599
  // module resolution keep their engine defaults (full Node
1511
1600
  // emulation), matching the prior behavior where Agent OS only
1512
1601
  // constrained the builtin allow-list.
1513
- ...(options?.allowedNodeBuiltins !== undefined
1602
+ ...(options?.allowedNodeBuiltins !== undefined ||
1603
+ options?.highResolutionTime !== undefined
1514
1604
  ? {
1515
1605
  jsRuntime: {
1516
1606
  platform: "node",
1517
1607
  moduleResolution: "node",
1518
- allowedBuiltins: options.allowedNodeBuiltins,
1608
+ ...(options?.allowedNodeBuiltins !== undefined
1609
+ ? { allowedBuiltins: options.allowedNodeBuiltins }
1610
+ : {}),
1611
+ ...(options?.highResolutionTime !== undefined
1612
+ ? { highResolutionTime: options.highResolutionTime }
1613
+ : {}),
1519
1614
  },
1520
1615
  }
1521
1616
  : {}),
@@ -1524,19 +1619,30 @@ export class AgentOs {
1524
1619
  runtime: "java_script",
1525
1620
  config: createVmConfig,
1526
1621
  });
1622
+ createdNativeVm = nativeVm;
1623
+ // Scope the readiness wait to THIS VM's ownership; on a shared process
1624
+ // other VMs are emitting their own lifecycle events concurrently.
1527
1625
  await client.waitForEvent((event) => event.payload.type === "vm_lifecycle" &&
1528
- event.payload.state === "ready", 10_000);
1529
- await client.configureVm(session, nativeVm, {
1626
+ event.payload.state === "ready" &&
1627
+ event.ownership.scope === "vm" &&
1628
+ event.ownership.vm_id === nativeVm.vmId, 10_000);
1629
+ const configuredVm = await client.configureVm(session, nativeVm, {
1530
1630
  mounts: sidecarMounts,
1531
1631
  permissions: sidecarPermissions,
1532
- commandPermissions: processed.commandPermissions,
1632
+ commandPermissions: {},
1533
1633
  loopbackExemptPorts: options?.loopbackExemptPorts,
1634
+ packages: sidecarPackages,
1635
+ packagesMountAt: OPT_AGENTOS_ROOT,
1636
+ bindingShimCommands: bindingBootstrapCommands,
1534
1637
  });
1535
- if (toolKits && toolKits.length > 0) {
1536
- toolReference = await registerToolkitsOnSidecar(client, session, nativeVm, toolKits);
1638
+ for (const command of configuredVm.projectedCommands) {
1639
+ commandGuestPaths.set(command.name, command.guestPath);
1640
+ }
1641
+ if (bindings && bindings.length > 0) {
1642
+ bindingReference = await registerBindingsOnSidecar(client, session, nativeVm, bindings);
1537
1643
  commandGuestPaths.set("agentos", "/bin/agentos");
1538
- for (const toolKit of toolKits) {
1539
- commandGuestPaths.set(`agentos-${toolKit.name}`, `/bin/agentos-${toolKit.name}`);
1644
+ for (const bindingCollection of bindings) {
1645
+ commandGuestPaths.set(`agentos-${bindingCollection.name}`, `/bin/agentos-${bindingCollection.name}`);
1540
1646
  }
1541
1647
  }
1542
1648
  rootBridge = new NativeSidecarKernelProxy({
@@ -1544,12 +1650,27 @@ export class AgentOs {
1544
1650
  session,
1545
1651
  vm: nativeVm,
1546
1652
  env,
1547
- cwd: "/home/user",
1653
+ cwd: "/workspace",
1548
1654
  localMounts,
1655
+ sidecarMounts,
1656
+ permissions: sidecarPermissions,
1657
+ commandPermissions: {},
1658
+ loopbackExemptPorts: options?.loopbackExemptPorts,
1659
+ // Retained for runtime mount reconfigures: `configure_vm` is
1660
+ // replace-on-write for the whole payload, so post-boot mountFs
1661
+ // must resend the boot packages and binding shims.
1662
+ packages: sidecarPackages,
1663
+ packagesMountAt: OPT_AGENTOS_ROOT,
1664
+ bindingShimCommands: bindingBootstrapCommands,
1549
1665
  commandGuestPaths,
1550
1666
  onDispose: cleanup,
1667
+ // The native process is owned by the AgentOsSidecar handle and
1668
+ // shared across VMs; disposing this VM must not kill the process.
1669
+ ownsClient: false,
1551
1670
  });
1552
- await bootstrapLiveBootstrapDirectories(client, session, nativeVm, options?.rootFilesystem);
1671
+ if (options?.rootFilesystem?.type !== "native") {
1672
+ await bootstrapLiveBootstrapDirectories(client, session, nativeVm, options?.rootFilesystem);
1673
+ }
1553
1674
  kernel = rootBridge;
1554
1675
  const snapshotClient = client;
1555
1676
  return {
@@ -1557,13 +1678,17 @@ export class AgentOs {
1557
1678
  hostMounts,
1558
1679
  kernel,
1559
1680
  rootView: rootBridge.createRootView(),
1681
+ sidecarMounts,
1682
+ sidecarPermissions,
1683
+ commandPermissions: {},
1684
+ loopbackExemptPorts: options?.loopbackExemptPorts,
1560
1685
  sidecarClient: client,
1561
1686
  sidecarSession: session,
1562
1687
  sidecarVm: nativeVm,
1563
1688
  permissions: hostPermissions,
1564
- snapshotRootFilesystem: async () => createSnapshotExport(convertSidecarRootSnapshotEntries(await snapshotClient.snapshotRootFilesystem(session, nativeVm))),
1565
- toolKits: toolKits ?? [],
1566
- toolReference,
1689
+ snapshotRootFilesystem: async (maxBytes) => createSnapshotExport(convertSidecarRootSnapshotEntries(await snapshotClient.snapshotRootFilesystem(session, nativeVm, maxBytes))),
1690
+ bindings: bindings ?? [],
1691
+ bindingReference,
1567
1692
  async dispose() {
1568
1693
  if (kernel) {
1569
1694
  const currentKernel = kernel;
@@ -1581,6 +1706,8 @@ export class AgentOs {
1581
1706
  };
1582
1707
  }
1583
1708
  catch (error) {
1709
+ // The native process is shared and owned by the sidecar handle, so
1710
+ // never dispose the client here — only tear down this VM's resources.
1584
1711
  if (kernel) {
1585
1712
  await kernel.dispose().catch(() => { });
1586
1713
  }
@@ -1588,23 +1715,26 @@ export class AgentOs {
1588
1715
  await rootBridge.dispose().catch(() => { });
1589
1716
  }
1590
1717
  else {
1591
- await client?.dispose().catch(() => { });
1718
+ if (createdNativeVm && nativeSession && client) {
1719
+ await client
1720
+ .disposeVm(nativeSession, createdNativeVm)
1721
+ .catch(() => { });
1722
+ }
1592
1723
  await cleanup();
1593
1724
  }
1594
1725
  throw error;
1595
1726
  }
1596
1727
  };
1597
- const sidecar = resolveAgentOsSidecar(options?.sidecar);
1598
1728
  let sidecarLease = null;
1599
1729
  try {
1600
1730
  sidecarLease = await leaseAgentOsSidecarVm(sidecar, {
1601
1731
  createVm: async () => createVmAdmin(),
1602
1732
  });
1603
1733
  const vmAdmin = sidecarLease.admin;
1604
- const vm = new AgentOs(vmAdmin.kernel, sidecar, processed.softwareRoots, processed.agentConfigs, vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm);
1734
+ const vm = new AgentOs(vmAdmin.kernel, sidecar, [], vmAdmin.hostMounts, vmAdmin.env, vmAdmin.rootView, vmAdmin.sidecarClient, vmAdmin.sidecarSession, vmAdmin.sidecarVm, options?.onAgentStderr ?? defaultAgentStderrHandler, options?.onAgentExit ?? defaultAgentExitHandler, options?.onLimitWarning);
1605
1735
  vm._sidecarLease = sidecarLease;
1606
- vm._toolKits = vmAdmin.toolKits;
1607
- vm._toolReference = vmAdmin.toolReference;
1736
+ vm._bindings = vmAdmin.bindings;
1737
+ vm._bindingReference = vmAdmin.bindingReference;
1608
1738
  vm._permissions = vmAdmin.permissions;
1609
1739
  vm._installSidecarRequestHandler();
1610
1740
  vm._cronManager = new CronManager(vm, options?.scheduleDriver ?? new TimerScheduleDriver());
@@ -1622,76 +1752,66 @@ export class AgentOs {
1622
1752
  const kernel = this.#kernel;
1623
1753
  return kernel.execArgv(command, args, options);
1624
1754
  }
1625
- _trackProcess(proc, command, args, stdoutHandlers, stderrHandlers, exitHandlers) {
1755
+ _trackProcess(proc, command, args, outputHandlers, exitHandlers) {
1626
1756
  const entry = {
1627
1757
  proc,
1628
1758
  command,
1629
1759
  args,
1630
- stdoutHandlers,
1631
- stderrHandlers,
1760
+ outputHandlers,
1632
1761
  exitHandlers,
1633
1762
  };
1634
1763
  this._processes.set(proc.pid, entry);
1764
+ // NOTE: do NOT delete from `_processes` on exit — the public API contract
1765
+ // (getProcess/listProcesses/stopProcess, see process-management.test.ts)
1766
+ // requires exited processes to stay queryable (running:false, exitCode set).
1767
+ // `_processes` is a process table for this VM's lifetime; it is freed wholesale
1768
+ // in dispose(). (H5: the leak was that dispose() never cleared it.)
1635
1769
  void proc.wait().then((code) => {
1636
1770
  for (const h of exitHandlers)
1637
- h(code);
1771
+ h({ pid: proc.pid, exitCode: code });
1638
1772
  });
1639
1773
  return { pid: proc.pid };
1640
1774
  }
1641
1775
  spawn(command, args, options) {
1642
- const stdoutHandlers = new Set();
1643
- const stderrHandlers = new Set();
1776
+ const outputHandlers = new Set();
1644
1777
  const exitHandlers = new Set();
1645
- // Include caller-provided callbacks in the initial handler sets.
1646
- if (options?.onStdout)
1647
- stdoutHandlers.add(options.onStdout);
1648
- if (options?.onStderr)
1649
- stderrHandlers.add(options.onStderr);
1650
1778
  const proc = this.#kernel.spawn(command, args, {
1651
1779
  ...options,
1652
1780
  onStdout: (data) => {
1653
- for (const h of stdoutHandlers)
1654
- h(data);
1781
+ for (const h of outputHandlers) {
1782
+ h({ pid: proc.pid, stream: "stdout", data });
1783
+ }
1655
1784
  },
1656
1785
  onStderr: (data) => {
1657
- for (const h of stderrHandlers)
1658
- h(data);
1786
+ for (const h of outputHandlers) {
1787
+ h({ pid: proc.pid, stream: "stderr", data });
1788
+ }
1659
1789
  },
1660
1790
  });
1661
- return this._trackProcess(proc, command, args, stdoutHandlers, stderrHandlers, exitHandlers);
1791
+ return this._trackProcess(proc, command, args, outputHandlers, exitHandlers);
1662
1792
  }
1663
1793
  /** Write data to a process's stdin. */
1664
1794
  writeProcessStdin(pid, data) {
1665
1795
  const entry = this._processes.get(pid);
1666
1796
  if (!entry)
1667
1797
  throw new Error(`Process not found: ${pid}`);
1668
- entry.proc.writeStdin(data);
1798
+ return entry.proc.writeStdin(data);
1669
1799
  }
1670
1800
  /** Close a process's stdin stream. */
1671
1801
  closeProcessStdin(pid) {
1672
1802
  const entry = this._processes.get(pid);
1673
1803
  if (!entry)
1674
1804
  throw new Error(`Process not found: ${pid}`);
1675
- entry.proc.closeStdin();
1676
- }
1677
- /** Subscribe to stdout data from a process. Returns an unsubscribe function. */
1678
- onProcessStdout(pid, handler) {
1679
- const entry = this._processes.get(pid);
1680
- if (!entry)
1681
- throw new Error(`Process not found: ${pid}`);
1682
- entry.stdoutHandlers.add(handler);
1683
- return () => {
1684
- entry.stdoutHandlers.delete(handler);
1685
- };
1805
+ return entry.proc.closeStdin();
1686
1806
  }
1687
- /** Subscribe to stderr data from a process. Returns an unsubscribe function. */
1688
- onProcessStderr(pid, handler) {
1807
+ /** Subscribe to stdout and stderr from a process. */
1808
+ onProcessOutput(pid, handler) {
1689
1809
  const entry = this._processes.get(pid);
1690
1810
  if (!entry)
1691
1811
  throw new Error(`Process not found: ${pid}`);
1692
- entry.stderrHandlers.add(handler);
1812
+ entry.outputHandlers.add(handler);
1693
1813
  return () => {
1694
- entry.stderrHandlers.delete(handler);
1814
+ entry.outputHandlers.delete(handler);
1695
1815
  };
1696
1816
  }
1697
1817
  /** Subscribe to process exit. Returns an unsubscribe function. */
@@ -1701,7 +1821,7 @@ export class AgentOs {
1701
1821
  throw new Error(`Process not found: ${pid}`);
1702
1822
  // If already exited, call immediately.
1703
1823
  if (entry.proc.exitCode !== null) {
1704
- handler(entry.proc.exitCode);
1824
+ handler({ pid, exitCode: entry.proc.exitCode });
1705
1825
  return () => { };
1706
1826
  }
1707
1827
  entry.exitHandlers.add(handler);
@@ -1733,35 +1853,6 @@ export class AgentOs {
1733
1853
  _vfs() {
1734
1854
  return this.#kernel.vfs;
1735
1855
  }
1736
- async _copyPath(from, to) {
1737
- const stat = await this._vfs().lstat(from);
1738
- if (stat.isSymbolicLink) {
1739
- const target = await this._vfs().readlink(from);
1740
- await this._vfs().symlink(target, to);
1741
- return;
1742
- }
1743
- if (stat.isDirectory) {
1744
- await this._mkdirp(posixPath.dirname(to));
1745
- if (!(await this.#kernel.exists(to))) {
1746
- await this.#kernel.mkdir(to);
1747
- }
1748
- await this._vfs().chmod(to, stat.mode);
1749
- await this._vfs().chown(to, stat.uid, stat.gid);
1750
- const entries = await this.#kernel.readdir(from);
1751
- for (const entry of entries) {
1752
- if (entry === "." || entry === "..")
1753
- continue;
1754
- const fromPath = from === "/" ? `/${entry}` : `${from}/${entry}`;
1755
- const toPath = to === "/" ? `/${entry}` : `${to}/${entry}`;
1756
- await this._copyPath(fromPath, toPath);
1757
- }
1758
- return;
1759
- }
1760
- const content = await this.#kernel.readFile(from);
1761
- await this.writeFile(to, content);
1762
- await this._vfs().chmod(to, stat.mode);
1763
- await this._vfs().chown(to, stat.uid, stat.gid);
1764
- }
1765
1856
  async readFile(path) {
1766
1857
  this._assertSafeAbsolutePath(path);
1767
1858
  return this.#kernel.readFile(path);
@@ -1814,14 +1905,13 @@ export class AgentOs {
1814
1905
  /** Recursively create directories (mkdir -p). */
1815
1906
  async _mkdirp(path) {
1816
1907
  this._assertWritableAbsolutePath(path);
1817
- const parts = path.split("/").filter(Boolean);
1818
- let current = "";
1819
- for (const part of parts) {
1820
- current += `/${part}`;
1821
- if (!(await this.#kernel.exists(current))) {
1822
- await this.#kernel.mkdir(current);
1823
- }
1824
- }
1908
+ // `kernel.mkdir` is already recursive (it defaults to recursive=true on both
1909
+ // the native sidecar and compat kernels) and creating an existing directory is
1910
+ // a no-op, so a single call is sufficient. Do NOT probe each ancestor with
1911
+ // `exists()` first: on the native sidecar every read-side op
1912
+ // (exists/stat/readFile) triggers a full shadow-tree walk, so a per-component
1913
+ // exists() loop makes `mkdir -p` cost O(components * tree).
1914
+ await this.#kernel.mkdir(path);
1825
1915
  }
1826
1916
  async mkdir(path, options) {
1827
1917
  if (options?.recursive) {
@@ -1834,51 +1924,42 @@ export class AgentOs {
1834
1924
  this._assertSafeAbsolutePath(path);
1835
1925
  return this.#kernel.readdir(path);
1836
1926
  }
1927
+ async readdirEntries(path) {
1928
+ this._assertSafeAbsolutePath(path);
1929
+ const entries = await this.#kernel.readdirRecursive(path, { maxDepth: 0 });
1930
+ return entries.map((entry) => ({
1931
+ name: entry.name,
1932
+ isDirectory: entry.isDirectory,
1933
+ isSymbolicLink: entry.isSymbolicLink,
1934
+ }));
1935
+ }
1837
1936
  async readdirRecursive(path, options) {
1838
1937
  this._assertSafeAbsolutePath(path);
1839
- const maxDepth = options?.maxDepth;
1840
1938
  const exclude = options?.exclude ? new Set(options.exclude) : undefined;
1939
+ const entries = await this.#kernel.readdirRecursive(path, {
1940
+ maxDepth: options?.maxDepth,
1941
+ });
1942
+ const excludedPrefixes = [];
1841
1943
  const results = [];
1842
- // BFS queue: [dirPath, currentDepth]
1843
- const queue = [[path, 0]];
1844
- while (queue.length > 0) {
1845
- const item = queue.shift();
1846
- if (!item)
1847
- break;
1848
- const [dirPath, depth] = item;
1849
- const entries = await this.#kernel.readdir(dirPath);
1850
- for (const name of entries) {
1851
- if (name === "." || name === "..")
1852
- continue;
1853
- if (exclude?.has(name))
1854
- continue;
1855
- const fullPath = dirPath === "/" ? `/${name}` : `${dirPath}/${name}`;
1856
- const s = await this.#kernel.stat(fullPath);
1857
- if (s.isSymbolicLink) {
1858
- results.push({
1859
- path: fullPath,
1860
- type: "symlink",
1861
- size: s.size,
1862
- });
1863
- }
1864
- else if (s.isDirectory) {
1865
- results.push({
1866
- path: fullPath,
1867
- type: "directory",
1868
- size: s.size,
1869
- });
1870
- if (maxDepth === undefined || depth < maxDepth) {
1871
- queue.push([fullPath, depth + 1]);
1872
- }
1873
- }
1874
- else {
1875
- results.push({
1876
- path: fullPath,
1877
- type: "file",
1878
- size: s.size,
1879
- });
1944
+ for (const entry of entries) {
1945
+ if (excludedPrefixes.some((prefix) => entry.path === prefix || entry.path.startsWith(`${prefix}/`))) {
1946
+ continue;
1947
+ }
1948
+ if (exclude?.has(entry.name)) {
1949
+ if (entry.isDirectory && !entry.isSymbolicLink) {
1950
+ excludedPrefixes.push(entry.path);
1880
1951
  }
1952
+ continue;
1881
1953
  }
1954
+ results.push({
1955
+ path: entry.path,
1956
+ type: entry.isSymbolicLink
1957
+ ? "symlink"
1958
+ : entry.isDirectory
1959
+ ? "directory"
1960
+ : "file",
1961
+ size: entry.size,
1962
+ });
1882
1963
  }
1883
1964
  return results;
1884
1965
  }
@@ -1890,89 +1971,133 @@ export class AgentOs {
1890
1971
  this._assertSafeAbsolutePath(path);
1891
1972
  return this.#kernel.exists(path);
1892
1973
  }
1893
- async snapshotRootFilesystem() {
1974
+ async exportRootFilesystem(options) {
1975
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes <= 0) {
1976
+ throw new RangeError("maxBytes must be a positive safe integer");
1977
+ }
1894
1978
  const nativeSnapshot = this._sidecarLease?.admin.snapshotRootFilesystem;
1895
- if (nativeSnapshot) {
1896
- return nativeSnapshot();
1979
+ const snapshot = nativeSnapshot
1980
+ ? await nativeSnapshot(options.maxBytes)
1981
+ : createSnapshotExport(await snapshotVirtualFilesystem(this._rootFilesystem));
1982
+ const size = Buffer.byteLength(JSON.stringify(snapshot));
1983
+ if (size > options.maxBytes) {
1984
+ throw new RangeError(`root filesystem export is ${size} bytes, limit is ${options.maxBytes}; raise maxBytes to export this filesystem`);
1897
1985
  }
1898
- return createSnapshotExport(await snapshotVirtualFilesystem(this._rootFilesystem));
1986
+ return snapshot;
1899
1987
  }
1900
- mountFs(path, driver, options) {
1901
- this._assertSafeAbsolutePath(path);
1902
- this.#kernel.mountFs(path, driver, { readOnly: options?.readOnly });
1988
+ /**
1989
+ * Mount a filesystem into the running VM. Resolves once the mount has been
1990
+ * delivered to the native sidecar, so guest code can use it immediately
1991
+ * after the returned promise settles; a delivery failure rejects instead of
1992
+ * leaving the mount silently host-only.
1993
+ */
1994
+ async mountFs(descriptor) {
1995
+ this._assertSafeAbsolutePath(descriptor.path);
1996
+ if (!(this.#kernel instanceof NativeSidecarKernelProxy)) {
1997
+ throw new Error("portable dynamic mounts require the native sidecar");
1998
+ }
1999
+ await this.#kernel.mountDescriptor({
2000
+ guestPath: descriptor.path,
2001
+ readOnly: descriptor.readOnly ?? false,
2002
+ plugin: {
2003
+ id: descriptor.plugin.id,
2004
+ config: descriptor.plugin.config ?? {},
2005
+ },
2006
+ });
1903
2007
  }
1904
- unmountFs(path) {
2008
+ async unmountFs(path) {
1905
2009
  this._assertSafeAbsolutePath(path);
1906
- this.#kernel.unmountFs(path);
2010
+ if (!(this.#kernel instanceof NativeSidecarKernelProxy)) {
2011
+ throw new Error("portable dynamic mounts require the native sidecar");
2012
+ }
2013
+ await this.#kernel.unmountDescriptor(path);
2014
+ }
2015
+ async listMounts() {
2016
+ if (!(this.#kernel instanceof NativeSidecarKernelProxy))
2017
+ return [];
2018
+ return this.#kernel.listMounts();
1907
2019
  }
1908
2020
  async move(from, to) {
1909
- this._assertSafeAbsolutePath(from);
1910
- this._assertSafeAbsolutePath(to);
1911
- const sourceStat = await this._vfs().lstat(from);
1912
- if (!sourceStat.isDirectory || sourceStat.isSymbolicLink) {
1913
- return this.#kernel.rename(from, to);
1914
- }
1915
- await this._copyPath(from, to);
1916
- await this.delete(from, { recursive: true });
2021
+ this._assertWritableAbsolutePath(from);
2022
+ this._assertWritableAbsolutePath(to);
2023
+ await this.#kernel.movePath(from, to);
1917
2024
  }
1918
- async delete(path, options) {
1919
- this._assertSafeAbsolutePath(path);
1920
- const s = await this.#kernel.stat(path);
1921
- if (s.isDirectory) {
1922
- if (options?.recursive) {
1923
- const entries = await this.#kernel.readdir(path);
1924
- for (const entry of entries) {
1925
- if (entry === "." || entry === "..")
1926
- continue;
1927
- await this.delete(`${path}/${entry}`, { recursive: true });
1928
- }
1929
- }
1930
- return this.#kernel.removeDir(path);
1931
- }
1932
- return this.#kernel.removeFile(path);
2025
+ async remove(path, options) {
2026
+ this._assertWritableAbsolutePath(path);
2027
+ await this.#kernel.removePath(path, {
2028
+ recursive: options?.recursive ?? false,
2029
+ });
1933
2030
  }
1934
- async fetch(port, request) {
1935
- const url = new URL(request.url);
2031
+ async httpRequest(request) {
2032
+ this._assertSafeAbsolutePath(request.path.split("?")[0] || "/");
2033
+ const method = request.method?.toUpperCase() ?? "GET";
2034
+ const body = typeof request.body === "string"
2035
+ ? request.body
2036
+ : request.body === undefined
2037
+ ? undefined
2038
+ : new TextDecoder().decode(request.body);
1936
2039
  const responsePayload = JSON.parse(await this._sidecarClient.vmFetch(this._sidecarSession, this._sidecarVm, {
1937
- port,
1938
- method: request.method,
1939
- path: `${url.pathname}${url.search}`,
1940
- headersJson: JSON.stringify(Object.fromEntries(request.headers.entries())),
1941
- ...(request.method !== "GET" && request.method !== "HEAD"
1942
- ? { body: await request.text() }
2040
+ port: request.port,
2041
+ method,
2042
+ path: request.path,
2043
+ headersJson: JSON.stringify(request.headers ?? {}),
2044
+ ...(method !== "GET" && method !== "HEAD" && body !== undefined
2045
+ ? { body }
1943
2046
  : {}),
1944
2047
  }));
1945
- const headers = new Headers();
2048
+ const headers = {};
1946
2049
  for (const [key, value] of responsePayload.headers ?? []) {
1947
- headers.append(key, value);
2050
+ headers[key] = value;
1948
2051
  }
1949
- return new Response(Buffer.from(responsePayload.body ?? "", "base64"), {
2052
+ return {
1950
2053
  status: responsePayload.status,
1951
- statusText: responsePayload.statusText,
2054
+ statusText: responsePayload.statusText ?? "",
1952
2055
  headers,
1953
- });
2056
+ body: new Uint8Array(Buffer.from(responsePayload.body ?? "", "base64")),
2057
+ };
1954
2058
  }
1955
2059
  openShell(options) {
1956
2060
  const shellId = `shell-${++this._shellCounter}`;
1957
2061
  this._closedShellIds.delete(shellId);
1958
2062
  const dataHandlers = new Set();
1959
- const handle = this.#kernel.openShell(options);
2063
+ const stderrHandlers = new Set();
2064
+ const exitHandlers = new Set();
2065
+ const handle = this.#kernel.openShell({
2066
+ ...options,
2067
+ onStderr: (data) => {
2068
+ for (const handler of stderrHandlers)
2069
+ handler({ shellId, data });
2070
+ },
2071
+ });
1960
2072
  handle.onData = (data) => {
1961
- for (const h of dataHandlers)
1962
- h(data);
2073
+ for (const handler of dataHandlers)
2074
+ handler({ shellId, data });
1963
2075
  };
1964
2076
  const entry = {
1965
2077
  handle,
1966
2078
  dataHandlers,
1967
- exitPromise: Promise.resolve(),
2079
+ stderrHandlers,
2080
+ exitHandlers,
2081
+ exitPromise: Promise.resolve(0),
1968
2082
  };
1969
- const exitPromise = handle.wait().then(() => undefined, () => undefined);
1970
- entry.exitPromise = exitPromise.finally(() => {
2083
+ const exitPromise = handle.wait();
2084
+ const finalize = (exitCode) => {
1971
2085
  this._pendingShellExitPromises.delete(entry.exitPromise);
1972
2086
  if (this._shells.get(shellId) === entry) {
1973
2087
  this._shells.delete(shellId);
1974
- this._closedShellIds.add(shellId);
1975
2088
  }
2089
+ // Record the exit code even when closeShell already dropped the
2090
+ // entry, so a waitShell issued after exit still resolves with it.
2091
+ this._closedShellIds.add(shellId, exitCode);
2092
+ };
2093
+ entry.exitPromise = exitPromise.then((exitCode) => {
2094
+ finalize(exitCode);
2095
+ for (const handler of exitHandlers)
2096
+ handler({ shellId, exitCode });
2097
+ return exitCode;
2098
+ }, (error) => {
2099
+ finalize();
2100
+ throw error;
1976
2101
  });
1977
2102
  this._pendingShellExitPromises.add(entry.exitPromise);
1978
2103
  this._shells.set(shellId, entry);
@@ -1986,9 +2111,13 @@ export class AgentOs {
1986
2111
  const entry = this._shells.get(shellId);
1987
2112
  if (!entry)
1988
2113
  throw new Error(`Shell not found: ${shellId}`);
1989
- entry.handle.write(data);
2114
+ return entry.handle.write(data);
1990
2115
  }
1991
- /** Subscribe to data output from a shell. Returns an unsubscribe function. */
2116
+ /**
2117
+ * Subscribe to ordered PTY output (stdout and stderr). Returns an unsubscribe
2118
+ * function. `OpenShellOptions.onStderr` is a diagnostic tap for callers that
2119
+ * need channel identity; do not render both surfaces.
2120
+ */
1992
2121
  onShellData(shellId, handler) {
1993
2122
  const entry = this._shells.get(shellId);
1994
2123
  if (!entry)
@@ -1998,6 +2127,28 @@ export class AgentOs {
1998
2127
  entry.dataHandlers.delete(handler);
1999
2128
  };
2000
2129
  }
2130
+ /** Subscribe to the stderr-only diagnostic stream for a shell. */
2131
+ onShellStderr(shellId, handler) {
2132
+ const entry = this._shells.get(shellId);
2133
+ if (!entry)
2134
+ throw new Error(`Shell not found: ${shellId}`);
2135
+ entry.stderrHandlers.add(handler);
2136
+ return () => entry.stderrHandlers.delete(handler);
2137
+ }
2138
+ /** Subscribe to shell exit. */
2139
+ onShellExit(shellId, handler) {
2140
+ const entry = this._shells.get(shellId);
2141
+ if (!entry) {
2142
+ const exitCode = this._closedShellIds.get(shellId);
2143
+ if (exitCode !== undefined) {
2144
+ handler({ shellId, exitCode });
2145
+ return () => { };
2146
+ }
2147
+ throw new Error(`Shell not found: ${shellId}`);
2148
+ }
2149
+ entry.exitHandlers.add(handler);
2150
+ return () => entry.exitHandlers.delete(handler);
2151
+ }
2001
2152
  /** Notify a shell of terminal resize. */
2002
2153
  resizeShell(shellId, cols, rows) {
2003
2154
  const entry = this._shells.get(shellId);
@@ -2005,6 +2156,21 @@ export class AgentOs {
2005
2156
  throw new Error(`Shell not found: ${shellId}`);
2006
2157
  entry.handle.resize(cols, rows);
2007
2158
  }
2159
+ /**
2160
+ * Wait for a shell to exit and return its process exit code. Resolves
2161
+ * immediately for a shell that has already exited (within the closed-shell
2162
+ * retention window).
2163
+ */
2164
+ waitShell(shellId) {
2165
+ const entry = this._shells.get(shellId);
2166
+ if (!entry) {
2167
+ const exitCode = this._closedShellIds.get(shellId);
2168
+ if (exitCode !== undefined)
2169
+ return Promise.resolve(exitCode);
2170
+ throw new Error(`Shell not found: ${shellId}`);
2171
+ }
2172
+ return entry.exitPromise;
2173
+ }
2008
2174
  /** Kill a shell process and remove it from tracking. */
2009
2175
  closeShell(shellId) {
2010
2176
  const entry = this._shells.get(shellId);
@@ -2104,609 +2270,406 @@ export class AgentOs {
2104
2270
  return;
2105
2271
  entry.proc.kill(9);
2106
2272
  }
2107
- /** Returns all active sessions with their IDs and agent types. */
2108
- listSessions() {
2109
- return [...this._sessions.values()].map((s) => ({
2110
- sessionId: s.sessionId,
2111
- agentType: s.agentType,
2112
- }));
2113
- }
2114
- /** Internal helper: retrieve a session or throw. */
2115
- _requireSession(sessionId) {
2116
- const session = this._sessions.get(sessionId);
2117
- if (!session) {
2118
- throw new Error(`Session not found: ${sessionId}`);
2119
- }
2120
- return session;
2121
- }
2122
- /** Returns all registered agents with their installation status. */
2123
- listAgents() {
2124
- // Collect all agent IDs from both package configs and hardcoded configs.
2125
- const allIds = new Set([
2126
- ...this._softwareAgentConfigs.keys(),
2127
- ...Object.keys(AGENT_CONFIGS),
2128
- ]);
2129
- return [...allIds]
2130
- .map((id) => {
2131
- const config = this._resolveAgentConfig(id);
2132
- if (!config)
2133
- return null;
2134
- let installed = false;
2135
- try {
2136
- // Check software roots first, then the host dir behind the
2137
- // `/root/node_modules` mount.
2138
- const vmPrefix = `/root/node_modules/${config.acpAdapter}`;
2139
- let hostPkgJsonPath = null;
2140
- for (const root of this._softwareRoots) {
2141
- if (root.vmPath === vmPrefix) {
2142
- hostPkgJsonPath = join(root.hostPath, "package.json");
2143
- break;
2144
- }
2145
- }
2146
- if (!hostPkgJsonPath) {
2147
- const nodeModulesRoot = this._nodeModulesHostRoot();
2148
- if (nodeModulesRoot) {
2149
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, config.acpAdapter), "package.json");
2150
- }
2151
- }
2152
- if (!hostPkgJsonPath) {
2153
- throw new Error("no package source");
2154
- }
2155
- readFileSync(hostPkgJsonPath);
2156
- installed = true;
2157
- }
2158
- catch {
2159
- // Package not installed
2160
- }
2161
- return {
2162
- id,
2163
- acpAdapter: config.acpAdapter,
2164
- agentPackage: config.agentPackage,
2165
- installed,
2166
- };
2167
- })
2168
- .filter((entry) => entry !== null);
2169
- }
2170
- _syncSessionState(session, state) {
2171
- session.processId = state.processId;
2172
- session.pid = state.pid ?? null;
2173
- session.closed = state.closed;
2174
- session.modes = toSessionModes(state.modes);
2175
- session.configOptions = toSessionConfigOptions(state.configOptions);
2176
- this._applySyntheticConfigOverrides(session);
2177
- session.capabilities = toAgentCapabilities(state.agentCapabilities);
2178
- session.agentInfo = toAgentInfo(state.agentInfo);
2179
- }
2180
- _applySessionUpdate(session, notification) {
2181
- if (notification.method !== "session/update") {
2182
- return;
2183
- }
2184
- const params = toRecord(notification.params);
2185
- const update = toRecord(params.update ?? params);
2186
- const sessionUpdate = update.sessionUpdate;
2187
- if (sessionUpdate === "current_mode_update" &&
2188
- typeof update.currentModeId === "string" &&
2189
- session.modes) {
2190
- session.modes = {
2191
- ...session.modes,
2192
- currentModeId: update.currentModeId,
2193
- };
2194
- }
2195
- if ((sessionUpdate === "config_option_update" ||
2196
- sessionUpdate === "config_options_update") &&
2197
- Array.isArray(update.configOptions)) {
2198
- session.configOptions = update.configOptions;
2199
- }
2200
- }
2201
- _recordSessionNotification(session, notification) {
2202
- this._applySessionUpdate(session, notification);
2203
- if (shouldDispatchToSessionEventHandlers(notification)) {
2204
- this._dispatchSessionEvent(session, notification);
2205
- }
2206
- if (notification.method === LEGACY_PERMISSION_METHOD ||
2207
- notification.method === ACP_PERMISSION_METHOD) {
2208
- const params = toRecord(notification.params);
2209
- const permissionId = params.permissionId;
2210
- if (typeof permissionId === "string" ||
2211
- typeof permissionId === "number") {
2212
- const request = {
2213
- permissionId: String(permissionId),
2214
- description: typeof params.description === "string"
2215
- ? params.description
2216
- : undefined,
2217
- params,
2218
- };
2219
- for (const handler of session.permissionHandlers) {
2220
- handler(request);
2221
- }
2222
- }
2273
+ async openSession(input) {
2274
+ const response = await this._sendAcpRequest({
2275
+ tag: "AcpOpenSessionRequest",
2276
+ val: {
2277
+ sessionId: input.sessionId ?? null,
2278
+ agent: input.agent,
2279
+ cwd: input.cwd ?? null,
2280
+ additionalDirectories: input.additionalDirectories === undefined
2281
+ ? null
2282
+ : JSON.stringify(input.additionalDirectories),
2283
+ env: input.env === undefined ? null : JSON.stringify(input.env),
2284
+ mcpServers: input.mcpServers === undefined
2285
+ ? null
2286
+ : JSON.stringify(input.mcpServers),
2287
+ permissionPolicy: input.permissionPolicy ?? null,
2288
+ skipOsInstructions: input.skipOsInstructions ?? null,
2289
+ additionalInstructions: combineInstructions(input.additionalInstructions, this._bindingReference) ?? null,
2290
+ },
2291
+ });
2292
+ if (response.tag !== "AcpOpenSessionResponse") {
2293
+ throw new Error(`unexpected openSession response: ${response.tag}`);
2223
2294
  }
2224
2295
  }
2225
- _dispatchSessionEvent(session, notification) {
2226
- if (session.eventHandlers.size === 0) {
2227
- return;
2228
- }
2229
- for (const subscriber of [...session.eventHandlers]) {
2230
- try {
2231
- subscriber.handler(notification);
2232
- }
2233
- catch {
2234
- // Ignore subscriber callback failures and keep event delivery moving.
2235
- }
2296
+ async getSession(input) {
2297
+ const response = await this._sendAcpRequest({
2298
+ tag: "AcpGetDurableSessionRequest",
2299
+ val: { sessionId: input?.sessionId ?? null },
2300
+ });
2301
+ if (response.tag !== "AcpGetDurableSessionResponse") {
2302
+ throw new Error(`unexpected getSession response: ${response.tag}`);
2236
2303
  }
2304
+ return decodeDurableSessionInfo(response.val.session);
2237
2305
  }
2238
- _subscribeSessionEvents(session, handler) {
2239
- const subscriber = {
2240
- handler,
2241
- };
2242
- session.eventHandlers.add(subscriber);
2243
- return () => {
2244
- session.eventHandlers.delete(subscriber);
2306
+ async listSessions(input) {
2307
+ const response = await this._sendAcpRequest({
2308
+ tag: "AcpListDurableSessionsRequest",
2309
+ val: { cursor: input?.cursor ?? null, limit: input?.limit ?? null },
2310
+ });
2311
+ if (response.tag !== "AcpListDurableSessionsResponse") {
2312
+ throw new Error(`unexpected listSessions response: ${response.tag}`);
2313
+ }
2314
+ return {
2315
+ sessions: response.val.sessions.map(decodeDurableSessionInfo),
2316
+ nextCursor: response.val.nextCursor,
2245
2317
  };
2246
2318
  }
2247
- _applySyntheticConfigOverrides(session) {
2248
- if (session.configOverrides.size === 0) {
2249
- return;
2250
- }
2251
- session.configOptions = session.configOptions.map((option) => {
2252
- const override = session.configOverrides.get(option.id) ??
2253
- (typeof option.category === "string"
2254
- ? session.configOverrides.get(option.category)
2255
- : undefined);
2256
- return override === undefined
2257
- ? option
2258
- : { ...option, currentValue: override };
2319
+ async deleteSession(input = {}) {
2320
+ const response = await this._sendAcpRequest({
2321
+ tag: "AcpDeleteSessionRequest",
2322
+ val: { sessionId: input.sessionId ?? null },
2259
2323
  });
2260
- }
2261
- _handleSidecarEvent(event) {
2262
- if (event.payload.type === "ext") {
2263
- this._handleAcpExtEvent(event.payload.envelope);
2264
- return;
2265
- }
2266
- if (event.payload.type !== "structured") {
2267
- return;
2268
- }
2269
- if (event.payload.name !== "acp.session_event") {
2270
- return;
2324
+ if (response.tag !== "AcpDeleteSessionResponse") {
2325
+ throw new Error(`unexpected deleteSession response: ${response.tag}`);
2271
2326
  }
2272
- const sessionId = event.payload.detail.session_id;
2273
- const session = sessionId ? this._sessions.get(sessionId) : undefined;
2274
- if (!session) {
2275
- return;
2327
+ }
2328
+ async unloadSession(input) {
2329
+ const response = await this._sendAcpRequest({
2330
+ tag: "AcpUnloadSessionRequest",
2331
+ val: { sessionId: input?.sessionId ?? null },
2332
+ });
2333
+ if (response.tag !== "AcpUnloadSessionResponse") {
2334
+ throw new Error(`unexpected unloadSession response: ${response.tag}`);
2276
2335
  }
2277
- const notificationText = event.payload.detail.notification;
2278
- if (typeof notificationText !== "string") {
2279
- return;
2336
+ }
2337
+ async prompt(input) {
2338
+ const response = await this._sendAcpRequest({
2339
+ tag: "AcpPromptRequest",
2340
+ val: {
2341
+ sessionId: input.sessionId ?? null,
2342
+ idempotencyKey: input.idempotencyKey ?? null,
2343
+ content: JSON.stringify(input.content),
2344
+ },
2345
+ });
2346
+ if (response.tag !== "AcpPromptResponse") {
2347
+ throw new Error(`unexpected prompt response: ${response.tag}`);
2280
2348
  }
2281
- try {
2282
- this._recordSessionNotification(session, toJsonRpcNotification(JSON.parse(notificationText)));
2349
+ return {
2350
+ sessionId: response.val.sessionId,
2351
+ message: response.val.message === null ? null : JSON.parse(response.val.message),
2352
+ stopReason: response.val.stopReason,
2353
+ };
2354
+ }
2355
+ async cancelPrompt(input) {
2356
+ const response = await this._sendAcpRequest({
2357
+ tag: "AcpCancelPromptRequest",
2358
+ val: { sessionId: input?.sessionId ?? null },
2359
+ });
2360
+ if (response.tag !== "AcpCancelPromptResponse") {
2361
+ throw new Error(`unexpected cancelPrompt response: ${response.tag}`);
2283
2362
  }
2284
- catch {
2285
- // Ignore malformed event payloads from the sidecar.
2363
+ if (response.val.status !== "cancelled" &&
2364
+ response.val.status !== "no_active_prompt") {
2365
+ throw new Error(`invalid cancelPrompt status: ${response.val.status}`);
2286
2366
  }
2367
+ return { status: response.val.status };
2287
2368
  }
2288
- _handleAcpExtEvent(envelope) {
2289
- if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
2290
- return;
2369
+ async respondPermission(input) {
2370
+ const response = await this._sendAcpRequest({
2371
+ tag: "AcpRespondPermissionRequest",
2372
+ val: {
2373
+ sessionId: input.sessionId,
2374
+ requestId: input.requestId,
2375
+ optionId: input.optionId,
2376
+ },
2377
+ });
2378
+ if (response.tag !== "AcpRespondPermissionResponse") {
2379
+ throw new Error(`unexpected respondPermission response: ${response.tag}`);
2291
2380
  }
2292
- try {
2293
- const event = decodeAcpEvent(envelope.payload);
2294
- switch (event.tag) {
2295
- case "AcpSessionEvent": {
2296
- const session = this._sessions.get(event.val.sessionId);
2297
- if (!session) {
2298
- return;
2299
- }
2300
- this._recordSessionNotification(session, toJsonRpcNotification(JSON.parse(event.val.notification)));
2301
- return;
2302
- }
2303
- }
2381
+ if (response.val.status !== "accepted" &&
2382
+ response.val.status !== "not_pending") {
2383
+ throw new Error(`invalid respondPermission status: ${response.val.status}`);
2304
2384
  }
2305
- catch {
2306
- // Ignore malformed event payloads from the sidecar.
2385
+ if (response.val.status === "accepted") {
2386
+ if (response.val.reason !== null) {
2387
+ throw new Error("accepted permission response must not include a reason");
2388
+ }
2389
+ return { status: "accepted" };
2307
2390
  }
2308
- }
2309
- _unsupportedConfigResponse(agentType, category) {
2310
- const message = agentType === "opencode" && category === "model"
2311
- ? "OpenCode reports available models, but model switching must be configured before createSession() because ACP session/set_config_option is not implemented."
2312
- : `The ${category} config option is read-only for ${agentType} sessions.`;
2313
2391
  return {
2314
- jsonrpc: "2.0",
2315
- id: null,
2316
- error: {
2317
- code: -32601,
2318
- message,
2319
- },
2392
+ status: "not_pending",
2393
+ reason: permissionTerminalReason(response.val.reason),
2320
2394
  };
2321
2395
  }
2322
- async _sendAcpRequest(request) {
2323
- const envelope = await this._sidecarClient.extensionRequest(this._sidecarSession, this._sidecarVm, {
2324
- namespace: ACP_EXTENSION_NAMESPACE,
2325
- payload: encodeAcpRequest(request),
2396
+ async readHistory(input) {
2397
+ const response = await this._sendAcpRequest({
2398
+ tag: "AcpReadHistoryRequest",
2399
+ val: {
2400
+ sessionId: input?.sessionId ?? null,
2401
+ before: input?.before === undefined ? null : BigInt(input.before),
2402
+ after: input?.after === undefined ? null : BigInt(input.after),
2403
+ limit: input?.limit ?? null,
2404
+ },
2326
2405
  });
2327
- if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
2328
- throw new Error(`unexpected ACP Ext namespace: ${envelope.namespace}`);
2406
+ if (response.tag !== "AcpHistoryPageResponse") {
2407
+ throw new Error(`unexpected readHistory response: ${response.tag}`);
2329
2408
  }
2330
- const response = decodeAcpResponse(envelope.payload);
2331
- if (response.tag === "AcpErrorResponse") {
2332
- const error = new Error(response.val.message);
2333
- error.code = response.val.code;
2334
- throw error;
2409
+ return {
2410
+ events: response.val.events.map(decodeDurableSessionEvent),
2411
+ hasMoreBefore: response.val.hasMoreBefore,
2412
+ hasMoreAfter: response.val.hasMoreAfter,
2413
+ };
2414
+ }
2415
+ async getSessionConfig(input) {
2416
+ const response = await this._sendAcpRequest({
2417
+ tag: "AcpGetSessionConfigRequest",
2418
+ val: { sessionId: input?.sessionId ?? null },
2419
+ });
2420
+ if (response.tag !== "AcpSessionConfigResponse") {
2421
+ throw new Error(`unexpected getSessionConfig response: ${response.tag}`);
2335
2422
  }
2336
- return response;
2423
+ return {
2424
+ revision: safeWireU64(response.val.revision),
2425
+ options: JSON.parse(response.val.options),
2426
+ };
2337
2427
  }
2338
- async _sendSessionRequest(sessionId, method, params) {
2339
- const session = this._requireSession(sessionId);
2340
- const response = await new Promise((resolve, reject) => {
2341
- const resolvers = this._pendingSessionRequestResolvers.get(sessionId) ?? new Set();
2342
- const resolver = {
2343
- method,
2344
- resolve: (response) => {
2345
- resolve(response);
2346
- },
2347
- };
2348
- resolvers.add(resolver);
2349
- this._pendingSessionRequestResolvers.set(sessionId, resolvers);
2350
- void this._sendAcpRequest({
2351
- tag: "AcpSessionRequest",
2352
- val: {
2353
- sessionId,
2354
- method,
2355
- params: params === undefined ? null : JSON.stringify(params),
2356
- },
2357
- })
2358
- .then((response) => {
2359
- if (response.tag !== "AcpSessionRpcResponse") {
2360
- throw new Error(`unexpected response to AcpSessionRequest: ${response.tag}`);
2361
- }
2362
- return toJsonRpcResponse(JSON.parse(response.val.response));
2363
- })
2364
- .then(resolve, reject)
2365
- .finally(() => {
2366
- const nextResolvers = this._pendingSessionRequestResolvers.get(sessionId);
2367
- if (!nextResolvers) {
2368
- return;
2369
- }
2370
- nextResolvers.delete(resolver);
2371
- if (nextResolvers.size === 0) {
2372
- this._pendingSessionRequestResolvers.delete(sessionId);
2373
- }
2374
- });
2428
+ async setSessionConfigOption(input) {
2429
+ const response = await this._sendAcpRequest({
2430
+ tag: "AcpSetSessionConfigOptionRequest",
2431
+ val: {
2432
+ sessionId: input.sessionId ?? null,
2433
+ configId: input.configId,
2434
+ value: JSON.stringify(input.value),
2435
+ },
2375
2436
  });
2376
- const liveSession = this._sessions.get(sessionId);
2377
- if (liveSession && !isLocalCancelledPromptResponse(method, response)) {
2378
- await this._hydrateSessionState(liveSession).catch(() => { });
2379
- }
2380
- if (!response.error) {
2381
- if (method === "session/set_mode" &&
2382
- typeof params?.modeId === "string" &&
2383
- session.modes) {
2384
- session.modes = {
2385
- ...session.modes,
2386
- currentModeId: params.modeId,
2387
- };
2388
- }
2389
- if (method === "session/set_config_option" &&
2390
- typeof params?.configId === "string" &&
2391
- typeof params?.value === "string") {
2392
- const nextValue = params.value;
2393
- const updatedOption = session.configOptions.find((option) => option.id === params.configId);
2394
- session.configOverrides.set(params.configId, nextValue);
2395
- if (typeof updatedOption?.category === "string") {
2396
- session.configOverrides.set(updatedOption.category, nextValue);
2397
- }
2398
- session.configOptions = session.configOptions.map((option) => option.id === params.configId
2399
- ? { ...option, currentValue: nextValue }
2400
- : option);
2401
- }
2437
+ if (response.tag !== "AcpSessionConfigResponse") {
2438
+ throw new Error(`unexpected setSessionConfigOption response: ${response.tag}`);
2402
2439
  }
2403
- return response;
2440
+ return {
2441
+ revision: safeWireU64(response.val.revision),
2442
+ options: JSON.parse(response.val.options),
2443
+ };
2404
2444
  }
2405
- async _setSessionConfigByCategory(sessionId, category, value) {
2406
- const session = this._requireSession(sessionId);
2407
- const option = session.configOptions.find((entry) => entry.category === category);
2408
- if (option?.readOnly) {
2409
- return this._unsupportedConfigResponse(session.agentType, category);
2445
+ async getSessionCapabilities(input) {
2446
+ const response = await this._sendAcpRequest({
2447
+ tag: "AcpGetSessionCapabilitiesRequest",
2448
+ val: { sessionId: input?.sessionId ?? null },
2449
+ });
2450
+ if (response.tag !== "AcpSessionCapabilitiesResponse") {
2451
+ throw new Error(`unexpected getSessionCapabilities response: ${response.tag}`);
2410
2452
  }
2411
- const response = await this._sendSessionRequest(sessionId, "session/set_config_option", {
2412
- configId: option?.id ?? category,
2413
- value,
2453
+ return response.val.capabilities === null
2454
+ ? null
2455
+ : normalizeSessionCapabilities(JSON.parse(response.val.capabilities));
2456
+ }
2457
+ async getSessionAgentInfo(input) {
2458
+ const response = await this._sendAcpRequest({
2459
+ tag: "AcpGetSessionAgentInfoRequest",
2460
+ val: { sessionId: input?.sessionId ?? null },
2414
2461
  });
2415
- return response;
2462
+ if (response.tag !== "AcpSessionAgentInfoResponse") {
2463
+ throw new Error(`unexpected getSessionAgentInfo response: ${response.tag}`);
2464
+ }
2465
+ return response.val.agentInfo === null
2466
+ ? null
2467
+ : JSON.parse(response.val.agentInfo);
2468
+ }
2469
+ /**
2470
+ * Dynamically link a software package into the RUNNING VM. The package's
2471
+ * `bin/` commands appear under `/opt/agentos/bin` (on `$PATH`) and its `share/man`
2472
+ * pages under MANPATH immediately — the `/opt/agentos` mount is host-backed, so
2473
+ * writing into its staging dir is reflected live with no reboot. An `agent`
2474
+ * block registers the package for `openSession({ agent: name })`. Persists for the VM's
2475
+ * lifetime (and across a snapshot iff the volume persists).
2476
+ */
2477
+ async linkSoftware(descriptor) {
2478
+ // Forward to the sidecar, which owns the `/opt/agentos` projection and
2479
+ // appends the package to its live host-backed staging dir; the commands
2480
+ // appear under `/opt/agentos/bin` immediately. The sidecar rejects a
2481
+ // duplicate command, surfaced here as a thrown error.
2482
+ const commands = await this._sidecarClient.linkPackage(this._sidecarSession, this._sidecarVm, descriptor);
2483
+ if (this.#kernel instanceof NativeSidecarKernelProxy) {
2484
+ this.#kernel.registerCommandGuestPaths(new Map(commands.projectedCommands.map((command) => [
2485
+ command.name,
2486
+ command.guestPath,
2487
+ ])));
2488
+ // Retain the linked package for runtime mount reconfigures:
2489
+ // `configure_vm` is replace-on-write, so a later `mountFs` that
2490
+ // resent only the boot packages would unproject this one.
2491
+ this.#kernel.registerLinkedPackage(descriptor);
2492
+ }
2493
+ // The client parses no manifests: an `agent` block in the linked package is
2494
+ // picked up by the sidecar (it owns the projected `/opt/agentos` and answers
2495
+ // openSession/listAgents from it). Nothing to record client-side.
2496
+ }
2497
+ async listSoftware() {
2498
+ return this._sidecarClient.providedCommands(this._sidecarSession, this._sidecarVm);
2416
2499
  }
2417
- _removeSession(sessionId) {
2418
- this._sessions.delete(sessionId);
2500
+ /**
2501
+ * Returns all registered agents with their installation status. Thin forwarder:
2502
+ * sends `AcpListAgentsRequest` and maps the response. The sidecar enumerates the
2503
+ * projected `/opt/agentos` packages (the client parses no manifests). Every such
2504
+ * agent is a package materialized into the VM, so `installed` is always `true`.
2505
+ */
2506
+ async listAgents() {
2507
+ const response = await this._sendAcpRequest({
2508
+ tag: "AcpListAgentsRequest",
2509
+ val: { reserved: false },
2510
+ });
2511
+ if (response.tag !== "AcpListAgentsResponse") {
2512
+ throw new Error(`unexpected list_agents response: ${response.tag}`);
2513
+ }
2514
+ return response.val.agents.map((agent) => ({
2515
+ id: agent.id,
2516
+ installed: agent.installed,
2517
+ }));
2419
2518
  }
2420
- _abortPendingSessionRequests(sessionId) {
2421
- const resolvers = this._pendingSessionRequestResolvers.get(sessionId);
2422
- if (!resolvers) {
2519
+ _recordAgentStderr(event) {
2520
+ if (!event.sessionId) {
2423
2521
  return;
2424
2522
  }
2425
- this._pendingSessionRequestResolvers.delete(sessionId);
2426
- const response = {
2427
- jsonrpc: "2.0",
2428
- id: null,
2429
- error: {
2430
- code: -32_000,
2431
- message: `Session closed: ${sessionId}`,
2432
- },
2433
- };
2434
- for (const resolver of resolvers) {
2435
- resolver.resolve(response);
2523
+ const handler = this._agentStderrHandler;
2524
+ if (!handler) {
2525
+ return;
2436
2526
  }
2437
- }
2438
- _cancelPendingPromptRequests(sessionId) {
2439
- const resolvers = this._pendingSessionRequestResolvers.get(sessionId);
2440
- if (!resolvers) {
2441
- return false;
2527
+ try {
2528
+ handler({
2529
+ sessionId: event.sessionId,
2530
+ agentType: event.agentType,
2531
+ processId: event.processId,
2532
+ pid: null,
2533
+ chunk: new Uint8Array(event.chunk),
2534
+ });
2442
2535
  }
2443
- const response = {
2444
- jsonrpc: "2.0",
2445
- id: null,
2446
- result: {
2447
- stopReason: "cancelled",
2448
- },
2536
+ catch (error) {
2537
+ console.error("AgentOS stderr handler failed", error);
2538
+ }
2539
+ }
2540
+ _recordAgentExit(event) {
2541
+ const publicEvent = {
2542
+ sessionId: event.sessionId,
2543
+ agentType: event.agentType,
2544
+ processId: event.processId,
2545
+ pid: event.pid,
2546
+ exitCode: event.exitCode,
2547
+ restart: event.restart,
2548
+ restartCount: event.restartCount,
2549
+ maxRestarts: event.maxRestarts,
2449
2550
  };
2450
- let cancelledPrompt = false;
2451
- for (const resolver of [...resolvers]) {
2452
- if (resolver.method !== "session/prompt") {
2453
- continue;
2551
+ const handler = this._agentExitHandler;
2552
+ if (handler) {
2553
+ try {
2554
+ handler(publicEvent);
2555
+ }
2556
+ catch (error) {
2557
+ console.error("AgentOS agent-exit handler failed", error);
2454
2558
  }
2455
- resolvers.delete(resolver);
2456
- resolver.resolve(response);
2457
- cancelledPrompt = true;
2458
2559
  }
2459
- if (resolvers.size === 0) {
2460
- this._pendingSessionRequestResolvers.delete(sessionId);
2560
+ for (const key of ["*", event.sessionId]) {
2561
+ for (const subscription of this._agentExitHandlers.get(key) ?? []) {
2562
+ try {
2563
+ subscription(publicEvent);
2564
+ }
2565
+ catch (error) {
2566
+ console.error("AgentOS agent-exit subscription failed", error);
2567
+ }
2568
+ }
2461
2569
  }
2462
- return cancelledPrompt;
2463
2570
  }
2464
- _rejectPendingPermissionReplies(sessionId) {
2465
- const session = this._sessions.get(sessionId);
2466
- if (!session) {
2571
+ _handleSidecarEvent(event) {
2572
+ if (event.payload.type === "ext") {
2573
+ this._handleAcpExtEvent(event.payload.envelope);
2467
2574
  return;
2468
2575
  }
2469
- this._rejectPendingPermissionRepliesFromSession(session);
2470
- }
2471
- _rejectPendingPermissionRepliesFromSession(session) {
2472
- for (const [permissionId, pendingReply,] of session.pendingPermissionReplies) {
2473
- clearTimeout(pendingReply.timer);
2474
- pendingReply.reject(new Error(`Session closed before permission reply: ${permissionId}`));
2475
- }
2476
- session.pendingPermissionReplies.clear();
2477
- }
2478
- async _closeSessionInternal(sessionId) {
2479
- const closing = this._sessionClosePromises.get(sessionId);
2480
- if (closing) {
2481
- return closing;
2482
- }
2483
- if (this._closedSessionIds.has(sessionId)) {
2576
+ if (event.payload.type !== "structured") {
2484
2577
  return;
2485
2578
  }
2486
- this._abortPendingSessionRequests(sessionId);
2487
- this._rejectPendingPermissionReplies(sessionId);
2488
- this._requireSession(sessionId);
2489
- this._removeSession(sessionId);
2490
- this._closedSessionIds.add(sessionId);
2491
- const closePromise = this._sendAcpRequest({
2492
- tag: "AcpCloseSessionRequest",
2493
- val: { sessionId },
2494
- })
2495
- .then((response) => {
2496
- if (response.tag !== "AcpSessionClosedResponse") {
2497
- throw new Error(`unexpected response to AcpCloseSessionRequest: ${response.tag}`);
2498
- }
2499
- })
2500
- .finally(() => {
2501
- this._sessionClosePromises.delete(sessionId);
2502
- });
2503
- this._sessionClosePromises.set(sessionId, closePromise);
2504
- await closePromise;
2579
+ if (event.payload.name === "limit_warning") {
2580
+ this._handleLimitWarning(event.payload.detail);
2581
+ }
2505
2582
  }
2506
- async _hydrateSessionState(session) {
2507
- const response = await this._sendAcpRequest({
2508
- tag: "AcpGetSessionStateRequest",
2509
- val: { sessionId: session.sessionId },
2510
- });
2511
- if (response.tag !== "AcpSessionStateResponse") {
2512
- throw new Error(`unexpected response to AcpGetSessionStateRequest: ${response.tag}`);
2513
- }
2514
- const state = sidecarSessionStateFromAcp(response.val);
2515
- this._syncSessionState(session, state);
2516
- }
2517
- async createSession(agentType, options) {
2518
- const config = this._resolveAgentConfig(agentType);
2519
- if (!config) {
2520
- throw new Error(`Unknown agent type: ${agentType}`);
2521
- }
2522
- // System-prompt assembly and injection (launch args / OPENCODE_CONTEXTPATHS) are owned by
2523
- // the sidecar at AcpCreateSessionRequest. The host only forwards additionalInstructions /
2524
- // skipOsInstructions plus the agent's static launch args and env.
2525
- const launchArgs = [...(config.launchArgs ?? [])];
2526
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2527
- const sessionCwd = options?.cwd ?? "/home/user";
2528
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2529
- if ((agentType === "pi" || agentType === "pi-cli") &&
2530
- !launchEnv.PI_ACP_PI_COMMAND) {
2531
- launchEnv = {
2532
- ...launchEnv,
2533
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2534
- };
2583
+ _handleLimitWarning(detail) {
2584
+ if (!this._limitWarningHandler) {
2585
+ return;
2535
2586
  }
2536
- const response = await this._sendAcpRequest({
2537
- tag: "AcpCreateSessionRequest",
2538
- val: {
2539
- agentType: String(agentType),
2540
- runtime: AcpRuntimeKind.JavaScript,
2541
- adapterEntrypoint,
2542
- args: launchArgs,
2543
- env: new Map(Object.entries(launchEnv)),
2544
- cwd: sessionCwd,
2545
- mcpServers: JSON.stringify(options?.mcpServers ?? []),
2546
- protocolVersion: ACP_PROTOCOL_VERSION,
2547
- clientCapabilities: JSON.stringify(defaultAcpClientCapabilities()),
2548
- additionalInstructions: combineInstructions(options?.additionalInstructions, this._toolReference),
2549
- skipOsInstructions: options?.skipOsInstructions ?? false,
2550
- },
2551
- });
2552
- if (response.tag !== "AcpSessionCreatedResponse") {
2553
- throw new Error(`unexpected create_session response: ${response.tag}`);
2554
- }
2555
- const created = sidecarSessionCreatedFromAcp(response.val);
2556
- // The sessionId is chosen by the (untrusted/buggy) ACP adapter or sidecar. If it collides
2557
- // with a live session already in `_sessions`, blindly overwriting the entry would orphan the
2558
- // first session's event/permission handlers and pending permission replies. Fail closed:
2559
- // reject the colliding create and leave the original session intact. (A previously-closed,
2560
- // evicted id may still be re-used; only a live, non-closed entry is a collision.)
2561
- const existing = this._sessions.get(created.sessionId);
2562
- if (existing !== undefined && !existing.closed) {
2563
- throw new Error(`session id collision: ${created.sessionId}`);
2564
- }
2565
- const initData = {
2566
- modes: toSessionModes(created.modes) ?? undefined,
2567
- configOptions: toSessionConfigOptions(created.configOptions),
2568
- capabilities: toAgentCapabilities(created.agentCapabilities),
2569
- agentInfo: toAgentInfo(created.agentInfo) ?? undefined,
2587
+ const toNumber = (value) => {
2588
+ const parsed = Number(value);
2589
+ return Number.isFinite(parsed) ? parsed : 0;
2570
2590
  };
2571
- const session = sessionEntryFromInit(created.sessionId, String(agentType), initData);
2572
- this._closedSessionIds.delete(created.sessionId);
2573
- this._sessions.set(created.sessionId, session);
2574
2591
  try {
2575
- await this._hydrateSessionState(session);
2592
+ this._limitWarningHandler({
2593
+ limit: detail.limit ?? "",
2594
+ category: detail.category ?? "",
2595
+ observed: toNumber(detail.observed),
2596
+ capacity: toNumber(detail.capacity),
2597
+ fillPercent: toNumber(detail.fillPercent),
2598
+ });
2576
2599
  }
2577
2600
  catch (error) {
2578
- this._removeSession(created.sessionId);
2579
- throw error;
2601
+ console.error("AgentOS limit-warning handler failed", error);
2580
2602
  }
2581
- return { sessionId: created.sessionId };
2582
2603
  }
2583
- /**
2584
- * Resume a session that exists in durable storage but is not live in this VM
2585
- * (e.g. after a Rivet actor slept and woke with a fresh VM). Thin forwarder:
2586
- * resolves the agent config + adapter entrypoint exactly as {@link createSession}
2587
- * does, then forwards a single `AcpResumeSessionRequest` to the sidecar, which
2588
- * owns the resume state machine (native `session/load` when the agent supports
2589
- * it, else `session/new` + a transcript-continuation preamble). The returned
2590
- * `sessionId` is the live id in this VM (equal to the requested id for native
2591
- * loads, freshly assigned for the fallback); the caller remaps `external -> live`.
2592
- * The new live session is registered + hydrated locally so subsequent prompts
2593
- * route to it.
2594
- *
2595
- * Resume depends on a durable root; on a non-durable (default in-memory) root
2596
- * there is no surviving store and the fallback tier always runs.
2597
- */
2598
- async resumeSession(sessionId, agentType, options) {
2599
- const config = this._resolveAgentConfig(agentType);
2600
- if (!config) {
2601
- throw new Error(`Unknown agent type: ${agentType}`);
2602
- }
2603
- const adapterEntrypoint = this._resolveAdapterBin(config.acpAdapter);
2604
- let launchEnv = { ...config.defaultEnv, ...options?.env };
2605
- const sessionCwd = options?.cwd ?? "/home/user";
2606
- if ((agentType === "pi" || agentType === "pi-cli") &&
2607
- !launchEnv.PI_ACP_PI_COMMAND) {
2608
- launchEnv = {
2609
- ...launchEnv,
2610
- PI_ACP_PI_COMMAND: this._resolvePackageBin(config.agentPackage, "pi"),
2611
- };
2604
+ _handleAcpExtEvent(envelope) {
2605
+ if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
2606
+ return;
2612
2607
  }
2613
- // The resume wire request has no dedicated `adapterEntrypoint` field; carry
2614
- // the resolved entrypoint through env under the sidecar's reserved key. The
2615
- // sidecar reads it and strips it before launching the adapter.
2616
- launchEnv = {
2617
- ...launchEnv,
2618
- [RESUME_ADAPTER_ENTRYPOINT_ENV]: adapterEntrypoint,
2619
- };
2620
- const response = await this._sendAcpRequest({
2621
- tag: "AcpResumeSessionRequest",
2622
- val: {
2623
- sessionId,
2624
- agentType: String(agentType),
2625
- transcriptPath: options?.transcriptPath ?? null,
2626
- cwd: sessionCwd,
2627
- env: new Map(Object.entries(launchEnv)),
2628
- },
2629
- });
2630
- if (response.tag !== "AcpSessionResumedResponse") {
2631
- throw new Error(`unexpected resume_session response: ${response.tag}`);
2632
- }
2633
- const { sessionId: liveSessionId, mode } = response.val;
2634
- // Register + hydrate the live session so subsequent prompts route to it.
2635
- const session = sessionEntryFromInit(liveSessionId, String(agentType), {});
2636
- this._closedSessionIds.delete(liveSessionId);
2637
- this._sessions.set(liveSessionId, session);
2638
2608
  try {
2639
- await this._hydrateSessionState(session);
2609
+ const event = decodeAcpEvent(envelope.payload);
2610
+ switch (event.tag) {
2611
+ case "AcpDurableSessionEvent": {
2612
+ this._emitDurableSessionEvent(decodeDurableSessionEvent(event.val));
2613
+ return;
2614
+ }
2615
+ case "AcpEphemeralSessionUpdateEvent": {
2616
+ const update = JSON.parse(event.val.update);
2617
+ const { sessionUpdate: type, ...payload } = update;
2618
+ this._emitDurableSessionEvent({
2619
+ durability: "ephemeral",
2620
+ type,
2621
+ sessionId: event.val.sessionId,
2622
+ afterSequence: safeWireU64(event.val.afterSequence),
2623
+ ...payload,
2624
+ });
2625
+ return;
2626
+ }
2627
+ case "AcpSessionEvent":
2628
+ return;
2629
+ case "AcpAgentStderrEvent": {
2630
+ this._recordAgentStderr(event.val);
2631
+ return;
2632
+ }
2633
+ case "AcpAgentExitedEvent": {
2634
+ this._recordAgentExit(event.val);
2635
+ return;
2636
+ }
2637
+ }
2640
2638
  }
2641
2639
  catch (error) {
2642
- this._removeSession(liveSessionId);
2643
- throw error;
2640
+ console.error("AgentOS failed to decode an ACP sidecar event", error);
2644
2641
  }
2645
- return { sessionId: liveSessionId, mode };
2646
2642
  }
2647
- /**
2648
- * Resolve the VM bin entry point of an ACP adapter package.
2649
- * Reads from the host filesystem since kernel.readFile() resolves through
2650
- * mounts; adapter package.json is read directly off the host dir backing
2651
- * the relevant mount.
2652
- */
2653
- _resolveAdapterBin(adapterPackage) {
2654
- return this._resolvePackageBin(adapterPackage);
2655
- }
2656
- /**
2657
- * Parent of the host directory backing the `/root/node_modules` mount, if
2658
- * the caller supplied one (e.g. via `nodeModulesMount(...)`). Used as the
2659
- * `resolvePackageDir` start dir for adapter/agent package resolution when no
2660
- * software root matches. `nodeModulesMount` mounts `<dir>/node_modules`, so
2661
- * the start dir is `<dir>` (one level above the node_modules tree).
2662
- */
2663
- _nodeModulesHostRoot() {
2664
- for (const mount of this._hostMounts) {
2665
- if (mount.vmPath === "/root/node_modules") {
2666
- return dirname(mount.hostPath);
2667
- }
2668
- }
2669
- return null;
2670
- }
2671
- _resolvePackageBin(packageName, binName) {
2672
- const vmPrefix = `/root/node_modules/${packageName}`;
2673
- let hostPkgJsonPath = null;
2674
- for (const root of this._softwareRoots) {
2675
- if (root.vmPath === vmPrefix) {
2676
- hostPkgJsonPath = join(root.hostPath, "package.json");
2677
- break;
2643
+ _emitDurableSessionEvent(entry) {
2644
+ for (const handler of this._durableSessionEventHandlers.get(entry.sessionId) ?? []) {
2645
+ try {
2646
+ handler(entry);
2678
2647
  }
2679
- }
2680
- // Fall back to the host dir behind the `/root/node_modules` mount.
2681
- if (!hostPkgJsonPath) {
2682
- const nodeModulesRoot = this._nodeModulesHostRoot();
2683
- if (!nodeModulesRoot) {
2684
- throw new Error(`Cannot resolve package "${packageName}": no software root provides it and ` +
2685
- `no /root/node_modules mount was supplied. Pass ` +
2686
- `mounts: [nodeModulesMount("<host>/node_modules")] to AgentOs.create().`);
2648
+ catch (error) {
2649
+ console.error("AgentOS session event handler failed", error);
2687
2650
  }
2688
- hostPkgJsonPath = join(resolvePackageDir(nodeModulesRoot, packageName), "package.json");
2689
- }
2690
- const pkg = JSON.parse(readFileSync(hostPkgJsonPath, "utf-8"));
2691
- let binEntry;
2692
- if (typeof pkg.bin === "string") {
2693
- binEntry = pkg.bin;
2694
2651
  }
2695
- else if (typeof pkg.bin === "object" && pkg.bin !== null) {
2696
- binEntry =
2697
- (binName ? pkg.bin[binName] : undefined) ??
2698
- pkg.bin[packageName] ??
2699
- Object.values(pkg.bin)[0];
2652
+ }
2653
+ async _sendAcpRequest(request) {
2654
+ const envelope = await this._sidecarClient.extensionRequest(this._sidecarSession, this._sidecarVm, {
2655
+ namespace: ACP_EXTENSION_NAMESPACE,
2656
+ payload: encodeAcpRequest(request),
2657
+ });
2658
+ if (envelope.namespace !== ACP_EXTENSION_NAMESPACE) {
2659
+ throw new Error(`unexpected ACP Ext namespace: ${envelope.namespace}`);
2700
2660
  }
2701
- if (!binEntry) {
2702
- throw new Error(`No bin entry found in ${packageName}/package.json`);
2661
+ const response = decodeAcpResponse(envelope.payload);
2662
+ if (response.tag === "AcpErrorResponse") {
2663
+ const error = new Error(response.val.message);
2664
+ error.code = response.val.code;
2665
+ throw error;
2703
2666
  }
2704
- return `${vmPrefix}/${binEntry}`;
2667
+ return response;
2705
2668
  }
2706
2669
  _installSidecarRequestHandler() {
2707
2670
  const context = {
2708
- toolKits: this._toolKits,
2709
- toolMap: buildToolMap(this._toolKits),
2671
+ bindings: this._bindings,
2672
+ bindingMap: buildBindingMap(this._bindings),
2710
2673
  permissions: this._permissions,
2711
2674
  readFile: (path) => this.readFile(path),
2712
2675
  };
@@ -2715,10 +2678,8 @@ export class AgentOs {
2715
2678
  case "host_callback":
2716
2679
  return handleHostCallback(request, context);
2717
2680
  case "js_bridge_call":
2718
- return Promise.resolve({
2719
- type: "js_bridge_result",
2720
- call_id: request.payload.call_id,
2721
- error: `unsupported sidecar request type: ${request.payload.type}`,
2681
+ return handleJsBridgeCall(request.payload, {
2682
+ filesystem: this.#kernel.vfs,
2722
2683
  });
2723
2684
  case "ext":
2724
2685
  return this._handleAcpExtSidecarRequest(request.payload.envelope);
@@ -2737,25 +2698,6 @@ export class AgentOs {
2737
2698
  }
2738
2699
  const callback = decodeAcpCallback(envelope.payload);
2739
2700
  switch (callback.tag) {
2740
- case "AcpPermissionCallback": {
2741
- const reply = await this._handleAcpPermissionCallback(callback.val.sessionId, callback.val.permissionId, {
2742
- ...toRecord(JSON.parse(callback.val.params)),
2743
- _acpMethod: ACP_PERMISSION_METHOD,
2744
- });
2745
- return {
2746
- type: "ext_result",
2747
- envelope: {
2748
- namespace: ACP_EXTENSION_NAMESPACE,
2749
- payload: encodeAcpCallbackResponse({
2750
- tag: "AcpPermissionCallbackResponse",
2751
- val: {
2752
- permissionId: callback.val.permissionId,
2753
- reply,
2754
- },
2755
- }),
2756
- },
2757
- };
2758
- }
2759
2701
  case "AcpHostRequestCallback": {
2760
2702
  const response = await this._dispatchAcpSidecarRequest(toJsonRpcRequest(JSON.parse(callback.val.request)));
2761
2703
  return {
@@ -2807,8 +2749,6 @@ export class AgentOs {
2807
2749
  async _handleSupportedAcpSidecarRequest(request) {
2808
2750
  const params = this._acpParams(request);
2809
2751
  switch (request.method) {
2810
- case ACP_PERMISSION_METHOD:
2811
- return this._handleAcpPermissionRequest(request, params);
2812
2752
  case "fs/read":
2813
2753
  case "fs/read_text_file":
2814
2754
  return this._handleAcpReadFile(params);
@@ -2841,95 +2781,6 @@ export class AgentOs {
2841
2781
  });
2842
2782
  }
2843
2783
  }
2844
- _normalizeAcpPermissionOptionId(options, reply) {
2845
- const optionTargets = reply === "always"
2846
- ? {
2847
- optionIds: new Set(["always", "allow_always"]),
2848
- kinds: new Set(["allow_always"]),
2849
- }
2850
- : reply === "once"
2851
- ? {
2852
- optionIds: new Set(["once", "allow_once"]),
2853
- kinds: new Set(["allow_once"]),
2854
- }
2855
- : {
2856
- optionIds: new Set(["reject", "reject_once"]),
2857
- kinds: new Set(["reject_once"]),
2858
- };
2859
- const matched = options?.find((option) => {
2860
- const optionId = typeof option.optionId === "string" ? option.optionId : undefined;
2861
- const kind = typeof option.kind === "string" ? option.kind : undefined;
2862
- return ((optionId !== undefined && optionTargets.optionIds.has(optionId)) ||
2863
- (kind !== undefined && optionTargets.kinds.has(kind)));
2864
- });
2865
- if (matched && typeof matched.optionId === "string") {
2866
- return matched.optionId;
2867
- }
2868
- if (reply === "always") {
2869
- return "allow_always";
2870
- }
2871
- if (reply === "once") {
2872
- return "allow_once";
2873
- }
2874
- return "reject_once";
2875
- }
2876
- _buildAcpPermissionResult(reply, params) {
2877
- const options = Array.isArray(params.options)
2878
- ? params.options.filter((option) => typeof option === "object" && option !== null)
2879
- : undefined;
2880
- const optionId = this._normalizeAcpPermissionOptionId(options, reply);
2881
- return {
2882
- outcome: optionId
2883
- ? {
2884
- outcome: "selected",
2885
- optionId,
2886
- }
2887
- : {
2888
- outcome: "cancelled",
2889
- },
2890
- };
2891
- }
2892
- async _handleAcpPermissionRequest(request, params) {
2893
- const sessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
2894
- if (!sessionId) {
2895
- throw new AcpDispatchError(-32602, `${ACP_PERMISSION_METHOD} requires a sessionId`);
2896
- }
2897
- const session = this._sessions.get(sessionId);
2898
- if (!session) {
2899
- throw new AcpDispatchError(-32602, `Session not found: ${sessionId}`);
2900
- }
2901
- const permissionId = String(request.id);
2902
- const permissionParams = {
2903
- ...params,
2904
- permissionId,
2905
- _acpMethod: request.method,
2906
- };
2907
- if (session.permissionHandlers.size === 0) {
2908
- return this._buildAcpPermissionResult("reject", permissionParams);
2909
- }
2910
- const reply = await new Promise((resolve, reject) => {
2911
- const timer = setTimeout(() => {
2912
- session.pendingPermissionReplies.delete(permissionId);
2913
- reject(new Error(`Timed out waiting for permission reply: ${permissionId}`));
2914
- }, 120_000);
2915
- session.pendingPermissionReplies.set(permissionId, {
2916
- resolve,
2917
- reject,
2918
- timer,
2919
- });
2920
- const permissionRequest = {
2921
- permissionId,
2922
- description: typeof permissionParams["description"] === "string"
2923
- ? permissionParams["description"]
2924
- : undefined,
2925
- params: permissionParams,
2926
- };
2927
- for (const handler of session.permissionHandlers) {
2928
- handler(permissionRequest);
2929
- }
2930
- });
2931
- return this._buildAcpPermissionResult(reply, permissionParams);
2932
- }
2933
2784
  _acpParams(request) {
2934
2785
  if (!request.params) {
2935
2786
  return {};
@@ -3102,9 +2953,6 @@ export class AgentOs {
3102
2953
  ...(cwd ? { cwd } : {}),
3103
2954
  ...(cols !== undefined ? { cols: Math.trunc(cols) } : {}),
3104
2955
  ...(rows !== undefined ? { rows: Math.trunc(rows) } : {}),
3105
- onStderr: (data) => {
3106
- this._appendAcpTerminalOutput(terminal, data);
3107
- },
3108
2956
  }),
3109
2957
  output: "",
3110
2958
  truncated: false,
@@ -3181,197 +3029,38 @@ export class AgentOs {
3181
3029
  terminal.handle.resize(Math.trunc(cols), Math.trunc(rows));
3182
3030
  return null;
3183
3031
  }
3184
- async _handleAcpPermissionCallback(sessionId, permissionId, params) {
3185
- const session = this._sessions.get(sessionId);
3186
- if (!session) {
3187
- return "reject";
3188
- }
3189
- if (session.permissionHandlers.size === 0) {
3190
- return "reject";
3191
- }
3192
- try {
3193
- return await new Promise((resolve, reject) => {
3194
- const timer = setTimeout(() => {
3195
- session.pendingPermissionReplies.delete(permissionId);
3196
- reject(new Error(`Timed out waiting for permission reply: ${permissionId}`));
3197
- }, 120_000);
3198
- session.pendingPermissionReplies.set(permissionId, {
3199
- resolve,
3200
- reject,
3201
- timer,
3202
- });
3203
- const permissionRequest = {
3204
- permissionId,
3205
- description: typeof params.description === "string"
3206
- ? params.description
3207
- : undefined,
3208
- params,
3209
- };
3210
- for (const handler of session.permissionHandlers) {
3211
- handler(permissionRequest);
3212
- }
3213
- });
3214
- }
3215
- catch {
3216
- return "reject";
3217
- }
3218
- }
3219
- /**
3220
- * Resolve an agent config by ID. Package-provided configs take
3221
- * precedence over the hardcoded AGENT_CONFIGS.
3222
- */
3223
- _resolveAgentConfig(agentType) {
3224
- return (this._softwareAgentConfigs.get(agentType) ??
3225
- AGENT_CONFIGS[agentType]);
3226
- }
3227
- /**
3228
- * Gracefully destroy a session: cancel any pending work, close the client,
3229
- * and remove from tracking. Unlike close() which is abrupt, this attempts
3230
- * a graceful shutdown sequence.
3231
- */
3232
- async destroySession(sessionId) {
3233
- this._requireSession(sessionId);
3234
- try {
3235
- await this.cancelSession(sessionId);
3236
- }
3237
- catch {
3238
- // Ignore cancellation failures during teardown.
3239
- }
3240
- await this._closeSessionInternal(sessionId);
3241
- }
3242
- // ── Flat session API (ID-based) ───────────────────────────────
3243
- async prompt(sessionId, text) {
3244
- const session = this._requireSession(sessionId);
3245
- let agentText = "";
3246
- const handler = (event) => {
3247
- const params = toRecord(event.params);
3248
- const update = toRecord(params.update);
3249
- if (update?.sessionUpdate === "agent_message_chunk") {
3250
- const content = toRecord(update.content);
3251
- if (typeof content.text === "string") {
3252
- agentText += content.text;
3253
- }
3032
+ onSessionEvent(sessionIdOrHandler, maybeHandler) {
3033
+ const sessionId = typeof sessionIdOrHandler === "string" ? sessionIdOrHandler : "main";
3034
+ const handler = typeof sessionIdOrHandler === "function"
3035
+ ? sessionIdOrHandler
3036
+ : maybeHandler;
3037
+ if (!handler) {
3038
+ throw new TypeError("onSessionEvent requires a handler");
3039
+ }
3040
+ const handlers = this._durableSessionEventHandlers.get(sessionId) ?? new Set();
3041
+ handlers.add(handler);
3042
+ this._durableSessionEventHandlers.set(sessionId, handlers);
3043
+ return () => {
3044
+ handlers.delete(handler);
3045
+ if (handlers.size === 0) {
3046
+ this._durableSessionEventHandlers.delete(sessionId);
3254
3047
  }
3255
3048
  };
3256
- const unsubscribe = this._subscribeSessionEvents(session, handler);
3257
- try {
3258
- const response = await this._sendSessionRequest(sessionId, "session/prompt", {
3259
- prompt: [{ type: "text", text }],
3260
- });
3261
- return { response, text: agentText };
3262
- }
3263
- finally {
3264
- unsubscribe();
3265
- }
3266
- }
3267
- /** Cancel ongoing agent work for a session. */
3268
- async cancelSession(sessionId) {
3269
- this._requireSession(sessionId);
3270
- const cancelledPendingPrompt = this._cancelPendingPromptRequests(sessionId);
3271
- if (cancelledPendingPrompt) {
3272
- // Session control requests share the same framed sidecar transport as an
3273
- // in-flight prompt request. If the adapter is blocked in prompt I/O, a
3274
- // synchronous cancel RPC can wedge behind that prompt until the transport
3275
- // timeout fires. Resolve the local prompt immediately, then let the sidecar
3276
- // cancellation continue in the background as best effort.
3277
- void this._sendSessionRequest(sessionId, "session/cancel").catch(() => { });
3278
- return {
3279
- jsonrpc: "2.0",
3280
- id: null,
3281
- result: {
3282
- cancelled: true,
3283
- requested: true,
3284
- via: "prompt-fallback",
3285
- },
3286
- };
3287
- }
3288
- const response = await this._sendSessionRequest(sessionId, "session/cancel");
3289
- if (response.error?.code === -32601) {
3290
- return {
3291
- jsonrpc: "2.0",
3292
- id: null,
3293
- result: {
3294
- cancelled: false,
3295
- requested: true,
3296
- via: "unsupported",
3297
- },
3298
- };
3299
- }
3300
- return response;
3301
- }
3302
- closeSession(sessionId) {
3303
- if (!this._sessions.has(sessionId) &&
3304
- !this._closedSessionIds.has(sessionId) &&
3305
- !this._sessionClosePromises.has(sessionId)) {
3306
- throw new Error(`Session not found: ${sessionId}`);
3307
- }
3308
- const closePromise = this._closeSessionInternal(sessionId);
3309
- // `closeSession()` is intentionally fire-and-forget; suppress unhandled
3310
- // rejections here and let tracked close promises surface errors to any
3311
- // internal/test callers awaiting `_sessionClosePromises`.
3312
- void closePromise.catch(() => { });
3313
- }
3314
- async respondPermission(sessionId, permissionId, reply) {
3315
- const session = this._requireSession(sessionId);
3316
- const pendingReply = session.pendingPermissionReplies.get(permissionId);
3317
- if (pendingReply) {
3318
- session.pendingPermissionReplies.delete(permissionId);
3319
- clearTimeout(pendingReply.timer);
3320
- pendingReply.resolve(reply);
3321
- return {
3322
- jsonrpc: "2.0",
3323
- id: null,
3324
- result: {
3325
- permissionId,
3326
- reply,
3327
- via: "sidecar-request",
3328
- },
3329
- };
3330
- }
3331
- return this._sendSessionRequest(sessionId, LEGACY_PERMISSION_METHOD, {
3332
- permissionId,
3333
- reply,
3334
- });
3335
- }
3336
- async setSessionMode(sessionId, modeId) {
3337
- return this._sendSessionRequest(sessionId, "session/set_mode", {
3338
- modeId,
3339
- });
3340
- }
3341
- getSessionModes(sessionId) {
3342
- return this._requireSession(sessionId).modes;
3343
- }
3344
- async setSessionModel(sessionId, model) {
3345
- return this._setSessionConfigByCategory(sessionId, "model", model);
3346
- }
3347
- async setSessionThoughtLevel(sessionId, level) {
3348
- return this._setSessionConfigByCategory(sessionId, "thought_level", level);
3349
- }
3350
- getSessionConfigOptions(sessionId) {
3351
- return [...this._requireSession(sessionId).configOptions];
3352
- }
3353
- getSessionCapabilities(sessionId) {
3354
- const caps = this._requireSession(sessionId).capabilities;
3355
- return Object.keys(caps).length > 0 ? caps : null;
3356
- }
3357
- getSessionAgentInfo(sessionId) {
3358
- return this._requireSession(sessionId).agentInfo;
3359
3049
  }
3360
- async rawSessionSend(sessionId, method, params) {
3361
- return this._sendSessionRequest(sessionId, method, params);
3362
- }
3363
- async rawSend(sessionId, method, params) {
3364
- return this.rawSessionSend(sessionId, method, params);
3365
- }
3366
- onSessionEvent(sessionId, handler) {
3367
- const session = this._requireSession(sessionId);
3368
- return this._subscribeSessionEvents(session, handler);
3369
- }
3370
- onPermissionRequest(sessionId, handler) {
3371
- const session = this._requireSession(sessionId);
3372
- session.permissionHandlers.add(handler);
3050
+ onAgentExit(sessionIdOrHandler, maybeHandler) {
3051
+ const sessionId = typeof sessionIdOrHandler === "string" ? sessionIdOrHandler : "*";
3052
+ const handler = typeof sessionIdOrHandler === "function"
3053
+ ? sessionIdOrHandler
3054
+ : maybeHandler;
3055
+ if (!handler)
3056
+ throw new TypeError("onAgentExit requires a handler");
3057
+ const handlers = this._agentExitHandlers.get(sessionId) ?? new Set();
3058
+ handlers.add(handler);
3059
+ this._agentExitHandlers.set(sessionId, handlers);
3373
3060
  return () => {
3374
- session.permissionHandlers.delete(handler);
3061
+ handlers.delete(handler);
3062
+ if (handlers.size === 0)
3063
+ this._agentExitHandlers.delete(sessionId);
3375
3064
  };
3376
3065
  }
3377
3066
  // ── Cron ────────────────────────────────────────────────────
@@ -3393,9 +3082,6 @@ export class AgentOs {
3393
3082
  }
3394
3083
  async dispose() {
3395
3084
  this._cronManager.dispose();
3396
- for (const sessionId of [...this._sessions.keys()]) {
3397
- await this._closeSessionInternal(sessionId).catch(() => { });
3398
- }
3399
3085
  for (const [id, entry] of this._shells) {
3400
3086
  entry.handle.kill();
3401
3087
  }
@@ -3407,6 +3093,7 @@ export class AgentOs {
3407
3093
  terminalExitPromises.push(terminal.waitPromise.then(() => undefined, () => undefined));
3408
3094
  }
3409
3095
  this._acpTerminals.clear();
3096
+ this._processes.clear();
3410
3097
  await waitForTrackedExitPromises([...shellExitPromises, ...terminalExitPromises], SHELL_DISPOSE_TIMEOUT_MS);
3411
3098
  this._disposeSidecarEventListener();
3412
3099
  const sidecarLease = this._sidecarLease;
@@ -3436,6 +3123,173 @@ function resolveAgentOsSidecar(config) {
3436
3123
  }
3437
3124
  const sidecarStates = new WeakMap();
3438
3125
  const sharedSidecars = new Map();
3126
+ let sidecarProcessExitHookInstalled = false;
3127
+ /**
3128
+ * Install a one-time, synchronous `process.on("exit")` hook that SIGKILLs any
3129
+ * pooled shared sidecar child. Once a one-shot host process is allowed to exit
3130
+ * (its sidecar handles are unref'd at 0 leases), this reaps the sidecar
3131
+ * immediately instead of waiting for its stdin-EOF grace window — no orphan, no
3132
+ * delay. We deliberately do NOT install SIGINT/SIGTERM handlers: a library
3133
+ * should not hijack the host's signal handling. SIGINT still reaches the sidecar
3134
+ * via the process group, and SIGTERM-driven exit still closes its stdin.
3135
+ */
3136
+ function ensureSidecarProcessExitCleanup() {
3137
+ if (sidecarProcessExitHookInstalled)
3138
+ return;
3139
+ sidecarProcessExitHookInstalled = true;
3140
+ process.on("exit", () => {
3141
+ for (const sidecar of sharedSidecars.values()) {
3142
+ try {
3143
+ sidecarStates.get(sidecar)?.sharedChild?.kill?.("SIGKILL");
3144
+ }
3145
+ catch {
3146
+ // best-effort reap; the process is exiting regardless
3147
+ }
3148
+ }
3149
+ });
3150
+ }
3151
+ function sidecarChildHandle(client) {
3152
+ // SidecarProcess -> StdioSidecarProtocolClient.child (the spawned ChildProcess).
3153
+ const protocolClient = client?.protocolClient;
3154
+ return protocolClient?.child ?? undefined;
3155
+ }
3156
+ /**
3157
+ * Apply the current hold state to the shared sidecar's child + stdio: ref them
3158
+ * while ≥1 hold is live so in-flight VM work keeps the host process alive; unref
3159
+ * them at 0 so a one-shot script exits on its own after `dispose()`. The sidecar
3160
+ * process itself stays running (reusable) and self-exits on stdin EOF when the
3161
+ * host finally goes away. Best-effort: never let ref/unref break VM lifecycle.
3162
+ */
3163
+ function applySharedSidecarHold(state) {
3164
+ const child = state.sharedChild;
3165
+ if (!child)
3166
+ return;
3167
+ const hold = (state.eventLoopHolds ?? 0) > 0;
3168
+ // Include the complete stdio array, not just the named fd0-fd2 aliases.
3169
+ // Protocol v8 carries responses over fd3; leaving that control socket
3170
+ // referenced pins Node's event loop after the final VM lease is disposed.
3171
+ const handles = new Set([
3172
+ child,
3173
+ child.stdin,
3174
+ child.stdout,
3175
+ child.stderr,
3176
+ ...(child.stdio ?? []),
3177
+ ]);
3178
+ for (const handle of handles) {
3179
+ if (!handle)
3180
+ continue;
3181
+ try {
3182
+ if (hold)
3183
+ handle.ref?.();
3184
+ else
3185
+ handle.unref?.();
3186
+ }
3187
+ catch {
3188
+ // ref/unref is an optimization, not correctness-critical
3189
+ }
3190
+ }
3191
+ }
3192
+ /**
3193
+ * Take a hold for the entire create→use→dispose lifetime of one VM lease. Taken
3194
+ * BEFORE VM creation starts (not when the lease lands in `activeLeases`) so a VM
3195
+ * that is still mid-creation keeps the sidecar ref'd and a concurrent dispose
3196
+ * cannot unref it out from under the in-flight create.
3197
+ */
3198
+ function acquireSharedSidecarHold(state) {
3199
+ state.eventLoopHolds = (state.eventLoopHolds ?? 0) + 1;
3200
+ if (state.eventLoopHolds === 1)
3201
+ applySharedSidecarHold(state);
3202
+ }
3203
+ /** Release a hold taken by {@link acquireSharedSidecarHold}; unref at 0. */
3204
+ function releaseSharedSidecarHold(state) {
3205
+ const current = state.eventLoopHolds ?? 0;
3206
+ if (current <= 0) {
3207
+ // The `holdReleased` guard makes each lease release exactly once, so this
3208
+ // should be unreachable. Warn rather than silently floor, per the repo's
3209
+ // no-silent-masking rule, so an accounting bug surfaces instead of hiding.
3210
+ state.eventLoopHolds = 0;
3211
+ console.warn("[agentos] shared sidecar event-loop hold released more than acquired");
3212
+ return;
3213
+ }
3214
+ state.eventLoopHolds = current - 1;
3215
+ if (state.eventLoopHolds === 0)
3216
+ applySharedSidecarHold(state);
3217
+ }
3218
+ /**
3219
+ * Spawn-once accessor for a sidecar handle's shared native process. Concurrent
3220
+ * callers await the same promise, so one `AgentOsSidecar` maps to exactly one
3221
+ * `agent-os-sidecar` OS process for its whole lifetime.
3222
+ */
3223
+ function ensureSharedSidecarNativeProcess(sidecar) {
3224
+ const state = getSidecarState(sidecar);
3225
+ if (!state.nativeProcess) {
3226
+ ensureSidecarProcessExitCleanup();
3227
+ state.nativeProcess = (async () => {
3228
+ const client = SidecarProcess.spawn({
3229
+ cwd: REPO_ROOT,
3230
+ command: ensureNativeSidecarBinary(),
3231
+ args: [],
3232
+ });
3233
+ // Track the child immediately — BEFORE the handshake await — so a
3234
+ // failed `authenticateAndOpenSession()` can still reap it (otherwise
3235
+ // the spawned child is untracked, unreapable, and pins the loop).
3236
+ state.sharedChild = sidecarChildHandle(client);
3237
+ if (!state.sharedChild) {
3238
+ // We reached into @rivet-dev/agentos-runtime-core internals to get the child for
3239
+ // idle-unref. If that shape ever changes this returns undefined and
3240
+ // the optimization silently stops working (one-shot scripts would
3241
+ // hang again). Make it loud rather than a silent regression.
3242
+ console.warn("[agentos] could not resolve the shared sidecar child handle; " +
3243
+ "standalone scripts may not exit cleanly after dispose(). " +
3244
+ "This usually means @rivet-dev/agentos-runtime-core internals changed.");
3245
+ }
3246
+ // Apply the current hold state to the just-spawned child.
3247
+ applySharedSidecarHold(state);
3248
+ try {
3249
+ const session = await client.authenticateAndOpenSession();
3250
+ return { client, session };
3251
+ }
3252
+ catch (error) {
3253
+ // Spawn/handshake failed: reap the child, drop the cached handle,
3254
+ // and CLEAR the rejected promise so the next create() retries
3255
+ // instead of permanently wedging on a rejected `nativeProcess`.
3256
+ try {
3257
+ state.sharedChild?.kill?.("SIGKILL");
3258
+ }
3259
+ catch {
3260
+ // already gone
3261
+ }
3262
+ state.sharedChild = undefined;
3263
+ state.nativeProcess = undefined;
3264
+ throw error;
3265
+ }
3266
+ })();
3267
+ }
3268
+ return state.nativeProcess;
3269
+ }
3270
+ /** Dispose a sidecar handle's shared native process, if one was spawned. */
3271
+ async function disposeSharedSidecarNativeProcess(state) {
3272
+ const pending = state.nativeProcess;
3273
+ if (!pending) {
3274
+ return;
3275
+ }
3276
+ state.nativeProcess = undefined;
3277
+ // The cached child is now dead; drop it (symmetric with the assignment in
3278
+ // ensureSharedSidecarNativeProcess). We deliberately do NOT zero
3279
+ // `eventLoopHolds` here: this runs only from `AgentOsSidecar.dispose()`, which
3280
+ // has already set the handle to `disposing` (so no new lease can acquire) and
3281
+ // drained `activeLeases`; the disposed handle's state is then abandoned. Force-
3282
+ // zeroing a shared counter could clobber a hold on a freshly re-acquired
3283
+ // process generation, so it is left to the balanced acquire/release pairs.
3284
+ state.sharedChild = undefined;
3285
+ try {
3286
+ const { client } = await pending;
3287
+ await client.dispose();
3288
+ }
3289
+ catch {
3290
+ // Process may have already exited; nothing to reclaim.
3291
+ }
3292
+ }
3439
3293
  export class AgentOsSidecar {
3440
3294
  constructor(sidecarId, placement, sharedPool) {
3441
3295
  sidecarStates.set(this, {
@@ -3470,6 +3324,8 @@ export class AgentOsSidecar {
3470
3324
  }
3471
3325
  state.activeLeases.clear();
3472
3326
  state.description.activeVmCount = 0;
3327
+ // Tear down the shared native process after all leased VMs are gone.
3328
+ await disposeSharedSidecarNativeProcess(state);
3473
3329
  state.description.state = "disposed";
3474
3330
  if (state.sharedPool && sharedSidecars.get(state.sharedPool) === this) {
3475
3331
  sharedSidecars.delete(state.sharedPool);
@@ -3526,15 +3382,27 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3526
3382
  }
3527
3383
  let transport;
3528
3384
  const client = createAgentOsSidecarClient({
3529
- async createSessionTransport(sessionBootstrap) {
3385
+ async createOwnershipTransport(sessionBootstrap) {
3530
3386
  transport = await createInProcessSidecarTransport(sessionBootstrap, options);
3531
3387
  return transport;
3532
3388
  },
3533
3389
  });
3390
+ // Hold the shared sidecar's event-loop ref for this lease's WHOLE lifetime —
3391
+ // taken now, before VM creation, so a concurrent dispose cannot unref the
3392
+ // sidecar while this create is still in flight. Released exactly once on
3393
+ // dispose or on a failed create.
3394
+ acquireSharedSidecarHold(state);
3395
+ let holdReleased = false;
3396
+ const releaseHold = () => {
3397
+ if (holdReleased)
3398
+ return;
3399
+ holdReleased = true;
3400
+ releaseSharedSidecarHold(state);
3401
+ };
3534
3402
  let disposed = false;
3535
3403
  let leaseRecord;
3536
3404
  try {
3537
- const session = await client.createSession({
3405
+ const session = await client.createOwnershipSession({
3538
3406
  placement: cloneSidecarPlacement(state.description.placement),
3539
3407
  });
3540
3408
  const vm = await session.createVm();
@@ -3555,6 +3423,10 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3555
3423
  state.activeLeases.delete(leaseRecord);
3556
3424
  state.description.activeVmCount = state.activeLeases.size;
3557
3425
  await client.dispose();
3426
+ // Release this lease's hold; the shared sidecar is unref'd only
3427
+ // once the last hold (across all in-flight + active leases) drops,
3428
+ // so a one-shot host process can then exit on its own.
3429
+ releaseHold();
3558
3430
  },
3559
3431
  };
3560
3432
  leaseRecord = {
@@ -3566,6 +3438,7 @@ async function leaseAgentOsSidecarVm(sidecar, options) {
3566
3438
  }
3567
3439
  catch (error) {
3568
3440
  await client.dispose().catch(() => { });
3441
+ releaseHold();
3569
3442
  throw error;
3570
3443
  }
3571
3444
  }