@desplega.ai/agent-swarm 1.86.0 → 1.88.0

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 (89) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +84 -1
  3. package/package.json +7 -5
  4. package/src/be/db-queries/tracker.ts +21 -0
  5. package/src/be/db.ts +284 -21
  6. package/src/be/migrations/079_task_followup_config.sql +1 -0
  7. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  8. package/src/be/modelsdev-cache.json +77652 -73973
  9. package/src/be/seed/registry.ts +3 -2
  10. package/src/be/seed-skills/index.ts +172 -0
  11. package/src/cli.tsx +55 -0
  12. package/src/commands/context-preamble.ts +272 -0
  13. package/src/commands/e2b-stack-wizard.tsx +394 -0
  14. package/src/commands/e2b.ts +2027 -0
  15. package/src/commands/onboard/dashboard-url.ts +29 -0
  16. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  17. package/src/commands/onboard.tsx +3 -1
  18. package/src/commands/resume-session.ts +35 -78
  19. package/src/commands/runner.ts +126 -13
  20. package/src/e2b/dispatch.ts +645 -0
  21. package/src/e2b/env.ts +206 -0
  22. package/src/heartbeat/heartbeat.ts +145 -30
  23. package/src/heartbeat/templates.ts +11 -7
  24. package/src/http/memory.ts +13 -1
  25. package/src/http/session-data.ts +8 -1
  26. package/src/http/skills.ts +53 -0
  27. package/src/http/tasks.ts +152 -3
  28. package/src/http/webhooks.ts +75 -0
  29. package/src/integrations/kapso/client.ts +82 -0
  30. package/src/jira/sync.ts +4 -4
  31. package/src/linear/sync.ts +6 -5
  32. package/src/memory/automatic-task-gate.ts +47 -0
  33. package/src/prompts/base-prompt.ts +16 -1
  34. package/src/prompts/session-templates.ts +51 -0
  35. package/src/providers/claude-adapter.ts +29 -76
  36. package/src/providers/claude-managed-adapter.ts +61 -75
  37. package/src/providers/codex-adapter.ts +37 -18
  38. package/src/providers/codex-oauth/auth-json.ts +18 -1
  39. package/src/providers/codex-oauth/flow.ts +24 -1
  40. package/src/providers/ctx-mode-env.ts +10 -0
  41. package/src/providers/opencode-adapter.ts +50 -1
  42. package/src/providers/types.ts +6 -0
  43. package/src/slack/blocks.ts +12 -4
  44. package/src/slack/watcher.ts +3 -3
  45. package/src/tasks/worker-follow-up.ts +162 -2
  46. package/src/telemetry.ts +25 -2
  47. package/src/templates.d.ts +4 -0
  48. package/src/tests/base-prompt.test.ts +41 -0
  49. package/src/tests/claude-adapter.test.ts +87 -24
  50. package/src/tests/claude-managed-adapter.test.ts +38 -52
  51. package/src/tests/codex-adapter.test.ts +95 -31
  52. package/src/tests/codex-oauth.test.ts +149 -3
  53. package/src/tests/codex-pool.test.ts +14 -3
  54. package/src/tests/e2b-dispatch.test.ts +922 -0
  55. package/src/tests/heartbeat-supersede-resume.test.ts +285 -0
  56. package/src/tests/heartbeat.test.ts +26 -16
  57. package/src/tests/http-api-integration.test.ts +113 -0
  58. package/src/tests/kapso-client.test.ts +74 -1
  59. package/src/tests/kapso-inbound.test.ts +60 -2
  60. package/src/tests/opencode-adapter.test.ts +95 -0
  61. package/src/tests/prompt-template-remaining.test.ts +4 -0
  62. package/src/tests/prompt-template-session.test.ts +4 -2
  63. package/src/tests/resume-session.test.ts +42 -50
  64. package/src/tests/self-improvement.test.ts +89 -0
  65. package/src/tests/skill-update-scope.test.ts +88 -1
  66. package/src/tests/slack-blocks.test.ts +15 -0
  67. package/src/tests/structured-output.test.ts +69 -0
  68. package/src/tests/system-default-skills.test.ts +119 -0
  69. package/src/tests/task-completion-idempotency.test.ts +185 -2
  70. package/src/tests/task-supersede-resume.test.ts +722 -0
  71. package/src/tests/telemetry-init.test.ts +155 -0
  72. package/src/tests/vcs-tracking.test.ts +39 -0
  73. package/src/tools/send-task.ts +12 -1
  74. package/src/tools/skills/skill-delete.ts +14 -0
  75. package/src/tools/skills/skill-update.ts +14 -0
  76. package/src/tools/store-progress.ts +21 -7
  77. package/src/tools/templates.ts +14 -2
  78. package/src/types.ts +47 -1
  79. package/src/workflows/executors/agent-task.ts +3 -0
  80. package/templates/skills/artifacts/config.json +1 -0
  81. package/templates/skills/kv-storage/config.json +1 -0
  82. package/templates/skills/pages/config.json +1 -0
  83. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  84. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  85. package/templates/skills/swarm-scripts/config.json +14 -0
  86. package/templates/skills/swarm-scripts/content.md +86 -0
  87. package/templates/skills/workflow-iterate/config.json +1 -0
  88. package/templates/skills/workflow-structured-output/config.json +1 -0
  89. package/tsconfig.json +2 -1
@@ -0,0 +1,645 @@
1
+ import { DEFAULT_E2B_API_BASE, type EnvMap, redactWithEnv } from "./env";
2
+
3
+ export type E2BRole = "api" | "worker";
4
+
5
+ export type E2BSandboxInfo = {
6
+ templateID: string;
7
+ sandboxID: string;
8
+ clientID?: string;
9
+ envdVersion?: string;
10
+ alias?: string;
11
+ envdAccessToken?: string;
12
+ trafficAccessToken?: string;
13
+ domain?: string | null;
14
+ startedAt?: string;
15
+ endAt?: string;
16
+ metadata?: Record<string, string>;
17
+ // Client-side fallback for the sandbox expiry. The raw `POST /sandboxes`
18
+ // create response uses E2B's `Sandbox` schema, which (unlike `ListedSandbox`
19
+ // / `SandboxDetail`) does NOT include `endAt`. We populate this from
20
+ // `now + timeoutSec*1000` at create time so `ttlRemaining` can report expiry
21
+ // immediately after a launch without an extra round-trip. `endAt` (when
22
+ // present, e.g. from `listSandboxes`) is always authoritative over this.
23
+ expiresAt?: string;
24
+ };
25
+
26
+ export type TtlRemaining = {
27
+ expiresAt?: string;
28
+ secondsLeft?: number;
29
+ };
30
+
31
+ export type SetSandboxTimeoutOptions = {
32
+ sandboxId: string;
33
+ apiKey: string;
34
+ apiBase?: string;
35
+ e2bEnv?: EnvMap;
36
+ timeoutMs: number;
37
+ };
38
+
39
+ export type E2BCommandResult = {
40
+ exitCode: number;
41
+ stdout: string;
42
+ stderr: string;
43
+ };
44
+
45
+ export type BuildTemplateOptions = {
46
+ role: E2BRole;
47
+ name: string;
48
+ dockerfile: string;
49
+ cwd: string;
50
+ cpuCount: number;
51
+ memoryMb: number;
52
+ noCache: boolean;
53
+ e2bEnv: EnvMap;
54
+ dryRun?: boolean;
55
+ };
56
+
57
+ export type DeleteTemplateOptions = {
58
+ name: string;
59
+ e2bEnv: EnvMap;
60
+ dryRun?: boolean;
61
+ };
62
+
63
+ export type TemplateVisibilityOptions = {
64
+ name: string;
65
+ e2bEnv: EnvMap;
66
+ public: boolean;
67
+ dryRun?: boolean;
68
+ };
69
+
70
+ export type BuildImageTemplateOptions = {
71
+ role: E2BRole;
72
+ name: string;
73
+ image: string;
74
+ cpuCount: number;
75
+ memoryMb: number;
76
+ noCache: boolean;
77
+ e2bEnv: EnvMap;
78
+ dryRun?: boolean;
79
+ };
80
+
81
+ export type CreateSandboxOptions = {
82
+ apiKey: string;
83
+ apiBase?: string;
84
+ template: string;
85
+ timeoutSec: number;
86
+ envVars: EnvMap;
87
+ metadata: Record<string, string>;
88
+ allowInternetAccess?: boolean;
89
+ };
90
+
91
+ export type StartDetachedOptions = {
92
+ sandbox: E2BSandboxInfo;
93
+ apiKey: string;
94
+ apiBase?: string;
95
+ e2bEnv?: EnvMap;
96
+ env: EnvMap;
97
+ command: string;
98
+ role: E2BRole;
99
+ user?: string;
100
+ cwd?: string;
101
+ };
102
+
103
+ export type StreamSandboxLogOptions = {
104
+ sandboxId: string;
105
+ role: E2BRole;
106
+ apiKey: string;
107
+ apiBase?: string;
108
+ e2bEnv?: EnvMap;
109
+ /** Number of trailing history lines to emit before following (default 200). */
110
+ tailLines?: number;
111
+ /** When true, keep streaming new output (`tail -f`) until the caller aborts. */
112
+ follow?: boolean;
113
+ /**
114
+ * Egress sink for each chunk. The caller MUST scrub here — log output is
115
+ * untrusted entrypoint stdout and can embed tokens/secrets.
116
+ */
117
+ onChunk: (chunk: string) => void;
118
+ /** Abort signal to stop a `--follow` stream (e.g. on SIGINT). */
119
+ signal?: AbortSignal;
120
+ };
121
+
122
+ type E2BSdkConnectionOptions = {
123
+ apiKey: string;
124
+ apiUrl?: string;
125
+ domain?: string;
126
+ sandboxUrl?: string;
127
+ };
128
+
129
+ type E2BTemplateVisibilityResponse = {
130
+ names: string[];
131
+ };
132
+
133
+ function e2bHeaders(apiKey: string): Record<string, string> {
134
+ return {
135
+ "Content-Type": "application/json",
136
+ "X-API-Key": apiKey,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Build the shell payload for the envd-tracked entrypoint launch. Phase 5: the
142
+ * entrypoint is no longer detached via `nohup … >file &` (a grandchild envd
143
+ * never sees). Instead it runs as the SDK background command itself (envd owns
144
+ * and streams it; it survives client disconnect). We still `tee` to a
145
+ * deterministic file so `swarms logs` can retrieve FULL history later: the SDK's
146
+ * `commands.connect(pid)` only streams output going forward from the connect
147
+ * instant — it does NOT replay stdout produced while disconnected (verified
148
+ * against the e2b SDK types + docs) — so the file copy is the only reliable
149
+ * full-history source.
150
+ *
151
+ * `set -o pipefail` makes the pipeline's exit code reflect the ENTRYPOINT rather
152
+ * than `tee` (tee exits 0 on EOF even if the entrypoint crashed), so the early
153
+ * `exitCode` poll in {@link startDetachedProcess} can detect a launch failure.
154
+ * Invoked via `bash -lc` (both the api + worker images ship bash) for pipefail.
155
+ */
156
+ export function buildTrackedShell(command: string, logPath: string): string {
157
+ return `set -o pipefail; ${command} 2>&1 | tee ${logPath}`;
158
+ }
159
+
160
+ export function e2bSdkConnectionOptions(
161
+ apiKey: string,
162
+ env: EnvMap,
163
+ apiBase?: string,
164
+ ): E2BSdkConnectionOptions {
165
+ const options: E2BSdkConnectionOptions = { apiKey };
166
+ const resolvedApiUrl = apiBase || env.E2B_API_URL;
167
+ if (resolvedApiUrl) options.apiUrl = resolvedApiUrl;
168
+ if (env.E2B_DOMAIN) options.domain = env.E2B_DOMAIN;
169
+ if (env.E2B_SANDBOX_URL) options.sandboxUrl = env.E2B_SANDBOX_URL;
170
+ return options;
171
+ }
172
+
173
+ function sandboxDomainFromUrl(rawUrl: string): string | undefined {
174
+ try {
175
+ const url = new URL(rawUrl);
176
+ const host = url.host;
177
+ return host.startsWith("sandbox.") ? host.slice("sandbox.".length) : host;
178
+ } catch {
179
+ const host = rawUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
180
+ if (!host) return undefined;
181
+ return host.startsWith("sandbox.") ? host.slice("sandbox.".length) : host;
182
+ }
183
+ }
184
+
185
+ function configuredSandboxDomain(env: EnvMap): string | undefined {
186
+ if (env.E2B_DOMAIN) return env.E2B_DOMAIN;
187
+ if (env.E2B_SANDBOX_URL) return sandboxDomainFromUrl(env.E2B_SANDBOX_URL);
188
+ return undefined;
189
+ }
190
+
191
+ export function sandboxPortHost(sandbox: E2BSandboxInfo, port: number, env: EnvMap = {}): string {
192
+ const domain = sandbox.domain || configuredSandboxDomain(env) || "e2b.app";
193
+ if (domain.includes(sandbox.sandboxID)) {
194
+ return `${port}-${domain}`;
195
+ }
196
+ return `${port}-${sandbox.sandboxID}.${domain}`;
197
+ }
198
+
199
+ export function sandboxPortUrl(sandbox: E2BSandboxInfo, port: number, env: EnvMap = {}): string {
200
+ return `https://${sandboxPortHost(sandbox, port, env)}`;
201
+ }
202
+
203
+ async function readResponseBody(response: Response): Promise<string> {
204
+ const text = await response.text();
205
+ return text.trim();
206
+ }
207
+
208
+ export async function e2bFetchJson<T>(
209
+ path: string,
210
+ apiKey: string,
211
+ init: RequestInit = {},
212
+ apiBase = DEFAULT_E2B_API_BASE,
213
+ ): Promise<T> {
214
+ const response = await fetch(`${apiBase}${path}`, {
215
+ ...init,
216
+ headers: {
217
+ ...e2bHeaders(apiKey),
218
+ ...(init.headers ?? {}),
219
+ },
220
+ });
221
+
222
+ if (!response.ok) {
223
+ const body = await readResponseBody(response);
224
+ throw new Error(`E2B API ${response.status} ${response.statusText}: ${body}`);
225
+ }
226
+
227
+ if (response.status === 204) {
228
+ return undefined as T;
229
+ }
230
+
231
+ return (await response.json()) as T;
232
+ }
233
+
234
+ export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSandboxInfo> {
235
+ // Capture the wall-clock create instant BEFORE the request so the client-side
236
+ // expiry fallback reflects when the TTL countdown begins.
237
+ const createdAt = Date.now();
238
+ const sandbox = await e2bFetchJson<E2BSandboxInfo>(
239
+ "/sandboxes",
240
+ opts.apiKey,
241
+ {
242
+ method: "POST",
243
+ body: JSON.stringify({
244
+ templateID: opts.template,
245
+ timeout: opts.timeoutSec,
246
+ secure: true,
247
+ allow_internet_access: opts.allowInternetAccess ?? true,
248
+ metadata: opts.metadata,
249
+ envVars: opts.envVars,
250
+ }),
251
+ },
252
+ opts.apiBase,
253
+ );
254
+ // Pre-flight check (resolved against node_modules/e2b types): the create
255
+ // response is E2B's `Sandbox` schema, which omits `endAt`. Compute a
256
+ // client-side expiry fallback so `ttlRemaining` works right after launch.
257
+ if (!sandbox.endAt && !sandbox.expiresAt) {
258
+ sandbox.expiresAt = new Date(createdAt + opts.timeoutSec * 1000).toISOString();
259
+ }
260
+ return sandbox;
261
+ }
262
+
263
+ /**
264
+ * Compute the remaining time-to-live for a sandbox. Prefers the authoritative
265
+ * `endAt` (present on listed/detail responses); falls back to the client-side
266
+ * `expiresAt` stamped by `createSandbox`. Returns an empty object when neither
267
+ * is available (e.g. a dry-run fake sandbox). `secondsLeft` is clamped at 0 so
268
+ * an already-expired sandbox never reports negative time.
269
+ */
270
+ export function ttlRemaining(sandbox: E2BSandboxInfo): TtlRemaining {
271
+ const expiresAt = sandbox.endAt ?? sandbox.expiresAt;
272
+ if (!expiresAt) return {};
273
+ const expiryMs = Date.parse(expiresAt);
274
+ if (Number.isNaN(expiryMs)) return {};
275
+ const secondsLeft = Math.max(0, Math.round((expiryMs - Date.now()) / 1000));
276
+ return { expiresAt, secondsLeft };
277
+ }
278
+
279
+ /**
280
+ * Extend (or reduce) a live sandbox's TTL via the SDK and read back the actual
281
+ * `endAt` E2B applied (the server clamps to the tier max, so the requested
282
+ * timeout is not always honored verbatim). Connecting to a dead/expired sandbox
283
+ * throws; we translate that into a redacted "not found / already expired"
284
+ * error so a stale sandbox ID never leaks the controller key into logs.
285
+ */
286
+ export async function setSandboxTimeout(opts: SetSandboxTimeoutOptions): Promise<TtlRemaining> {
287
+ const { Sandbox } = await import("e2b");
288
+ let sandbox: Awaited<ReturnType<typeof Sandbox.connect>>;
289
+ try {
290
+ sandbox = await Sandbox.connect(
291
+ opts.sandboxId,
292
+ e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
293
+ );
294
+ } catch {
295
+ // Do not surface the underlying error verbatim — it can embed the
296
+ // controller API key / connection URL. Emit a fixed redacted message.
297
+ throw new Error(`sandbox ${opts.sandboxId} not found / already expired`);
298
+ }
299
+
300
+ await sandbox.setTimeout(opts.timeoutMs);
301
+ // `setTimeout` returns void; re-read the info to learn the clamped expiry.
302
+ const info = await sandbox.getInfo();
303
+ const expiresAt = info.endAt instanceof Date ? info.endAt.toISOString() : String(info.endAt);
304
+ return ttlRemaining({
305
+ sandboxID: opts.sandboxId,
306
+ templateID: info.templateId,
307
+ endAt: expiresAt,
308
+ });
309
+ }
310
+
311
+ export async function killSandbox(
312
+ sandboxId: string,
313
+ apiKey: string,
314
+ apiBase = DEFAULT_E2B_API_BASE,
315
+ ): Promise<void> {
316
+ await e2bFetchJson<void>(
317
+ `/sandboxes/${encodeURIComponent(sandboxId)}`,
318
+ apiKey,
319
+ { method: "DELETE" },
320
+ apiBase,
321
+ );
322
+ }
323
+
324
+ export async function listSandboxes(
325
+ apiKey: string,
326
+ apiBase = DEFAULT_E2B_API_BASE,
327
+ ): Promise<E2BSandboxInfo[]> {
328
+ return e2bFetchJson<E2BSandboxInfo[]>("/sandboxes", apiKey, {}, apiBase);
329
+ }
330
+
331
+ /**
332
+ * The deterministic per-role log path the entrypoint tees to. `swarms logs`
333
+ * recomputes it from the role alone (no PID bookkeeping needed) to `tail`/`cat`
334
+ * full history or `tail -f` for `--follow`.
335
+ */
336
+ export function sandboxLogPath(role: E2BRole): string {
337
+ return `/tmp/agent-swarm-e2b-${role}.log`;
338
+ }
339
+
340
+ /**
341
+ * Launch the entrypoint as an envd-tracked BACKGROUND command (Phase 5). Returns
342
+ * the PID immediately. Replaces the old `nohup … >file & sleep 2; kill -0` hack:
343
+ * the SDK's background handle exposes `exitCode` (undefined while running), so we
344
+ * poll it once after a short grace period — a non-zero exit by then means the
345
+ * entrypoint died at launch, which we surface as a launch failure (reading the
346
+ * tee'd log for context). The `tee` preserves a file copy for full-history
347
+ * retrieval regardless of envd stdout-replay semantics.
348
+ */
349
+ export async function startDetachedProcess(opts: StartDetachedOptions): Promise<string> {
350
+ const logPath = sandboxLogPath(opts.role);
351
+ const shell = buildTrackedShell(opts.command, logPath);
352
+
353
+ const { Sandbox } = await import("e2b");
354
+ const sandbox = await Sandbox.connect(
355
+ opts.sandbox.sandboxID,
356
+ e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
357
+ );
358
+ // `bash -lc` (not `sh`) so `set -o pipefail` is honored on both images.
359
+ const handle = await sandbox.commands.run(`bash -lc ${shellQuote(shell)}`, {
360
+ user: opts.user ?? "root",
361
+ cwd: opts.cwd ?? "/",
362
+ envs: opts.env,
363
+ background: true,
364
+ });
365
+
366
+ // Early liveness poll: give the entrypoint a moment to fault, then check the
367
+ // handle's exit code. `undefined` = still running (the expected happy path).
368
+ await Bun.sleep(2_000);
369
+ if (typeof handle.exitCode === "number" && handle.exitCode !== 0) {
370
+ // The pipeline already exited non-zero — surface stderr/stdout (redacted, as
371
+ // entrypoint output can embed tokens) as a launch failure.
372
+ const detail = redactWithEnv(`${handle.stdout}\n${handle.stderr}`.trim(), opts.env);
373
+ throw new Error(`E2B start command exited ${handle.exitCode} at launch: ${detail}`);
374
+ }
375
+
376
+ return String(handle.pid);
377
+ }
378
+
379
+ /** Single-quote a string for safe embedding in a `bash -lc '<...>'` invocation. */
380
+ function shellQuote(value: string): string {
381
+ return `'${value.split("'").join(`'\\''`)}'`;
382
+ }
383
+
384
+ /**
385
+ * Stream a sandbox's tee'd entrypoint log to the caller's `onChunk` sink.
386
+ *
387
+ * Design (Phase 5): we read from the deterministic per-role {@link sandboxLogPath}
388
+ * the entrypoint tees to — NOT from a tracked PID — so no PID bookkeeping is
389
+ * needed and history survives reconnect / a fresh CLI process. The SDK's
390
+ * `commands.connect(pid)` only streams forward from connect (no historical
391
+ * replay, verified against the SDK), so the file is the source of truth for
392
+ * full history.
393
+ *
394
+ * - History (no `--follow`): `tail -n <N> <logPath>` once (a CommandResult).
395
+ * - Follow: `tail -n <N> -F <logPath>` as a BACKGROUND command, piping each
396
+ * `onStdout`/`onStderr` chunk to `onChunk` until the abort signal fires
397
+ * (`-F` keeps following across truncation/rotation; tolerates a not-yet-created
398
+ * file). The caller scrubs inside `onChunk`.
399
+ *
400
+ * Output is emitted RAW here; the caller is responsible for scrubbing in
401
+ * `onChunk` (it sees both this function's stdout and stderr).
402
+ */
403
+ export async function streamSandboxLog(opts: StreamSandboxLogOptions): Promise<void> {
404
+ const logPath = sandboxLogPath(opts.role);
405
+ const tailLines = opts.tailLines ?? 200;
406
+
407
+ const { Sandbox } = await import("e2b");
408
+ const sandbox = await Sandbox.connect(
409
+ opts.sandboxId,
410
+ e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
411
+ );
412
+
413
+ if (!opts.follow) {
414
+ // History only: a single `tail`. If the file does not exist yet (entrypoint
415
+ // hasn't written), `tail` exits non-zero with a message on stderr — we emit
416
+ // that to the sink rather than throwing, so a freshly-launched swarm reads as
417
+ // "no logs yet" instead of a hard error.
418
+ const result = await sandbox.commands.run(
419
+ `bash -lc ${shellQuote(`tail -n ${tailLines} ${logPath} 2>&1 || true`)}`,
420
+ { user: "root", timeoutMs: 30_000 },
421
+ );
422
+ if (result.stdout) opts.onChunk(result.stdout);
423
+ if (result.stderr) opts.onChunk(result.stderr);
424
+ return;
425
+ }
426
+
427
+ // Follow: background `tail -F` streaming forward. `-F` (vs `-f`) re-opens the
428
+ // file if it is rotated/recreated and waits for a not-yet-existing file.
429
+ const handle = await sandbox.commands.run(
430
+ `bash -lc ${shellQuote(`tail -n ${tailLines} -F ${logPath}`)}`,
431
+ {
432
+ user: "root",
433
+ background: true,
434
+ onStdout: (data) => opts.onChunk(data),
435
+ onStderr: (data) => opts.onChunk(data),
436
+ },
437
+ );
438
+
439
+ const stop = async () => {
440
+ try {
441
+ await handle.kill();
442
+ } catch {
443
+ // The command may already be gone (sandbox killed/expired); ignore.
444
+ }
445
+ };
446
+ if (opts.signal) {
447
+ if (opts.signal.aborted) {
448
+ await stop();
449
+ return;
450
+ }
451
+ opts.signal.addEventListener("abort", stop, { once: true });
452
+ }
453
+
454
+ try {
455
+ // `wait()` resolves when the stream ends (sandbox death) or the handle is
456
+ // killed by the abort listener above. `tail -F` otherwise runs indefinitely.
457
+ await handle.wait();
458
+ } catch {
459
+ // A kill / disconnect surfaces as a rejected wait — that is the expected exit
460
+ // path for `--follow`, not an error to propagate.
461
+ }
462
+ }
463
+
464
+ export async function waitForAgentRegistration(
465
+ apiUrl: string,
466
+ agentId: string,
467
+ apiKey: string,
468
+ timeoutMs: number,
469
+ ): Promise<void> {
470
+ const baseUrl = apiUrl.replace(/\/+$/, "");
471
+ const url = `${baseUrl}/api/agents/${encodeURIComponent(agentId)}`;
472
+ const started = Date.now();
473
+ let lastError = "";
474
+
475
+ while (Date.now() - started < timeoutMs) {
476
+ try {
477
+ const response = await fetch(url, {
478
+ headers: {
479
+ Authorization: `Bearer ${apiKey}`,
480
+ },
481
+ });
482
+ if (response.ok) return;
483
+ lastError = `${response.status} ${response.statusText}`;
484
+ } catch (err) {
485
+ lastError = err instanceof Error ? err.message : String(err);
486
+ }
487
+ await Bun.sleep(1_000);
488
+ }
489
+
490
+ throw new Error(
491
+ `Timed out waiting for worker ${agentId} to register at ${url}${
492
+ lastError ? ` (${lastError})` : ""
493
+ }`,
494
+ );
495
+ }
496
+
497
+ export async function waitForHttpOk(url: string, timeoutMs: number): Promise<void> {
498
+ const started = Date.now();
499
+ let lastError = "";
500
+ while (Date.now() - started < timeoutMs) {
501
+ try {
502
+ const response = await fetch(url);
503
+ if (response.ok) return;
504
+ lastError = `${response.status} ${response.statusText}`;
505
+ } catch (err) {
506
+ lastError = err instanceof Error ? err.message : String(err);
507
+ }
508
+ await Bun.sleep(1_000);
509
+ }
510
+ throw new Error(`Timed out waiting for ${url}${lastError ? ` (${lastError})` : ""}`);
511
+ }
512
+
513
+ export function buildTemplateArgs(opts: BuildTemplateOptions): string[] {
514
+ const args = [
515
+ "template",
516
+ "create",
517
+ "-p",
518
+ opts.cwd,
519
+ "-d",
520
+ opts.dockerfile,
521
+ "-c",
522
+ "sleep infinity",
523
+ "--ready-cmd",
524
+ "sleep 0",
525
+ "--cpu-count",
526
+ String(opts.cpuCount),
527
+ "--memory-mb",
528
+ String(opts.memoryMb),
529
+ ];
530
+
531
+ if (opts.noCache) {
532
+ args.push("--no-cache");
533
+ }
534
+
535
+ args.push(opts.name);
536
+ return args;
537
+ }
538
+
539
+ export async function runE2BCommand(args: string[], env: EnvMap): Promise<E2BCommandResult> {
540
+ const child = Bun.spawn(["e2b", ...args], {
541
+ env: { ...process.env, ...env },
542
+ stdout: "pipe",
543
+ stderr: "pipe",
544
+ });
545
+ const [stdout, stderr, exitCode] = await Promise.all([
546
+ new Response(child.stdout).text(),
547
+ new Response(child.stderr).text(),
548
+ child.exited,
549
+ ]);
550
+ return { stdout: redactWithEnv(stdout, env), stderr: redactWithEnv(stderr, env), exitCode };
551
+ }
552
+
553
+ export async function buildTemplate(opts: BuildTemplateOptions): Promise<E2BCommandResult> {
554
+ const args = buildTemplateArgs(opts);
555
+ if (opts.dryRun) {
556
+ return { exitCode: 0, stdout: `e2b ${args.join(" ")}\n`, stderr: "" };
557
+ }
558
+ return runE2BCommand(args, opts.e2bEnv);
559
+ }
560
+
561
+ export async function buildImageTemplate(
562
+ opts: BuildImageTemplateOptions,
563
+ ): Promise<E2BCommandResult> {
564
+ if (opts.dryRun) {
565
+ return {
566
+ exitCode: 0,
567
+ stdout: [
568
+ `e2b-sdk template build --from-image ${opts.image}`,
569
+ ` --name ${opts.name}`,
570
+ ` --start-cmd "sleep infinity"`,
571
+ ` --ready-cmd "sleep 0"`,
572
+ ` --cpu-count ${opts.cpuCount}`,
573
+ ` --memory-mb ${opts.memoryMb}`,
574
+ opts.noCache ? ` --no-cache` : "",
575
+ ]
576
+ .filter(Boolean)
577
+ .join("\n")
578
+ .concat("\n"),
579
+ stderr: "",
580
+ };
581
+ }
582
+
583
+ const apiKey = opts.e2bEnv.E2B_API_KEY;
584
+ if (!apiKey) {
585
+ throw new Error("Missing E2B_API_KEY");
586
+ }
587
+
588
+ const { Template } = await import("e2b");
589
+ const template = Template().fromImage(opts.image).setStartCmd("sleep infinity", "sleep 0");
590
+ const buildInfo = await Template.build(template, opts.name, {
591
+ ...e2bSdkConnectionOptions(apiKey, opts.e2bEnv),
592
+ cpuCount: opts.cpuCount,
593
+ memoryMB: opts.memoryMb,
594
+ skipCache: opts.noCache,
595
+ });
596
+
597
+ return {
598
+ exitCode: 0,
599
+ stdout: `Built E2B ${opts.role} template ${buildInfo.name} (${buildInfo.templateId}, build ${buildInfo.buildId})\n`,
600
+ stderr: "",
601
+ };
602
+ }
603
+
604
+ export async function deleteTemplate(opts: DeleteTemplateOptions): Promise<E2BCommandResult> {
605
+ const args = ["template", "delete", opts.name, "-y"];
606
+ if (opts.dryRun) {
607
+ return { exitCode: 0, stdout: `e2b ${args.join(" ")}\n`, stderr: "" };
608
+ }
609
+ return runE2BCommand(args, opts.e2bEnv);
610
+ }
611
+
612
+ export async function setTemplateVisibility(
613
+ opts: TemplateVisibilityOptions,
614
+ ): Promise<E2BCommandResult> {
615
+ const path = `/v2/templates/${encodeURIComponent(opts.name)}`;
616
+ if (opts.dryRun) {
617
+ return {
618
+ exitCode: 0,
619
+ stdout: `PATCH ${path} {"public":${opts.public}}\n`,
620
+ stderr: "",
621
+ };
622
+ }
623
+
624
+ const apiKey = opts.e2bEnv.E2B_API_KEY;
625
+ if (!apiKey) {
626
+ throw new Error("Missing E2B_API_KEY");
627
+ }
628
+
629
+ const result = await e2bFetchJson<E2BTemplateVisibilityResponse>(
630
+ path,
631
+ apiKey,
632
+ {
633
+ method: "PATCH",
634
+ body: JSON.stringify({ public: opts.public }),
635
+ },
636
+ opts.e2bEnv.E2B_API_URL || DEFAULT_E2B_API_BASE,
637
+ );
638
+ const names = result.names.length > 0 ? ` (${result.names.join(", ")})` : "";
639
+ const visibility = opts.public ? "public" : "private";
640
+ return {
641
+ exitCode: 0,
642
+ stdout: `Set E2B template ${opts.name} visibility to ${visibility}${names}\n`,
643
+ stderr: "",
644
+ };
645
+ }