@desplega.ai/agent-swarm 1.87.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 (59) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +13 -1
  3. package/package.json +5 -5
  4. package/src/be/db.ts +49 -7
  5. package/src/be/migrations/080_skill_system_defaults.sql +8 -0
  6. package/src/be/modelsdev-cache.json +1123 -1034
  7. package/src/be/seed/registry.ts +3 -2
  8. package/src/be/seed-skills/index.ts +172 -0
  9. package/src/cli.tsx +33 -4
  10. package/src/commands/e2b-stack-wizard.tsx +394 -0
  11. package/src/commands/e2b.ts +1352 -53
  12. package/src/commands/onboard/dashboard-url.ts +29 -0
  13. package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
  14. package/src/commands/onboard.tsx +3 -1
  15. package/src/commands/runner.ts +1 -0
  16. package/src/e2b/dispatch.ts +234 -18
  17. package/src/http/memory.ts +13 -1
  18. package/src/http/skills.ts +53 -0
  19. package/src/http/webhooks.ts +75 -0
  20. package/src/integrations/kapso/client.ts +82 -0
  21. package/src/memory/automatic-task-gate.ts +47 -0
  22. package/src/prompts/base-prompt.ts +16 -1
  23. package/src/prompts/session-templates.ts +51 -0
  24. package/src/providers/claude-adapter.ts +19 -0
  25. package/src/providers/codex-adapter.ts +22 -0
  26. package/src/providers/ctx-mode-env.ts +10 -0
  27. package/src/providers/opencode-adapter.ts +50 -1
  28. package/src/slack/blocks.ts +12 -4
  29. package/src/slack/watcher.ts +3 -3
  30. package/src/telemetry.ts +14 -1
  31. package/src/templates.d.ts +4 -0
  32. package/src/tests/base-prompt.test.ts +41 -0
  33. package/src/tests/claude-adapter.test.ts +86 -1
  34. package/src/tests/codex-adapter.test.ts +89 -0
  35. package/src/tests/e2b-dispatch.test.ts +603 -11
  36. package/src/tests/http-api-integration.test.ts +113 -0
  37. package/src/tests/kapso-client.test.ts +74 -1
  38. package/src/tests/kapso-inbound.test.ts +60 -2
  39. package/src/tests/opencode-adapter.test.ts +95 -0
  40. package/src/tests/prompt-template-session.test.ts +4 -2
  41. package/src/tests/self-improvement.test.ts +89 -0
  42. package/src/tests/skill-update-scope.test.ts +88 -1
  43. package/src/tests/slack-blocks.test.ts +15 -0
  44. package/src/tests/system-default-skills.test.ts +119 -0
  45. package/src/tests/telemetry-init.test.ts +86 -0
  46. package/src/tools/skills/skill-delete.ts +14 -0
  47. package/src/tools/skills/skill-update.ts +14 -0
  48. package/src/tools/store-progress.ts +19 -5
  49. package/src/types.ts +1 -0
  50. package/templates/skills/artifacts/config.json +1 -0
  51. package/templates/skills/kv-storage/config.json +1 -0
  52. package/templates/skills/pages/config.json +1 -0
  53. package/templates/skills/scheduled-task-resilience/config.json +1 -0
  54. package/templates/skills/swarm-scripts/SKILL.md +91 -0
  55. package/templates/skills/swarm-scripts/config.json +14 -0
  56. package/templates/skills/swarm-scripts/content.md +86 -0
  57. package/templates/skills/workflow-iterate/config.json +1 -0
  58. package/templates/skills/workflow-structured-output/config.json +1 -0
  59. package/tsconfig.json +2 -1
@@ -1,4 +1,6 @@
1
1
  import { dirname, resolve } from "node:path";
2
+ import { render } from "ink";
3
+ import { createElement } from "react";
2
4
  import {
3
5
  buildImageTemplate,
4
6
  buildTemplate,
@@ -8,8 +10,11 @@ import {
8
10
  killSandbox,
9
11
  listSandboxes,
10
12
  sandboxPortUrl,
13
+ setSandboxTimeout,
11
14
  setTemplateVisibility,
12
15
  startDetachedProcess,
16
+ streamSandboxLog,
17
+ ttlRemaining,
13
18
  waitForAgentRegistration,
14
19
  waitForHttpOk,
15
20
  } from "../e2b/dispatch";
@@ -29,8 +34,20 @@ import {
29
34
  selectEnv,
30
35
  splitKeys,
31
36
  } from "../e2b/env";
32
-
33
- type ParsedFlags = {
37
+ import { getAppUrl } from "../utils/constants";
38
+ import {
39
+ DEFAULT_STACK_TIMEOUT_SEC,
40
+ DEFAULT_STACK_WORKERS,
41
+ STACK_INTEGRATIONS,
42
+ StackWizard,
43
+ type StackWizardDefaults,
44
+ type StackWizardResult,
45
+ type StackWizardSkips,
46
+ SwarmPicker,
47
+ slugify,
48
+ } from "./e2b-stack-wizard.tsx";
49
+
50
+ export type ParsedFlags = {
34
51
  command?: string;
35
52
  positionals: string[];
36
53
  values: Map<string, string[]>;
@@ -43,10 +60,110 @@ type StartedRole = {
43
60
  url?: string;
44
61
  };
45
62
 
46
- const DEFAULT_API_PORT = 3013;
47
- const BOOLEAN_FLAGS = new Set(["dry-run", "json", "no-cache", "no-wait"]);
63
+ /**
64
+ * Env scope for role-scoped secret/env-file layering. A lead is E2B
65
+ * `SwarmRole === "worker"` but gets its own `"lead"` env scope so lead and
66
+ * worker env never cross-contaminate.
67
+ */
68
+ export type EnvScope = "api" | "lead" | "worker";
69
+
70
+ /**
71
+ * The swarm-grouping role stamped onto `metadata.swarmRole`. Distinct from the
72
+ * E2B `SwarmRole` ("api" | "worker") because a lead is E2B `SwarmRole:"worker"`
73
+ * but a separate grouping role for `e2b swarms` purposes. Used by `swarms info`
74
+ * to resolve which sandbox is the API vs lead vs workers.
75
+ */
76
+ export type MetadataSwarmRole = "api" | "lead" | "worker";
77
+
78
+ /**
79
+ * Per-instance launch spec threaded through {@link startRole}. `swarmRole` is
80
+ * the E2B template/entrypoint dimension (api vs worker). `agentRole` is the
81
+ * swarm-side role written to `AGENT_ROLE` (a lead is `swarmRole:"worker"` +
82
+ * `agentRole:"lead"`). `envScope` selects which scoped `--{scope}-env-file` /
83
+ * `--{scope}-secret` flags layer on top of the shared ones.
84
+ */
85
+ export type LaunchSpec = {
86
+ swarmRole: SwarmRole;
87
+ agentRole?: "worker" | "lead";
88
+ envScope: EnvScope;
89
+ /**
90
+ * The grouping role stamped onto `metadata.swarmRole`. Defaults to a sensible
91
+ * value derived from `swarmRole`/`agentRole` (api → "api", lead → "lead",
92
+ * worker → "worker") when omitted. Kept explicit on the spec so the stack's
93
+ * lead and workers tag distinctly even though both are E2B `SwarmRole:"worker"`.
94
+ */
95
+ metadataSwarmRole?: MetadataSwarmRole;
96
+ /**
97
+ * Flag the explicit AGENT_ID override is read from (default `"agent-id"`).
98
+ * The stack's lead reads `"lead-agent-id"` so a single `--agent-id` never
99
+ * collides the lead and a worker onto the same agent record.
100
+ */
101
+ agentIdFlag?: string;
102
+ /**
103
+ * Prefix for the generated default AGENT_ID (`<prefix>-<sandboxID>`). Workers
104
+ * use `"e2b"` (legacy, unchanged); the stack's lead uses `"e2b-lead"`. The
105
+ * sandbox ID is unique per sandbox, so every instance still registers
106
+ * distinctly even without an explicit `--agent-id`.
107
+ */
108
+ agentIdPrefix?: string;
109
+ };
110
+
111
+ /** The byte-identical specs for the legacy `start-api` / `start-worker` paths. */
112
+ const API_SPEC: LaunchSpec = { swarmRole: "api", envScope: "api", metadataSwarmRole: "api" };
113
+ const WORKER_SPEC: LaunchSpec = {
114
+ swarmRole: "worker",
115
+ envScope: "worker",
116
+ metadataSwarmRole: "worker",
117
+ };
48
118
 
49
- function parseFlags(argv: string[]): ParsedFlags {
119
+ /**
120
+ * Stack-specific specs. The lead is E2B `SwarmRole === "worker"` (same template
121
+ * + entrypoint) but pins `agentRole:"lead"`, its own `"lead"` env scope, a
122
+ * dedicated `--lead-agent-id` override + `e2b-lead-<sandboxID>` default, and a
123
+ * `"lead"` grouping role so `e2b swarms` can tell it apart from the workers.
124
+ */
125
+ const STACK_LEAD_SPEC: LaunchSpec = {
126
+ swarmRole: "worker",
127
+ agentRole: "lead",
128
+ envScope: "lead",
129
+ agentIdFlag: "lead-agent-id",
130
+ agentIdPrefix: "e2b-lead",
131
+ metadataSwarmRole: "lead",
132
+ };
133
+ const STACK_WORKER_SPEC: LaunchSpec = {
134
+ swarmRole: "worker",
135
+ agentRole: "worker",
136
+ envScope: "worker",
137
+ metadataSwarmRole: "worker",
138
+ };
139
+
140
+ const DEFAULT_API_PORT = 3013;
141
+ const BOOLEAN_FLAGS = new Set([
142
+ "dry-run",
143
+ "json",
144
+ "no-cache",
145
+ "no-wait",
146
+ "all",
147
+ "yes",
148
+ "non-interactive",
149
+ "no-lead",
150
+ "reveal-key",
151
+ // `swarms logs --follow` tails live output. Boolean so it never swallows the
152
+ // next positional/flag (e.g. the slug or `--role`).
153
+ "follow",
154
+ // `swarms add --add-lead` adds a lead to an existing swarm (in addition to or
155
+ // instead of workers). Boolean so it never swallows the next positional slug.
156
+ "add-lead",
157
+ // Integration disable shortcuts: `--no-<integration>` sets the matching
158
+ // API-side `*_DISABLE=true`. The `--integrations <csv>` allowlist is the
159
+ // value-bearing alternative (handled separately).
160
+ "no-slack",
161
+ "no-github",
162
+ "no-jira",
163
+ "no-linear",
164
+ ]);
165
+
166
+ export function parseFlags(argv: string[]): ParsedFlags {
50
167
  const [command, ...rest] = argv;
51
168
  const positionals: string[] = [];
52
169
  const values = new Map<string, string[]>();
@@ -194,25 +311,80 @@ function e2bApiBase(flags: ParsedFlags, controllerEnv: EnvMap): string {
194
311
  return value(flags, "e2b-api-base") || controllerEnv.E2B_API_URL || DEFAULT_E2B_API_BASE;
195
312
  }
196
313
 
197
- async function loadRuntimeEnv(
314
+ /** Read every `--{key}` env-file (repeatable) and merge them left-to-right. */
315
+ async function loadEnvFiles(flags: ParsedFlags, key: string): Promise<EnvMap> {
316
+ const paths = values(flags, key).map((path) => absolutePath(path));
317
+ const merged: EnvMap = {};
318
+ for (const env of await Promise.all(paths.map((path) => readDotenvFile(path)))) {
319
+ Object.assign(merged, env);
320
+ }
321
+ return merged;
322
+ }
323
+
324
+ /** Apply every `--{key} KEY=VALUE` secret (repeatable) onto `target`, in order. */
325
+ function applySecrets(flags: ParsedFlags, key: string, target: EnvMap): void {
326
+ for (const raw of values(flags, key)) {
327
+ const [secretKey, secretValue] = parseKeyValue(raw, `--${key}`);
328
+ target[secretKey] = secretValue;
329
+ }
330
+ }
331
+
332
+ /** Integrations toggleable via `--integrations <csv>` / `--no-<integration>`. */
333
+ const E2B_INTEGRATIONS = ["slack", "github", "jira", "linear"] as const;
334
+ type E2BIntegration = (typeof E2B_INTEGRATIONS)[number];
335
+
336
+ /**
337
+ * Resolve which integrations are enabled. Default: all on. `--integrations
338
+ * <csv>` is an allowlist — anything not listed is disabled. `--no-<integration>`
339
+ * disables a single one (and stacks on top of the allowlist). Returns a map of
340
+ * integration → enabled.
341
+ */
342
+ export function resolveIntegrationToggles(flags: ParsedFlags): Record<E2BIntegration, boolean> {
343
+ const allowlistRaw = splitKeys(values(flags, "integrations")).map((s) => s.toLowerCase());
344
+ const hasAllowlist = allowlistRaw.length > 0;
345
+ const toggles = {} as Record<E2BIntegration, boolean>;
346
+ for (const integration of E2B_INTEGRATIONS) {
347
+ // With an allowlist, only listed integrations stay on; without one, all on.
348
+ let enabled = hasAllowlist ? allowlistRaw.includes(integration) : true;
349
+ if (booleanFlag(flags, `no-${integration}`)) enabled = false;
350
+ toggles[integration] = enabled;
351
+ }
352
+ return toggles;
353
+ }
354
+
355
+ /**
356
+ * Stamp `*_DISABLE=true` for any integration the operator turned off. These envs
357
+ * are read API-side, so the caller only applies this to the API runtime scope.
358
+ */
359
+ function applyIntegrationDisables(flags: ParsedFlags, target: EnvMap): void {
360
+ const toggles = resolveIntegrationToggles(flags);
361
+ for (const integration of E2B_INTEGRATIONS) {
362
+ if (!toggles[integration]) {
363
+ target[`${integration.toUpperCase()}_DISABLE`] = "true";
364
+ }
365
+ }
366
+ }
367
+
368
+ export async function loadRuntimeEnv(
198
369
  flags: ParsedFlags,
199
- role: SwarmRole,
370
+ spec: LaunchSpec,
200
371
  apiUrl?: string,
201
372
  ): Promise<EnvMap> {
202
- const envFiles = values(flags, "env-file").map((path) => absolutePath(path));
203
- const fileEnv: EnvMap = {};
204
- for (const env of await Promise.all(envFiles.map((path) => readDotenvFile(path)))) {
205
- Object.assign(fileEnv, env);
206
- }
373
+ const role = spec.swarmRole;
374
+ const scope = spec.envScope;
207
375
 
376
+ // Precedence (lowest → highest, later overrides earlier):
377
+ // forward-keys (process.env) < shared --env-file < scoped --{scope}-env-file
378
+ // < shared --secret < scoped --{scope}-secret < forced API_KEY/AGENT_SWARM_API_KEY.
379
+ // Scoped flags LAYER ON TOP of the shared ones — they never replace them.
208
380
  const inheritKeys = [...DEFAULT_E2B_FORWARD_KEYS, ...splitKeys(values(flags, "inherit-env"))];
209
- const inherited = selectEnv(process.env, inheritKeys);
210
- const runtime: EnvMap = { ...inherited, ...fileEnv };
381
+ const runtime: EnvMap = selectEnv(process.env, inheritKeys);
211
382
 
212
- for (const raw of values(flags, "secret")) {
213
- const [key, secretValue] = parseKeyValue(raw, "--secret");
214
- runtime[key] = secretValue;
215
- }
383
+ Object.assign(runtime, await loadEnvFiles(flags, "env-file"));
384
+ Object.assign(runtime, await loadEnvFiles(flags, `${scope}-env-file`));
385
+
386
+ applySecrets(flags, "secret", runtime);
387
+ applySecrets(flags, `${scope}-secret`, runtime);
216
388
 
217
389
  let swarmApiKey: string;
218
390
  try {
@@ -242,12 +414,19 @@ async function loadRuntimeEnv(
242
414
  runtime.SCRIPT_RUNTIME_DIR = value(flags, "script-runtime-dir", "/app/scripts-runtime");
243
415
  runtime.TS_LIB_DIR = value(flags, "ts-lib-dir", "/app/typescript-lib");
244
416
  runtime.SCRIPT_TYPES_DIR = value(flags, "script-types-dir", "/app/script-types");
417
+ // Integration toggles are read API-side, so they only ever apply to the API
418
+ // sandbox's runtime env. `--no-<integration>` / `--integrations <csv>`
419
+ // resolve to `*_DISABLE=true` here.
420
+ applyIntegrationDisables(flags, runtime);
245
421
  } else {
246
422
  if (!apiUrl) {
247
423
  throw new Error("Worker startup requires --api-url, or use start-stack to create API first.");
248
424
  }
249
425
  runtime.MCP_BASE_URL = apiUrl;
250
- runtime.AGENT_ROLE = value(flags, "agent-role", "worker");
426
+ // AGENT_ROLE comes from the spec (so start-stack can pin lead/worker per
427
+ // instance); when the spec leaves it unset we fall back to the global
428
+ // --agent-role flag, keeping start-worker byte-identical to before.
429
+ runtime.AGENT_ROLE = spec.agentRole ?? value(flags, "agent-role", "worker");
251
430
  runtime.HARNESS_PROVIDER = value(flags, "provider", runtime.HARNESS_PROVIDER || "claude");
252
431
  runtime.WORKER_YOLO = value(flags, "worker-yolo", "false");
253
432
  runtime.WORKER_LOG_DIR = value(flags, "worker-log-dir", "/logs");
@@ -297,19 +476,89 @@ async function loadRuntimeEnv(
297
476
  return runtime;
298
477
  }
299
478
 
300
- function parseMetadata(flags: ParsedFlags, role: SwarmRole): Record<string, string> {
479
+ /**
480
+ * Reserved sandbox-metadata keys this dispatcher stamps on every launch. They
481
+ * are read back by `e2b list`, `e2b kill --all`, and the `e2b swarms` family to
482
+ * group/inspect sandboxes — operators should not override them via `--metadata`.
483
+ *
484
+ * app — "agent-swarm" (provenance; every sandbox we create)
485
+ * role — E2B SwarmRole ("api" | "worker"); template/entrypoint dimension
486
+ * launcher — "agent-swarm-e2b" (the dispatcher tag `kill --all` filters on)
487
+ * swarm — shared slug grouping every sandbox of one launch (Phase 4)
488
+ * swarmRole — grouping role ("api" | "lead" | "worker"); a lead is E2B
489
+ * role:"worker" but swarmRole:"lead", so `swarms info` can tell
490
+ * the lead apart from the workers
491
+ * apiPort — (API sandbox only) the port the swarm API listens on, so
492
+ * `swarms info` reconstructs the API URL without guessing
493
+ * agentId — (lead/worker only) the agent ID it registered under, when known
494
+ * pre-create (explicit --agent-id / --lead-agent-id / env). When
495
+ * absent (auto `<prefix>-<sandboxID>` default), `swarms info`
496
+ * reconstructs it from the sandbox ID + swarmRole.
497
+ */
498
+ const RESERVED_METADATA_KEYS = [
499
+ "app",
500
+ "role",
501
+ "launcher",
502
+ "swarm",
503
+ "swarmRole",
504
+ "apiPort",
505
+ "agentId",
506
+ ] as const;
507
+
508
+ type MetadataTagging = {
509
+ /** E2B SwarmRole — the template/entrypoint dimension. */
510
+ role: SwarmRole;
511
+ /** Shared swarm slug for grouping (Phase 4). Omitted on legacy single-role launches. */
512
+ swarm?: string;
513
+ /** Grouping role for `e2b swarms` (api | lead | worker). */
514
+ swarmRole?: MetadataSwarmRole;
515
+ /** API port (API sandbox only) so `swarms info` rebuilds the URL deterministically. */
516
+ apiPort?: number;
517
+ /** Resolved agent ID (lead/worker only) when known before sandbox creation. */
518
+ agentId?: string;
519
+ };
520
+
521
+ function parseMetadata(flags: ParsedFlags, tagging: MetadataTagging): Record<string, string> {
301
522
  const metadata: Record<string, string> = {
302
523
  app: "agent-swarm",
303
- role,
524
+ role: tagging.role,
304
525
  launcher: "agent-swarm-e2b",
305
526
  };
527
+ if (tagging.swarm) metadata.swarm = tagging.swarm;
528
+ if (tagging.swarmRole) metadata.swarmRole = tagging.swarmRole;
529
+ if (tagging.apiPort !== undefined) metadata.apiPort = String(tagging.apiPort);
530
+ if (tagging.agentId) metadata.agentId = tagging.agentId;
531
+ const reserved = new Set<string>(RESERVED_METADATA_KEYS);
306
532
  for (const raw of values(flags, "metadata")) {
307
533
  const [key, metadataValue] = parseKeyValue(raw, "--metadata");
534
+ if (reserved.has(key)) {
535
+ // The dispatcher owns these keys (grouping/teardown depend on them); a
536
+ // user override would silently break `kill --all` / the `swarms` family.
537
+ console.warn(`e2b: ignoring --metadata ${key}=… (reserved by the dispatcher)`);
538
+ continue;
539
+ }
308
540
  metadata[key] = metadataValue;
309
541
  }
310
542
  return metadata;
311
543
  }
312
544
 
545
+ /** The grouping role to stamp for a spec (defaults from swarmRole/agentRole). */
546
+ function metadataSwarmRoleForSpec(spec: LaunchSpec): MetadataSwarmRole {
547
+ if (spec.metadataSwarmRole) return spec.metadataSwarmRole;
548
+ if (spec.swarmRole === "api") return "api";
549
+ return spec.agentRole === "lead" ? "lead" : "worker";
550
+ }
551
+
552
+ /**
553
+ * The auto-generated AGENT_ID prefix for a grouping role (mirrors the prefixes
554
+ * in {@link startRole}). Lets `swarms info` reconstruct an agent ID from a
555
+ * sandbox ID when `metadata.agentId` is absent (the auto `<prefix>-<sandboxID>`
556
+ * default was used).
557
+ */
558
+ function agentIdPrefixForSwarmRole(swarmRole: MetadataSwarmRole): string {
559
+ return swarmRole === "lead" ? "e2b-lead" : "e2b";
560
+ }
561
+
313
562
  function roleTemplate(flags: ParsedFlags, role: SwarmRole): string {
314
563
  return value(
315
564
  flags,
@@ -322,9 +571,24 @@ function localDockerfile(role: SwarmRole): string {
322
571
  return role === "api" ? "Dockerfile" : "Dockerfile.worker";
323
572
  }
324
573
 
574
+ function formatDuration(secondsLeft: number): string {
575
+ if (secondsLeft <= 0) return "expired";
576
+ const hours = Math.floor(secondsLeft / 3600);
577
+ const minutes = Math.floor((secondsLeft % 3600) / 60);
578
+ const parts: string[] = [];
579
+ if (hours > 0) parts.push(`${hours}h`);
580
+ // Always show minutes when under an hour, otherwise show them alongside hours.
581
+ if (minutes > 0 || hours === 0) parts.push(`${minutes}m`);
582
+ return parts.join(" ");
583
+ }
584
+
325
585
  function printHumanStart(result: StartedRole, env: EnvMap): void {
326
586
  console.log(`${result.role} sandbox: ${result.sandbox.sandboxID}`);
327
587
  if (result.url) console.log(`${result.role} url: ${result.url}`);
588
+ const ttl = ttlRemaining(result.sandbox);
589
+ if (ttl.expiresAt && ttl.secondsLeft !== undefined) {
590
+ console.log(`${result.role} expires: ${ttl.expiresAt} (in ${formatDuration(ttl.secondsLeft)})`);
591
+ }
328
592
  console.log(
329
593
  redactWithEnv(`inspect: e2b sandbox info ${result.sandbox.sandboxID} --format json`, env),
330
594
  );
@@ -340,18 +604,37 @@ function publicStartedRole(result: StartedRole, env: EnvMap): StartedRole {
340
604
  async function startRole(
341
605
  flags: ParsedFlags,
342
606
  cwd: string,
343
- role: SwarmRole,
607
+ spec: LaunchSpec,
344
608
  apiUrl?: string,
345
609
  ): Promise<StartedRole> {
610
+ const role = spec.swarmRole;
346
611
  const controllerEnv = await loadE2BControllerEnv(flags, cwd);
347
- const runtimeEnv = await loadRuntimeEnv(flags, role, apiUrl);
612
+ const runtimeEnv = await loadRuntimeEnv(flags, spec, apiUrl);
348
613
  const controllerApiKey = e2bControllerApiKey(controllerEnv);
349
614
  const template = roleTemplate(flags, role);
350
615
  const timeoutSec = integerFlag(flags, "timeout-sec", 3600);
351
616
  const apiBase = e2bApiBase(flags, controllerEnv);
352
617
  const dryRun = booleanFlag(flags, "dry-run");
353
618
  const port = Number.parseInt(runtimeEnv.PORT || String(DEFAULT_API_PORT), 10);
354
- const metadata = parseMetadata(flags, role);
619
+
620
+ const swarmSlug = value(flags, "swarm") || undefined;
621
+ const metadataSwarmRole = metadataSwarmRoleForSpec(spec);
622
+ // Resolve the agent ID we can know BEFORE the sandbox exists: an explicit
623
+ // --agent-id / --lead-agent-id flag, or AGENT_ID from the runtime env. The
624
+ // auto `<prefix>-<sandboxID>` default depends on the not-yet-created sandbox
625
+ // ID, so it is NOT stamped here — `swarms info` reconstructs it from the
626
+ // sandbox ID + swarmRole instead.
627
+ const preCreateAgentId =
628
+ role === "worker"
629
+ ? value(flags, spec.agentIdFlag ?? "agent-id") || runtimeEnv.AGENT_ID || undefined
630
+ : undefined;
631
+ const metadata = parseMetadata(flags, {
632
+ role,
633
+ swarm: swarmSlug,
634
+ swarmRole: metadataSwarmRole,
635
+ apiPort: role === "api" ? port : undefined,
636
+ agentId: preCreateAgentId,
637
+ });
355
638
 
356
639
  if (dryRun) {
357
640
  const fakeSandbox = {
@@ -360,6 +643,7 @@ async function startRole(
360
643
  envdAccessToken: "dry-run",
361
644
  domain: "e2b.app",
362
645
  metadata,
646
+ expiresAt: new Date(Date.now() + timeoutSec * 1000).toISOString(),
363
647
  };
364
648
  return {
365
649
  role,
@@ -379,7 +663,14 @@ async function startRole(
379
663
 
380
664
  try {
381
665
  if (role === "worker" && !runtimeEnv.AGENT_ID) {
382
- runtimeEnv.AGENT_ID = value(flags, "agent-id", `e2b-${sandbox.sandboxID}`);
666
+ // Per-instance AGENT_ID. The explicit-override flag and the generated
667
+ // default prefix come from the spec so the stack's lead never collides
668
+ // with a worker (lead → --lead-agent-id / e2b-lead-<id>; worker →
669
+ // --agent-id / e2b-<id>). Sandbox IDs are unique, so each instance
670
+ // registers distinctly even without an explicit override.
671
+ const agentIdFlag = spec.agentIdFlag ?? "agent-id";
672
+ const agentIdPrefix = spec.agentIdPrefix ?? "e2b";
673
+ runtimeEnv.AGENT_ID = value(flags, agentIdFlag, `${agentIdPrefix}-${sandbox.sandboxID}`);
383
674
  }
384
675
 
385
676
  const entrypoint = role === "api" ? "/api-entrypoint.sh" : "/docker-entrypoint.sh";
@@ -541,8 +832,8 @@ async function templateVisibilityCommand(
541
832
  }
542
833
 
543
834
  async function startApiCommand(flags: ParsedFlags, cwd: string): Promise<void> {
544
- const result = await startRole(flags, cwd, "api");
545
- const runtimeEnv = await loadRuntimeEnv(flags, "api");
835
+ const result = await startRole(flags, cwd, API_SPEC);
836
+ const runtimeEnv = await loadRuntimeEnv(flags, API_SPEC);
546
837
  if (booleanFlag(flags, "json")) {
547
838
  console.log(JSON.stringify(publicStartedRole(result, runtimeEnv), null, 2));
548
839
  } else {
@@ -552,8 +843,8 @@ async function startApiCommand(flags: ParsedFlags, cwd: string): Promise<void> {
552
843
 
553
844
  async function startWorkerCommand(flags: ParsedFlags, cwd: string): Promise<void> {
554
845
  const apiUrl = value(flags, "api-url");
555
- const result = await startRole(flags, cwd, "worker", apiUrl);
556
- const runtimeEnv = await loadRuntimeEnv(flags, "worker", apiUrl);
846
+ const result = await startRole(flags, cwd, WORKER_SPEC, apiUrl);
847
+ const runtimeEnv = await loadRuntimeEnv(flags, WORKER_SPEC, apiUrl);
557
848
  if (booleanFlag(flags, "json")) {
558
849
  console.log(JSON.stringify(publicStartedRole(result, runtimeEnv), null, 2));
559
850
  } else {
@@ -587,39 +878,183 @@ async function cleanupStartedRoles(
587
878
  }
588
879
  }
589
880
 
881
+ async function resyncStackTimeout(
882
+ flags: ParsedFlags,
883
+ cwd: string,
884
+ started: StartedRole[],
885
+ ): Promise<void> {
886
+ if (booleanFlag(flags, "dry-run") || started.length === 0) return;
887
+
888
+ const timeoutSec = integerFlag(flags, "timeout-sec", 3600);
889
+ const controllerEnv = await loadE2BControllerEnv(flags, cwd);
890
+ const controllerApiKey = e2bControllerApiKey(controllerEnv);
891
+ const apiBase = e2bApiBase(flags, controllerEnv);
892
+
893
+ for (const role of started) {
894
+ try {
895
+ await setSandboxTimeout({
896
+ sandboxId: role.sandbox.sandboxID,
897
+ apiKey: controllerApiKey,
898
+ apiBase,
899
+ e2bEnv: controllerEnv,
900
+ timeoutMs: timeoutSec * 1000,
901
+ });
902
+ } catch (err) {
903
+ // A re-sync failure is non-fatal — the sandbox is still up with its
904
+ // original (slightly shorter) TTL. setSandboxTimeout already redacts.
905
+ const message = err instanceof Error ? err.message : String(err);
906
+ console.warn(
907
+ redactWithEnv(
908
+ `e2b: failed to re-sync TTL for ${role.role} sandbox ${role.sandbox.sandboxID}: ${message}`,
909
+ controllerEnv,
910
+ ),
911
+ );
912
+ }
913
+ }
914
+ }
915
+
916
+ /**
917
+ * `start-stack` should run headless (no prompts, never read stdin) whenever:
918
+ * - `--yes` / `--non-interactive` is passed,
919
+ * - `--dry-run` is passed (CI/preview path), or
920
+ * - we're not on an interactive TTY (piped / redirected stdin or stdout).
921
+ * Critically, the piped case (`echo | … start-stack …`) MUST take this path so
922
+ * it exits without hanging on a prompt that no one can answer.
923
+ */
924
+ function isStackHeadless(flags: ParsedFlags): boolean {
925
+ return (
926
+ booleanFlag(flags, "yes") ||
927
+ booleanFlag(flags, "non-interactive") ||
928
+ booleanFlag(flags, "dry-run") ||
929
+ !isInteractiveTty()
930
+ );
931
+ }
932
+
590
933
  async function startStackCommand(flags: ParsedFlags, cwd: string): Promise<void> {
934
+ // `--agent-role` is meaningless for the split topology (API + lead + workers
935
+ // each get a fixed role). Warn and point the operator at the right tool
936
+ // rather than silently ignoring an intent to change roles.
937
+ if (value(flags, "agent-role")) {
938
+ console.warn(
939
+ "e2b start-stack: --agent-role is ignored (the stack pins API/lead/worker roles). " +
940
+ "Use --no-lead for an API + workers topology, or start-worker --agent-role for a single custom-role worker.",
941
+ );
942
+ }
943
+
944
+ // Normalize a user-supplied --swarm into a clean slug so the value is
945
+ // consistent whether it came from a flag or the wizard. We do NOT synthesize
946
+ // the random fallback here: doing so would mark `swarm` as "set" and make the
947
+ // wizard skip its Swarm-name step, so a TTY operator without --swarm could
948
+ // never name the swarm. The `swarm-<short-random>` default is applied AFTER
949
+ // the wizard (below) — by which point either the operator named it or we fill
950
+ // it in for the headless / unnamed path.
951
+ const swarmFlag = value(flags, "swarm");
952
+ if (swarmFlag) {
953
+ setFlagValue(flags, "swarm", slugify(swarmFlag));
954
+ }
955
+
956
+ // Interactive wizard (TTY only). Headless runs (--yes / --non-interactive /
957
+ // --dry-run / non-TTY) skip it entirely and rely on flags + defaults. The
958
+ // wizard may set/overwrite the swarm slug if the operator names the swarm.
959
+ if (!isStackHeadless(flags)) {
960
+ await runStackWizard(flags);
961
+ }
962
+
963
+ // Now that the wizard (if any) has run, GENERATE a shared slug when neither
964
+ // the flag nor the wizard produced one. Every sandbox of this launch then
965
+ // shares a single grouping slug stamped onto metadata.swarm (read by
966
+ // `e2b swarms`). This lands on `flags` BEFORE any startRole call so all roles
967
+ // inherit it.
968
+ if (!value(flags, "swarm")) {
969
+ setFlagValue(flags, "swarm", generateSwarmSlug());
970
+ }
971
+
972
+ // A single explicit --agent-id is reused verbatim for every worker in the loop
973
+ // below, but the API registration path reuses the row for an existing
974
+ // X-Agent-ID — so N workers would collapse into one agent record and the wait
975
+ // loop would poll the same agent N times. Reject the shared explicit ID for
976
+ // multi-worker stacks; the per-sandbox `e2b-<sandboxID>` default (or a
977
+ // single-worker stack) stays unaffected. `--workers` is resolved here so a
978
+ // wizard-chosen count is also covered.
979
+ const explicitWorkerAgentId = value(flags, STACK_WORKER_SPEC.agentIdFlag ?? "agent-id");
980
+ if (explicitWorkerAgentId && integerFlag(flags, "workers", 1) > 1) {
981
+ throw new Error(
982
+ "e2b start-stack: --agent-id cannot be shared across multiple workers " +
983
+ "(it collapses them into a single agent record). Drop --agent-id to use " +
984
+ "the per-sandbox default, or run --workers 1.",
985
+ );
986
+ }
987
+
988
+ // Echo the resolved slug up front so the operator can group/inspect/extend the
989
+ // launch via `e2b swarms <cmd> <slug>` even if a later role fails. Under --json
990
+ // STDOUT must carry ONLY the final JSON payload, so route this human echo to
991
+ // STDERR (still visible to the operator, never pollutes `... --json | jq`).
992
+ const swarmSlug = value(flags, "swarm");
993
+ if (booleanFlag(flags, "json")) {
994
+ console.error(`swarm: ${swarmSlug}`);
995
+ } else {
996
+ console.log(`swarm: ${swarmSlug}`);
997
+ }
998
+
999
+ const noLead = booleanFlag(flags, "no-lead");
591
1000
  const started: StartedRole[] = [];
1001
+ let lead: StartedRole | undefined;
592
1002
  const workers: StartedRole[] = [];
593
1003
 
594
1004
  try {
595
- const api = await startRole(flags, cwd, "api");
1005
+ const api = await startRole(flags, cwd, API_SPEC);
596
1006
  started.push(api);
597
1007
  if (!api.url) throw new Error("API sandbox did not produce a public URL");
598
1008
 
1009
+ // (2) One lead, unless --no-lead retains the legacy homogeneous topology.
1010
+ if (!noLead) {
1011
+ lead = await startRole(flags, cwd, STACK_LEAD_SPEC, api.url);
1012
+ // The lead MUST be in `started[]` so a mid-launch failure tears it down,
1013
+ // and so the TTL re-sync pass below covers it.
1014
+ started.push(lead);
1015
+ }
1016
+
1017
+ // (3) N workers.
599
1018
  const workerCount = integerFlag(flags, "workers", 1);
600
1019
  for (let i = 0; i < workerCount; i++) {
601
- const worker = await startRole(flags, cwd, "worker", api.url);
1020
+ const worker = await startRole(flags, cwd, STACK_WORKER_SPEC, api.url);
602
1021
  workers.push(worker);
603
1022
  started.push(worker);
604
1023
  }
605
- const runtimeEnv = await loadRuntimeEnv(flags, "api");
1024
+
1025
+ // Re-sync the whole stack to a single wall-clock TTL. The API sandbox is
1026
+ // created first, so by the time the last worker is up its remaining TTL is
1027
+ // shorter than the API's. One setSandboxTimeout pass aligns every sandbox
1028
+ // to `timeoutSec` from now (E2B clamps to the tier max as usual). Dry-run
1029
+ // short-circuits — never touches E2B.
1030
+ await resyncStackTimeout(flags, cwd, started);
1031
+
1032
+ const runtimeEnv = await loadRuntimeEnv(flags, API_SPEC);
606
1033
 
607
1034
  if (booleanFlag(flags, "json")) {
608
- console.log(
609
- JSON.stringify(
610
- {
611
- api: publicStartedRole(api, runtimeEnv),
612
- workers: workers.map((worker) => publicStartedRole(worker, runtimeEnv)),
613
- },
614
- null,
615
- 2,
616
- ),
617
- );
1035
+ // Legacy shape under --no-lead: {api, workers}. New shape with a lead:
1036
+ // {api, lead, workers}.
1037
+ const payload: Record<string, unknown> = {
1038
+ api: publicStartedRole(api, runtimeEnv),
1039
+ };
1040
+ if (lead) payload.lead = publicStartedRole(lead, runtimeEnv);
1041
+ payload.workers = workers.map((worker) => publicStartedRole(worker, runtimeEnv));
1042
+ console.log(JSON.stringify(payload, null, 2));
618
1043
  } else {
619
1044
  printHumanStart(api, runtimeEnv);
1045
+ if (lead) printHumanStart(lead, runtimeEnv);
620
1046
  for (const worker of workers) {
621
1047
  printHumanStart(worker, runtimeEnv);
622
1048
  }
1049
+ // Dashboard deep-link (key hidden unless --reveal-key). Only printed on the
1050
+ // human path — the --json payload is consumed programmatically and the URL
1051
+ // would otherwise embed the swarm key in machine output.
1052
+ printDashboardDeepLink(flags, {
1053
+ apiUrl: api.url,
1054
+ apiKey: runtimeEnv.AGENT_SWARM_API_KEY,
1055
+ name: swarmSlug,
1056
+ env: runtimeEnv,
1057
+ });
623
1058
  }
624
1059
  } catch (err) {
625
1060
  await cleanupStartedRoles(flags, cwd, started);
@@ -627,13 +1062,315 @@ async function startStackCommand(flags: ParsedFlags, cwd: string): Promise<void>
627
1062
  }
628
1063
  }
629
1064
 
630
- async function killCommand(flags: ParsedFlags, cwd: string): Promise<void> {
1065
+ /** Set/replace a single-value flag in place (mirrors `--key value`). */
1066
+ function setFlagValue(flags: ParsedFlags, key: string, value: string): void {
1067
+ flags.values.set(key, [value]);
1068
+ }
1069
+
1070
+ /**
1071
+ * Compute which wizard steps to skip because the operator already supplied the
1072
+ * value on the command line. A step is skipped when its driving flag is present.
1073
+ */
1074
+ function stackWizardSkips(flags: ParsedFlags): StackWizardSkips {
1075
+ return {
1076
+ swarm: Boolean(value(flags, "swarm")),
1077
+ workers: flags.values.has("workers"),
1078
+ provider: flags.values.has("provider"),
1079
+ timeout: flags.values.has("timeout-sec"),
1080
+ envFiles: flags.values.has("env-file"),
1081
+ integrations:
1082
+ flags.values.has("integrations") ||
1083
+ STACK_INTEGRATIONS.some((i) => booleanFlag(flags, `no-${i}`)),
1084
+ };
1085
+ }
1086
+
1087
+ /** Seed the wizard with whatever the flags already resolve to. */
1088
+ function stackWizardDefaults(flags: ParsedFlags): StackWizardDefaults {
1089
+ return {
1090
+ swarmSlug: value(flags, "swarm") || undefined,
1091
+ workers: integerFlag(flags, "workers", DEFAULT_STACK_WORKERS),
1092
+ provider: value(flags, "provider", "claude"),
1093
+ timeoutSec: integerFlag(flags, "timeout-sec", DEFAULT_STACK_TIMEOUT_SEC),
1094
+ envFiles: values(flags, "env-file"),
1095
+ integrations: resolveIntegrationToggles(flags),
1096
+ noLead: booleanFlag(flags, "no-lead"),
1097
+ };
1098
+ }
1099
+
1100
+ /**
1101
+ * Fold the wizard's answers back onto `flags` so the single headless launch
1102
+ * path below picks them up. Only values the wizard actually collected are
1103
+ * written; flag-provided values were skipped in the wizard and remain as-is.
1104
+ */
1105
+ function applyWizardResultToFlags(flags: ParsedFlags, result: StackWizardResult): void {
1106
+ setFlagValue(flags, "swarm", result.swarmSlug);
1107
+ setFlagValue(flags, "workers", String(result.workers));
1108
+ setFlagValue(flags, "provider", result.provider);
1109
+ setFlagValue(flags, "timeout-sec", String(result.timeoutSec));
1110
+ if (result.envFiles.length > 0) {
1111
+ flags.values.set("env-file", result.envFiles);
1112
+ }
1113
+ // A disabled integration becomes `--no-<integration>` (→ API `*_DISABLE`).
1114
+ for (const integration of STACK_INTEGRATIONS) {
1115
+ if (!result.integrations[integration]) {
1116
+ flags.booleans.add(`no-${integration}`);
1117
+ }
1118
+ }
1119
+ if (result.noLead) flags.booleans.add("no-lead");
1120
+ }
1121
+
1122
+ /**
1123
+ * Reconstruct the equivalent headless one-shot command from the resolved flags,
1124
+ * so an operator who ran the wizard can copy/paste it for a repeatable CI run.
1125
+ * Secrets are NOT included — only the topology-shaping flags the wizard sets.
1126
+ */
1127
+ function buildOneShotCommand(flags: ParsedFlags): string {
1128
+ const parts = ["agent-swarm e2b start-stack --yes"];
1129
+ const slug = value(flags, "swarm");
1130
+ if (slug) parts.push(`--swarm ${slug}`);
1131
+ parts.push(`--workers ${integerFlag(flags, "workers", DEFAULT_STACK_WORKERS)}`);
1132
+ const provider = value(flags, "provider");
1133
+ if (provider) parts.push(`--provider ${provider}`);
1134
+ parts.push(`--timeout-sec ${integerFlag(flags, "timeout-sec", DEFAULT_STACK_TIMEOUT_SEC)}`);
1135
+ for (const file of values(flags, "env-file")) {
1136
+ parts.push(`--env-file ${file}`);
1137
+ }
1138
+ for (const integration of STACK_INTEGRATIONS) {
1139
+ if (booleanFlag(flags, `no-${integration}`)) parts.push(`--no-${integration}`);
1140
+ }
1141
+ if (booleanFlag(flags, "no-lead")) parts.push("--no-lead");
1142
+ return parts.join(" ");
1143
+ }
1144
+
1145
+ /**
1146
+ * Render the Ink wizard, await the operator's answers, fold them onto `flags`,
1147
+ * and echo the equivalent `--yes` command. Only called on an interactive TTY
1148
+ * (see {@link isStackHeadless}).
1149
+ */
1150
+ async function runStackWizard(flags: ParsedFlags): Promise<void> {
1151
+ const skips = stackWizardSkips(flags);
1152
+ const defaults = stackWizardDefaults(flags);
1153
+
1154
+ let resolved: StackWizardResult | undefined;
1155
+ const instance = render(
1156
+ createElement(StackWizard, {
1157
+ defaults,
1158
+ skips,
1159
+ onComplete: (result: StackWizardResult) => {
1160
+ resolved = result;
1161
+ },
1162
+ }),
1163
+ );
1164
+ await instance.waitUntilExit();
1165
+
1166
+ if (!resolved) {
1167
+ throw new Error("stack wizard exited without producing a configuration");
1168
+ }
1169
+ applyWizardResultToFlags(flags, resolved);
1170
+
1171
+ console.log("\nEquivalent one-shot command:");
1172
+ console.log(` ${buildOneShotCommand(flags)}\n`);
1173
+ }
1174
+
1175
+ /**
1176
+ * Generate a fresh swarm slug (`swarm-<short-random>`) when the operator did not
1177
+ * name the swarm. Shared across every sandbox of one launch via `metadata.swarm`.
1178
+ * `crypto.randomUUID()` is overkill; a short hex tail keeps the slug readable
1179
+ * while staying collision-free enough for a handful of concurrent launches.
1180
+ */
1181
+ function generateSwarmSlug(): string {
1182
+ const tail = Math.random().toString(16).slice(2, 8);
1183
+ return `swarm-${tail}`;
1184
+ }
1185
+
1186
+ /**
1187
+ * Mask a swarm API key for display: keep a short non-sensitive prefix/suffix and
1188
+ * elide the middle. Short keys are fully masked. Never prints the whole key.
1189
+ */
1190
+ function maskKey(key: string): string {
1191
+ if (!key) return "(none)";
1192
+ if (key.length <= 8) return "****";
1193
+ return `${key.slice(0, 4)}…${key.slice(-4)}`;
1194
+ }
1195
+
1196
+ /**
1197
+ * Report where `resolveSwarmApiKey` sourced the key from, for `swarms info`. The
1198
+ * precedence mirrors {@link resolveSwarmApiKey} (explicit > AGENT_SWARM_API_KEY >
1199
+ * API_KEY > getApiKey()/env default). Returns a human label, never the value.
1200
+ *
1201
+ * `runtime` is built from `selectEnv(process.env, FORWARD_KEYS)` by the caller,
1202
+ * so its AGENT_SWARM_API_KEY / API_KEY entries already reflect the process env —
1203
+ * no direct `process.env` reads here (that path is owned by getApiKey(), per the
1204
+ * api-key boundary). A resolved key with neither entry came from getApiKey().
1205
+ */
1206
+ function swarmApiKeySource(flags: ParsedFlags, runtime: EnvMap): string {
1207
+ if (value(flags, "api-key")) return "from --api-key";
1208
+ if (runtime.AGENT_SWARM_API_KEY) return "from AGENT_SWARM_API_KEY";
1209
+ if (runtime.API_KEY) return "from API_KEY";
1210
+ return "from getApiKey() default";
1211
+ }
1212
+
1213
+ export type DashboardDeepLinkParts = {
1214
+ apiUrl?: string;
1215
+ apiKey?: string;
1216
+ name?: string;
1217
+ };
1218
+
1219
+ /**
1220
+ * Build the dashboard deep-link the SPA reads. The SPA expects **camelCase**
1221
+ * `apiUrl` / `apiKey` / `name` query params (see ui/src/hooks/use-config.ts) and
1222
+ * silently ignores snake_case — so these MUST stay camelCase.
1223
+ *
1224
+ * When `reveal` is false the `apiKey` param is replaced with a placeholder so the
1225
+ * key never lands in logs/scrollback by default. When `reveal` is true the real
1226
+ * key is embedded — the caller is responsible for the secret warning and for NOT
1227
+ * routing the revealed URL through a redactor (the key would be scrubbed out).
1228
+ */
1229
+ export function buildDashboardDeepLink(parts: DashboardDeepLinkParts, reveal: boolean): string {
1230
+ const params = new URLSearchParams();
1231
+ if (parts.apiUrl) params.set("apiUrl", parts.apiUrl);
1232
+ // URLSearchParams percent-encodes the placeholder's spaces/em-dash; build the
1233
+ // query manually so the hidden hint stays human-readable in the printed URL.
1234
+ const keyParam = reveal
1235
+ ? parts.apiKey
1236
+ ? `apiKey=${encodeURIComponent(parts.apiKey)}`
1237
+ : ""
1238
+ : "apiKey=<hidden — pass --reveal-key>";
1239
+ if (parts.name) params.set("name", parts.name);
1240
+ const encodedRest = params.toString();
1241
+ const query = [keyParam, encodedRest].filter(Boolean).join("&");
1242
+ return `${getAppUrl()}${query ? `?${query}` : ""}`;
1243
+ }
1244
+
1245
+ /**
1246
+ * Print the dashboard deep-link. Default: key hidden. With `--reveal-key`: emit
1247
+ * the full key-bearing URL RAW (not via redactWithEnv — a redactor would mask
1248
+ * the very key the operator asked to reveal) under an explicit secret warning.
1249
+ */
1250
+ function printDashboardDeepLink(
1251
+ flags: ParsedFlags,
1252
+ opts: { apiUrl?: string; apiKey?: string; name?: string; env: EnvMap },
1253
+ ): void {
1254
+ if (!opts.apiUrl) return;
1255
+ const reveal = booleanFlag(flags, "reveal-key");
1256
+ const parts: DashboardDeepLinkParts = {
1257
+ apiUrl: opts.apiUrl,
1258
+ apiKey: opts.apiKey,
1259
+ name: opts.name,
1260
+ };
1261
+ if (reveal) {
1262
+ console.log("\n⚠ secret: the URL below embeds the swarm API key — do not share or paste it.");
1263
+ // Intentionally NOT redacted: the operator asked to reveal the key.
1264
+ console.log(`dashboard: ${buildDashboardDeepLink(parts, true)}`);
1265
+ } else {
1266
+ console.log(`dashboard: ${buildDashboardDeepLink(parts, false)}`);
1267
+ console.log(" (pass --reveal-key to embed the swarm API key for one-click connect)");
1268
+ }
1269
+ }
1270
+
1271
+ function isInteractiveTty(): boolean {
1272
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
1273
+ }
1274
+
1275
+ /**
1276
+ * Prompt for a yes/no confirmation on an interactive TTY. Returns true when the
1277
+ * operator answers "y"/"yes". In a non-TTY (CI, piped) context there is no one
1278
+ * to ask, so we require an explicit `--yes` to proceed and otherwise refuse.
1279
+ */
1280
+ async function confirm(prompt: string, flags: ParsedFlags): Promise<boolean> {
1281
+ if (booleanFlag(flags, "yes")) return true;
1282
+ if (!isInteractiveTty()) return false;
1283
+ process.stdout.write(`${prompt} [y/N] `);
1284
+ for await (const line of console) {
1285
+ const answer = line.trim().toLowerCase();
1286
+ return answer === "y" || answer === "yes";
1287
+ }
1288
+ return false;
1289
+ }
1290
+
1291
+ async function extendCommand(flags: ParsedFlags, cwd: string): Promise<void> {
631
1292
  const ids = flags.positionals;
632
- if (ids.length === 0) throw new Error("kill requires at least one sandbox ID");
1293
+ if (ids.length === 0) throw new Error("extend requires at least one sandbox ID");
1294
+ const timeoutSec = integerFlag(flags, "timeout-sec", 3600);
1295
+ const dryRun = booleanFlag(flags, "dry-run");
1296
+
1297
+ if (dryRun) {
1298
+ // Short-circuit before any SDK/network work so --dry-run never touches E2B.
1299
+ for (const id of ids) {
1300
+ console.log(`would extend ${id} to ${timeoutSec}s TTL`);
1301
+ }
1302
+ return;
1303
+ }
1304
+
633
1305
  const controllerEnv = await loadE2BControllerEnv(flags, cwd);
1306
+ const controllerApiKey = e2bControllerApiKey(controllerEnv);
634
1307
  const apiBase = e2bApiBase(flags, controllerEnv);
1308
+
1309
+ let failures = 0;
1310
+ for (const id of ids) {
1311
+ try {
1312
+ const ttl = await setSandboxTimeout({
1313
+ sandboxId: id,
1314
+ apiKey: controllerApiKey,
1315
+ apiBase,
1316
+ e2bEnv: controllerEnv,
1317
+ timeoutMs: timeoutSec * 1000,
1318
+ });
1319
+ if (ttl.expiresAt && ttl.secondsLeft !== undefined) {
1320
+ console.log(
1321
+ `extended ${id} — expires ${ttl.expiresAt} (in ${formatDuration(ttl.secondsLeft)})`,
1322
+ );
1323
+ } else {
1324
+ console.log(`extended ${id}`);
1325
+ }
1326
+ } catch (err) {
1327
+ failures++;
1328
+ // setSandboxTimeout already produces a redacted message.
1329
+ const message = err instanceof Error ? err.message : String(err);
1330
+ console.error(redactWithEnv(`e2b: extend failed: ${message}`, controllerEnv));
1331
+ }
1332
+ }
1333
+ if (failures > 0) {
1334
+ throw new Error(`extend failed for ${failures} of ${ids.length} sandbox(es)`);
1335
+ }
1336
+ }
1337
+
1338
+ async function killCommand(flags: ParsedFlags, cwd: string): Promise<void> {
1339
+ const controllerEnv = await loadE2BControllerEnv(flags, cwd);
1340
+ const apiBase = e2bApiBase(flags, controllerEnv);
1341
+ const controllerApiKey = e2bControllerApiKey(controllerEnv);
1342
+
1343
+ let ids = flags.positionals;
1344
+
1345
+ if (booleanFlag(flags, "all")) {
1346
+ // Sweep everything this dispatcher launched. The launcher tag is stamped on
1347
+ // every sandbox by parseMetadata, so this never touches unrelated sandboxes.
1348
+ const sandboxes = await listSandboxes(controllerApiKey, apiBase);
1349
+ ids = sandboxes
1350
+ .filter((sandbox) => sandbox.metadata?.launcher === "agent-swarm-e2b")
1351
+ .map((sandbox) => sandbox.sandboxID);
1352
+ if (ids.length === 0) {
1353
+ console.log("no agent-swarm sandboxes to kill");
1354
+ return;
1355
+ }
1356
+ // Guard against an accidental fleet-wide teardown. A single target is
1357
+ // unambiguous; multiple targets require confirmation (or --yes in CI).
1358
+ if (ids.length > 1) {
1359
+ const ok = await confirm(
1360
+ `Kill ${ids.length} agent-swarm sandboxes (${ids.join(", ")})?`,
1361
+ flags,
1362
+ );
1363
+ if (!ok) {
1364
+ console.log("aborted (pass --yes to skip this prompt)");
1365
+ return;
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ if (ids.length === 0) throw new Error("kill requires at least one sandbox ID (or --all)");
1371
+
635
1372
  for (const id of ids) {
636
- await killSandbox(id, e2bControllerApiKey(controllerEnv), apiBase);
1373
+ await killSandbox(id, controllerApiKey, apiBase);
637
1374
  console.log(`killed ${id}`);
638
1375
  }
639
1376
  }
@@ -653,6 +1390,504 @@ async function listCommand(flags: ParsedFlags, cwd: string): Promise<void> {
653
1390
  }
654
1391
  }
655
1392
 
1393
+ /** Bucket key for sandboxes carrying no `metadata.swarm` tag (legacy/standalone). */
1394
+ const UNGROUPED_BUCKET = "(ungrouped)";
1395
+
1396
+ /**
1397
+ * Group dispatcher sandboxes by `metadata.swarm`. Sandboxes with no swarm tag
1398
+ * (legacy `start-api`/`start-worker` launches, or anything created before Phase
1399
+ * 4) land in the `(ungrouped)` bucket. Returns an insertion-ordered map.
1400
+ */
1401
+ function groupSandboxesBySwarm(sandboxes: E2BSandboxInfo[]): Map<string, E2BSandboxInfo[]> {
1402
+ const groups = new Map<string, E2BSandboxInfo[]>();
1403
+ for (const sandbox of sandboxes) {
1404
+ const slug = sandbox.metadata?.swarm || UNGROUPED_BUCKET;
1405
+ const bucket = groups.get(slug);
1406
+ if (bucket) bucket.push(sandbox);
1407
+ else groups.set(slug, [sandbox]);
1408
+ }
1409
+ return groups;
1410
+ }
1411
+
1412
+ /** The grouping role for a sandbox, defaulting from the E2B `role` when absent. */
1413
+ function sandboxSwarmRole(sandbox: E2BSandboxInfo): MetadataSwarmRole {
1414
+ const swarmRole = sandbox.metadata?.swarmRole;
1415
+ if (swarmRole === "api" || swarmRole === "lead" || swarmRole === "worker") return swarmRole;
1416
+ // Pre-Phase-4 sandboxes only carry the E2B role (api|worker). Map worker → worker.
1417
+ return sandbox.metadata?.role === "api" ? "api" : "worker";
1418
+ }
1419
+
1420
+ /** The agent ID for a lead/worker sandbox: metadata if present, else reconstructed. */
1421
+ function sandboxAgentId(sandbox: E2BSandboxInfo): string {
1422
+ const explicit = sandbox.metadata?.agentId;
1423
+ if (explicit) return explicit;
1424
+ // Auto-generated default was `<prefix>-<sandboxID>` (see startRole). Rebuild it
1425
+ // so `swarms info` can name + probe the agent even without a stamped agentId.
1426
+ return `${agentIdPrefixForSwarmRole(sandboxSwarmRole(sandbox))}-${sandbox.sandboxID}`;
1427
+ }
1428
+
1429
+ /** The API URL for a swarm's API sandbox, preferring its own custom `domain`. */
1430
+ function swarmApiUrl(apiSandbox: E2BSandboxInfo, controllerEnv: EnvMap): string {
1431
+ const port = Number.parseInt(apiSandbox.metadata?.apiPort || String(DEFAULT_API_PORT), 10);
1432
+ // sandboxPortUrl already prefers the sandbox's own `domain` field over the
1433
+ // configured controller domain (custom-domain correctness), falling back to
1434
+ // the controller env's E2B_DOMAIN/E2B_SANDBOX_URL only when domain is absent.
1435
+ return sandboxPortUrl(apiSandbox, port, controllerEnv);
1436
+ }
1437
+
1438
+ /** A short role-count summary for a group, e.g. "1 api, 1 lead, 2 worker". */
1439
+ function roleCountSummary(sandboxes: E2BSandboxInfo[]): string {
1440
+ const counts: Record<MetadataSwarmRole, number> = { api: 0, lead: 0, worker: 0 };
1441
+ for (const sandbox of sandboxes) counts[sandboxSwarmRole(sandbox)]++;
1442
+ return (["api", "lead", "worker"] as const)
1443
+ .filter((role) => counts[role] > 0)
1444
+ .map((role) => `${counts[role]} ${role}`)
1445
+ .join(", ");
1446
+ }
1447
+
1448
+ /** The shortest remaining TTL across a group's sandboxes (the group's true expiry). */
1449
+ function groupTtlSummary(sandboxes: E2BSandboxInfo[]): string {
1450
+ let minSeconds: number | undefined;
1451
+ for (const sandbox of sandboxes) {
1452
+ const { secondsLeft } = ttlRemaining(sandbox);
1453
+ if (secondsLeft === undefined) continue;
1454
+ if (minSeconds === undefined || secondsLeft < minSeconds) minSeconds = secondsLeft;
1455
+ }
1456
+ return minSeconds === undefined ? "ttl unknown" : `expires in ${formatDuration(minSeconds)}`;
1457
+ }
1458
+
1459
+ /** Probe `GET <apiUrl>/health` once, unauthenticated. Returns up/down + detail. */
1460
+ async function probeHealth(apiUrl: string): Promise<{ up: boolean; detail: string }> {
1461
+ try {
1462
+ const response = await fetch(`${apiUrl.replace(/\/+$/, "")}/health`);
1463
+ return { up: response.ok, detail: `${response.status} ${response.statusText}`.trim() };
1464
+ } catch (err) {
1465
+ return { up: false, detail: err instanceof Error ? err.message : String(err) };
1466
+ }
1467
+ }
1468
+
1469
+ async function swarmsListCommand(flags: ParsedFlags, cwd: string): Promise<void> {
1470
+ const controllerEnv = await loadE2BControllerEnv(flags, cwd);
1471
+ const apiBase = e2bApiBase(flags, controllerEnv);
1472
+ const sandboxes = await listSandboxes(e2bControllerApiKey(controllerEnv), apiBase);
1473
+ const groups = groupSandboxesBySwarm(sandboxes);
1474
+
1475
+ if (booleanFlag(flags, "json")) {
1476
+ const payload = [...groups.entries()].map(([slug, members]) => ({
1477
+ swarm: slug,
1478
+ count: members.length,
1479
+ roles: roleCountSummary(members),
1480
+ sandboxIDs: members.map((m) => m.sandboxID),
1481
+ }));
1482
+ console.log(JSON.stringify(redactObjectWithEnv(payload, controllerEnv), null, 2));
1483
+ return;
1484
+ }
1485
+
1486
+ if (groups.size === 0) {
1487
+ console.log("no swarms found");
1488
+ return;
1489
+ }
1490
+ for (const [slug, members] of groups) {
1491
+ console.log(
1492
+ `${slug}\t${members.length} sandbox(es)\t${roleCountSummary(members)}\t${groupTtlSummary(
1493
+ members,
1494
+ )}`,
1495
+ );
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ * Select the sandboxes belonging to a swarm slug that WE launched. Restricting
1501
+ * to the `launcher === "agent-swarm-e2b"` tag (stamped by parseMetadata) matches
1502
+ * the `kill --all` ownership guard: without it, a foreign E2B sandbox using a
1503
+ * generic `metadata.swarm` key with a colliding slug would be pulled into the
1504
+ * group, so `swarms kill/info/logs/add <slug>` could operate on / delete
1505
+ * unrelated sandboxes. Pure (no I/O) so the ownership guarantee is unit-testable.
1506
+ */
1507
+ export function swarmGroupMembers(sandboxes: E2BSandboxInfo[], slug: string): E2BSandboxInfo[] {
1508
+ return sandboxes.filter(
1509
+ (sandbox) =>
1510
+ sandbox.metadata?.swarm === slug && sandbox.metadata?.launcher === "agent-swarm-e2b",
1511
+ );
1512
+ }
1513
+
1514
+ /** Find the sandboxes belonging to a swarm slug (throws if the group is empty). */
1515
+ async function resolveSwarmGroup(
1516
+ flags: ParsedFlags,
1517
+ cwd: string,
1518
+ slug: string,
1519
+ ): Promise<{ members: E2BSandboxInfo[]; controllerEnv: EnvMap; apiBase: string }> {
1520
+ const controllerEnv = await loadE2BControllerEnv(flags, cwd);
1521
+ const apiBase = e2bApiBase(flags, controllerEnv);
1522
+ const sandboxes = await listSandboxes(e2bControllerApiKey(controllerEnv), apiBase);
1523
+ const members = swarmGroupMembers(sandboxes, slug);
1524
+ if (members.length === 0) {
1525
+ throw new Error(`no swarm found with slug "${slug}" (try: e2b swarms list)`);
1526
+ }
1527
+ return { members, controllerEnv, apiBase };
1528
+ }
1529
+
1530
+ async function swarmsInfoCommand(flags: ParsedFlags, cwd: string): Promise<void> {
1531
+ const slug = flags.positionals[1];
1532
+ if (!slug) throw new Error("swarms info requires a slug: e2b swarms info <slug>");
1533
+
1534
+ const { members, controllerEnv } = await resolveSwarmGroup(flags, cwd, slug);
1535
+ const api = members.find((m) => sandboxSwarmRole(m) === "api");
1536
+ const lead = members.find((m) => sandboxSwarmRole(m) === "lead");
1537
+ const workers = members.filter((m) => sandboxSwarmRole(m) === "worker");
1538
+
1539
+ // Re-resolve the swarm API key LOCALLY (never from the sandbox) so we can build
1540
+ // the deep-link / authed probe. Source is reported; the value is masked.
1541
+ const runtime: EnvMap = selectEnv(process.env, [...DEFAULT_E2B_FORWARD_KEYS]);
1542
+ let resolvedKey = "";
1543
+ let keySource: string;
1544
+ try {
1545
+ resolvedKey = resolveSwarmApiKey(runtime, value(flags, "api-key"));
1546
+ keySource = swarmApiKeySource(flags, runtime);
1547
+ } catch {
1548
+ keySource = "unresolved (set AGENT_SWARM_API_KEY / API_KEY or pass --api-key)";
1549
+ }
1550
+
1551
+ const apiUrl = api ? swarmApiUrl(api, controllerEnv) : undefined;
1552
+
1553
+ console.log(`swarm: ${slug}`);
1554
+ console.log(`sandboxes: ${members.length} (${roleCountSummary(members)})`);
1555
+ if (apiUrl) console.log(`api url: ${apiUrl}`);
1556
+ console.log(`api key: ${maskKey(resolvedKey)} (${keySource})`);
1557
+
1558
+ // Per-sandbox lines, grouped API → lead → workers (resolved by swarmRole), each
1559
+ // with its agent ID (lead/workers) and remaining TTL.
1560
+ const ttlText = (member: E2BSandboxInfo): string => {
1561
+ const { secondsLeft } = ttlRemaining(member);
1562
+ return secondsLeft !== undefined ? `expires in ${formatDuration(secondsLeft)}` : "ttl unknown";
1563
+ };
1564
+ if (api) console.log(` api ${api.sandboxID} ${ttlText(api)}`);
1565
+ if (lead) {
1566
+ console.log(` lead ${lead.sandboxID} ${sandboxAgentId(lead)} ${ttlText(lead)}`);
1567
+ }
1568
+ for (const worker of workers) {
1569
+ console.log(` worker ${worker.sandboxID} ${sandboxAgentId(worker)} ${ttlText(worker)}`);
1570
+ }
1571
+
1572
+ // Single-shot unauthenticated health probe.
1573
+ if (apiUrl) {
1574
+ const health = await probeHealth(apiUrl);
1575
+ console.log(`health: ${health.up ? "up" : "down"} (${health.detail})`);
1576
+
1577
+ // If the key resolved, do one authenticated probe to detect a key mismatch.
1578
+ if (resolvedKey) {
1579
+ try {
1580
+ const authed = await fetch(`${apiUrl.replace(/\/+$/, "")}/api/agents`, {
1581
+ headers: { Authorization: `Bearer ${resolvedKey}` },
1582
+ });
1583
+ if (authed.status === 401) {
1584
+ console.warn(
1585
+ "warning: authenticated probe returned 401 — the resolved key may not match the launch key.",
1586
+ );
1587
+ }
1588
+ } catch {
1589
+ // A network error on the authed probe is non-fatal; the unauth health
1590
+ // probe above already reported reachability.
1591
+ }
1592
+ }
1593
+ }
1594
+
1595
+ // Dashboard deep-link (masked by default; --reveal-key embeds the key raw).
1596
+ printDashboardDeepLink(flags, {
1597
+ apiUrl,
1598
+ apiKey: resolvedKey || undefined,
1599
+ name: slug,
1600
+ env: controllerEnv,
1601
+ });
1602
+ }
1603
+
1604
+ async function swarmsKillCommand(flags: ParsedFlags, cwd: string): Promise<void> {
1605
+ const all = booleanFlag(flags, "all");
1606
+ const controllerEnv = await loadE2BControllerEnv(flags, cwd);
1607
+ const apiBase = e2bApiBase(flags, controllerEnv);
1608
+ const controllerApiKey = e2bControllerApiKey(controllerEnv);
1609
+
1610
+ // Build the ordered kill list. Within each group: every non-API sandbox first,
1611
+ // then the API LAST (so workers/lead never lose their API mid-teardown).
1612
+ function orderGroup(members: E2BSandboxInfo[]): E2BSandboxInfo[] {
1613
+ const apiLast = [...members].sort((a, b) => {
1614
+ const aApi = sandboxSwarmRole(a) === "api" ? 1 : 0;
1615
+ const bApi = sandboxSwarmRole(b) === "api" ? 1 : 0;
1616
+ return aApi - bApi;
1617
+ });
1618
+ return apiLast;
1619
+ }
1620
+
1621
+ let targets: E2BSandboxInfo[];
1622
+ let label: string;
1623
+ if (all) {
1624
+ const sandboxes = await listSandboxes(controllerApiKey, apiBase);
1625
+ const swarmTagged = sandboxes.filter((s) => s.metadata?.launcher === "agent-swarm-e2b");
1626
+ if (swarmTagged.length === 0) {
1627
+ console.log("no agent-swarm swarms to kill");
1628
+ return;
1629
+ }
1630
+ // Order each group api-last, then concatenate.
1631
+ const grouped = groupSandboxesBySwarm(swarmTagged);
1632
+ targets = [...grouped.values()].flatMap(orderGroup);
1633
+ label = `all ${grouped.size} swarm(s) (${targets.length} sandboxes)`;
1634
+ } else {
1635
+ const slug = flags.positionals[1];
1636
+ if (!slug) throw new Error("swarms kill requires a slug (or --all): e2b swarms kill <slug>");
1637
+ const { members } = await resolveSwarmGroup(flags, cwd, slug);
1638
+ targets = orderGroup(members);
1639
+ label = `swarm "${slug}" (${targets.length} sandboxes)`;
1640
+ }
1641
+
1642
+ const ok = await confirm(`Kill ${label}?`, flags);
1643
+ if (!ok) {
1644
+ console.log("aborted (pass --yes to skip this prompt)");
1645
+ return;
1646
+ }
1647
+
1648
+ for (const sandbox of targets) {
1649
+ await killSandbox(sandbox.sandboxID, controllerApiKey, apiBase);
1650
+ console.log(`killed ${sandbox.sandboxID} (${sandboxSwarmRole(sandbox)})`);
1651
+ }
1652
+ }
1653
+
1654
+ async function swarmsAddCommand(flags: ParsedFlags, cwd: string): Promise<void> {
1655
+ let slug = flags.positionals[1];
1656
+
1657
+ // No slug on a TTY → offer a picker of existing swarms.
1658
+ if (!slug) {
1659
+ if (!isInteractiveTty()) {
1660
+ throw new Error("swarms add requires a slug: e2b swarms add <slug>");
1661
+ }
1662
+ slug = await pickSwarmSlug(flags, cwd);
1663
+ if (!slug) {
1664
+ console.log("aborted (no swarm selected)");
1665
+ return;
1666
+ }
1667
+ }
1668
+
1669
+ const { members, controllerEnv, apiBase } = await resolveSwarmGroup(flags, cwd, slug);
1670
+ const api = members.find((m) => sandboxSwarmRole(m) === "api");
1671
+ if (!api) {
1672
+ throw new Error(`swarm "${slug}" has no API sandbox — cannot add members to it`);
1673
+ }
1674
+ const apiUrl = swarmApiUrl(api, controllerEnv);
1675
+ const controllerApiKey = e2bControllerApiKey(controllerEnv);
1676
+
1677
+ // Stamp the existing slug + point new members at the existing API. The slug
1678
+ // flows into metadata via parseMetadata; MCP_BASE_URL via the apiUrl arg below.
1679
+ setFlagValue(flags, "swarm", slug);
1680
+
1681
+ // Compute the group's current end so new members re-sync to the SAME wall-clock
1682
+ // expiry (reuse setSandboxTimeout). The shortest remaining TTL is the group's
1683
+ // true end; new members align to that rather than a fresh full TTL.
1684
+ const groupEndSeconds = members
1685
+ .map((m) => ttlRemaining(m).secondsLeft)
1686
+ .filter((s): s is number => s !== undefined);
1687
+ const resyncSeconds = groupEndSeconds.length > 0 ? Math.min(...groupEndSeconds) : undefined;
1688
+
1689
+ const addLead = booleanFlag(flags, "add-lead");
1690
+ const workerCount = integerFlag(flags, "workers", addLead ? 0 : 1);
1691
+ const added: StartedRole[] = [];
1692
+
1693
+ try {
1694
+ if (addLead) {
1695
+ const lead = await startRole(flags, cwd, STACK_LEAD_SPEC, apiUrl);
1696
+ added.push(lead);
1697
+ }
1698
+ for (let i = 0; i < workerCount; i++) {
1699
+ const worker = await startRole(flags, cwd, STACK_WORKER_SPEC, apiUrl);
1700
+ added.push(worker);
1701
+ }
1702
+ } catch (err) {
1703
+ await cleanupStartedRoles(flags, cwd, added);
1704
+ throw err;
1705
+ }
1706
+
1707
+ // Re-sync the freshly-added members to the group's current end (best-effort).
1708
+ if (resyncSeconds !== undefined && !booleanFlag(flags, "dry-run")) {
1709
+ for (const role of added) {
1710
+ try {
1711
+ await setSandboxTimeout({
1712
+ sandboxId: role.sandbox.sandboxID,
1713
+ apiKey: controllerApiKey,
1714
+ apiBase,
1715
+ e2bEnv: controllerEnv,
1716
+ timeoutMs: resyncSeconds * 1000,
1717
+ });
1718
+ } catch (err) {
1719
+ const message = err instanceof Error ? err.message : String(err);
1720
+ console.warn(
1721
+ redactWithEnv(
1722
+ `e2b: failed to re-sync TTL for added ${role.role} sandbox ${role.sandbox.sandboxID}: ${message}`,
1723
+ controllerEnv,
1724
+ ),
1725
+ );
1726
+ }
1727
+ }
1728
+ }
1729
+
1730
+ const runtimeEnv = await loadRuntimeEnv(flags, STACK_WORKER_SPEC, apiUrl);
1731
+ if (booleanFlag(flags, "json")) {
1732
+ console.log(
1733
+ JSON.stringify(
1734
+ { swarm: slug, added: added.map((r) => publicStartedRole(r, runtimeEnv)) },
1735
+ null,
1736
+ 2,
1737
+ ),
1738
+ );
1739
+ } else {
1740
+ console.log(`added ${added.length} member(s) to swarm ${slug}:`);
1741
+ for (const role of added) {
1742
+ printHumanStart(role, runtimeEnv);
1743
+ }
1744
+ }
1745
+ }
1746
+
1747
+ /** Render a one-shot Ink picker over existing swarm slugs. Returns "" if cancelled. */
1748
+ async function pickSwarmSlug(flags: ParsedFlags, cwd: string): Promise<string> {
1749
+ const controllerEnv = await loadE2BControllerEnv(flags, cwd);
1750
+ const apiBase = e2bApiBase(flags, controllerEnv);
1751
+ const sandboxes = await listSandboxes(e2bControllerApiKey(controllerEnv), apiBase);
1752
+ const groups = groupSandboxesBySwarm(sandboxes);
1753
+ const slugs = [...groups.keys()].filter((slug) => slug !== UNGROUPED_BUCKET);
1754
+ if (slugs.length === 0) {
1755
+ throw new Error("no existing swarms to add to (create one with e2b start-stack)");
1756
+ }
1757
+
1758
+ let chosen = "";
1759
+ const instance = render(
1760
+ createElement(SwarmPicker, {
1761
+ slugs: slugs.map((slug) => {
1762
+ const members = groups.get(slug) ?? [];
1763
+ return { slug, label: `${slug} (${roleCountSummary(members)})` };
1764
+ }),
1765
+ onSelect: (slug: string) => {
1766
+ chosen = slug;
1767
+ },
1768
+ }),
1769
+ );
1770
+ await instance.waitUntilExit();
1771
+ return chosen;
1772
+ }
1773
+
1774
+ /** The E2B `role` (api|worker) a swarm sandbox launched its entrypoint under. */
1775
+ function sandboxE2BRole(sandbox: E2BSandboxInfo): "api" | "worker" {
1776
+ // The entrypoint's tee log path keys off the E2B role, not the grouping role:
1777
+ // a lead is grouping-role "lead" but E2B role "worker" (so its log lives at
1778
+ // /tmp/agent-swarm-e2b-worker.log). Map back via the grouping role.
1779
+ return sandboxSwarmRole(sandbox) === "api" ? "api" : "worker";
1780
+ }
1781
+
1782
+ /**
1783
+ * `e2b swarms logs <slug> [--role api|lead|worker] [--follow]` — stream the
1784
+ * entrypoint log of a swarm's sandbox(es).
1785
+ *
1786
+ * Resolution: filter the swarm's members by `--role` (default: `api`, the most
1787
+ * useful single target — it carries the API boot lines + health). Reading the
1788
+ * deterministic per-role tee'd log path means NO PID bookkeeping is needed.
1789
+ *
1790
+ * Output is UNTRUSTED entrypoint stdout that can embed tokens, so every chunk is
1791
+ * routed through `redactWithEnv` (→ scrubSecrets) at this egress point before it
1792
+ * touches the terminal.
1793
+ */
1794
+ async function swarmsLogsCommand(flags: ParsedFlags, cwd: string): Promise<void> {
1795
+ const slug = flags.positionals[1];
1796
+ if (!slug) throw new Error("swarms logs requires a slug: e2b swarms logs <slug>");
1797
+
1798
+ const roleFlag = value(flags, "role") || "api";
1799
+ if (roleFlag !== "api" && roleFlag !== "lead" && roleFlag !== "worker") {
1800
+ throw new Error("--role must be one of api|lead|worker");
1801
+ }
1802
+
1803
+ const { members, controllerEnv, apiBase } = await resolveSwarmGroup(flags, cwd, slug);
1804
+ const targets = members.filter((m) => sandboxSwarmRole(m) === roleFlag);
1805
+ if (targets.length === 0) {
1806
+ throw new Error(`swarm "${slug}" has no ${roleFlag} sandbox`);
1807
+ }
1808
+
1809
+ // Egress redaction set. `controllerEnv` only carries E2B-controller + locally
1810
+ // resolved values, so launch-time secrets supplied via --secret / --env-file /
1811
+ // --inherit-env / a launch-specific --api-key would NOT be redacted unless the
1812
+ // operator re-supplies them here. Resolve runtime env the SAME way the launch
1813
+ // path does (loadRuntimeEnv with API_SPEC handles --env-file/--secret/
1814
+ // --inherit-env/--api-key + scoped flags) so any re-supplied launch secret is
1815
+ // scrubbed. A missing swarm API key must NOT hard-fail `swarms logs` (the start
1816
+ // path tolerates this only under --dry-run), so degrade to controllerEnv-only.
1817
+ let redactionEnv: EnvMap = controllerEnv;
1818
+ try {
1819
+ redactionEnv = { ...controllerEnv, ...(await loadRuntimeEnv(flags, API_SPEC)) };
1820
+ } catch {
1821
+ redactionEnv = controllerEnv;
1822
+ }
1823
+
1824
+ const follow = booleanFlag(flags, "follow");
1825
+ const tailLines = integerFlag(flags, "tail", 200);
1826
+ const controllerApiKey = e2bControllerApiKey(controllerEnv);
1827
+
1828
+ // Multi-target follow would interleave two live streams ambiguously; restrict
1829
+ // --follow to a single sandbox and point multi-worker users at --role/history.
1830
+ if (follow && targets.length > 1) {
1831
+ throw new Error(
1832
+ `swarm "${slug}" has ${targets.length} ${roleFlag} sandboxes — --follow needs a single target (omit --follow for history, or there is no per-sandbox selector yet)`,
1833
+ );
1834
+ }
1835
+
1836
+ // SIGINT (Ctrl-C) cleanly stops a --follow stream by aborting the tail.
1837
+ const controller = new AbortController();
1838
+ if (follow) {
1839
+ process.once("SIGINT", () => controller.abort());
1840
+ }
1841
+
1842
+ for (const target of targets) {
1843
+ if (targets.length > 1) {
1844
+ console.log(`==> ${roleFlag} ${target.sandboxID} <==`);
1845
+ }
1846
+ await streamSandboxLog({
1847
+ sandboxId: target.sandboxID,
1848
+ role: sandboxE2BRole(target),
1849
+ apiKey: controllerApiKey,
1850
+ apiBase,
1851
+ e2bEnv: controllerEnv,
1852
+ tailLines,
1853
+ follow,
1854
+ signal: follow ? controller.signal : undefined,
1855
+ // Egress scrub: entrypoint output can embed secrets — redact every chunk.
1856
+ // Scrubbed = known token shapes (scrubSecrets) + the controller env + any
1857
+ // launch secrets re-supplied here via --secret/--env-file/--inherit-env/
1858
+ // --api-key (folded into redactionEnv). Residual limitation: an arbitrary
1859
+ // secret known ONLY to a prior launch (never re-supplied, no known shape)
1860
+ // is NOT recoverable here and can stream raw — re-pass it to `swarms logs`
1861
+ // to scrub it, or treat the logs as sensitive.
1862
+ onChunk: (chunk) => process.stdout.write(redactWithEnv(chunk, redactionEnv)),
1863
+ });
1864
+ }
1865
+ }
1866
+
1867
+ async function swarmsCommand(flags: ParsedFlags, cwd: string): Promise<void> {
1868
+ const sub = flags.positionals[0];
1869
+ switch (sub) {
1870
+ case undefined:
1871
+ case "list":
1872
+ await swarmsListCommand(flags, cwd);
1873
+ return;
1874
+ case "info":
1875
+ await swarmsInfoCommand(flags, cwd);
1876
+ return;
1877
+ case "kill":
1878
+ await swarmsKillCommand(flags, cwd);
1879
+ return;
1880
+ case "add":
1881
+ await swarmsAddCommand(flags, cwd);
1882
+ return;
1883
+ case "logs":
1884
+ await swarmsLogsCommand(flags, cwd);
1885
+ return;
1886
+ default:
1887
+ throw new Error(`Unknown e2b swarms subcommand: ${sub} (expected list|info|kill|add|logs)`);
1888
+ }
1889
+ }
1890
+
656
1891
  function printE2BHelp(): void {
657
1892
  console.log(`
658
1893
  agent-swarm e2b
@@ -662,20 +1897,78 @@ Usage:
662
1897
  agent-swarm e2b delete-template <template-name...>
663
1898
  agent-swarm e2b publish-template <template-name...>
664
1899
  agent-swarm e2b unpublish-template <template-name...>
665
- agent-swarm e2b start-api --template <template> [--env-file .env]
666
- agent-swarm e2b start-worker --template <template> --api-url <https-url> [--env-file .env]
667
- agent-swarm e2b start-stack --api-template <template> --worker-template <template> [--workers 1]
1900
+ agent-swarm e2b start-api [--template <name>] [--env-file .env]
1901
+ agent-swarm e2b start-worker --api-url <https-url> [--template <name>] [--env-file .env]
1902
+ agent-swarm e2b start-stack [--swarm <slug>] [--workers <n>] [--no-lead] [--yes]
668
1903
  agent-swarm e2b list [--json]
669
- agent-swarm e2b kill <sandbox-id...>
1904
+ agent-swarm e2b swarms list | info <slug> | kill <slug> | add <slug> | logs <slug>
1905
+ agent-swarm e2b extend <sandbox-id...> --timeout-sec <seconds>
1906
+ agent-swarm e2b kill <sandbox-id...> | --all
670
1907
 
671
1908
  Common options:
672
- --env-file <path> Load runtime env/secrets for API or worker (repeatable)
673
- --secret KEY=VALUE Add/override one runtime secret (repeatable)
1909
+ --env-file <path> Load runtime env/secrets for all roles (repeatable)
1910
+ --secret KEY=VALUE Add/override one runtime secret for all roles (repeatable)
674
1911
  --inherit-env KEY[,KEY] Forward extra local env vars into the sandbox
675
- --api-key <key> Swarm API key passed to API/worker (required unless env provides one)
1912
+ --api-key <key> Swarm API key for API/worker (required unless env provides one)
1913
+ --api-url <https-url> Public API URL a worker connects to (start-worker)
676
1914
  --agent-id <id> Worker agent ID (default: e2b-<sandbox-id>)
677
- --timeout-sec <seconds> Sandbox TTL (default 3600)
1915
+ --agent-role worker|lead Role for start-worker (ignored by start-stack)
1916
+ --provider <name> Harness provider for workers (default claude)
1917
+ --template <name> Override the E2B template for the role
1918
+ --api-template / --worker-template <name> Per-role E2B template overrides
1919
+ --timeout-sec <seconds> Sandbox TTL (default 3600); for extend, the new TTL from now
1920
+ --no-wait Skip waiting for API health / worker registration
678
1921
  --e2b-api-key-file <path> Read the E2B controller API key from a file
1922
+
1923
+ start-stack (API + lead + N workers):
1924
+ Provisions an API, one lead, and N workers. Interactive wizard on a TTY;
1925
+ headless under --yes / --non-interactive / --dry-run / a non-TTY.
1926
+ --swarm <slug> Swarm name/slug (used for the wizard + echoed command)
1927
+ --workers <n> Worker count (default 1)
1928
+ --no-lead Legacy topology: API + N workers, no lead
1929
+ --lead-agent-id <id> Lead agent ID (default: e2b-lead-<sandbox-id>)
1930
+ --yes Skip the wizard; use flags + defaults (CI/headless)
1931
+ --non-interactive Same as --yes for prompting (never reads stdin)
1932
+ --integrations <csv> Allowlist of integrations to keep on (slack,github,jira,linear)
1933
+ --no-slack / --no-github / --no-jira / --no-linear
1934
+ Disable an integration (sets the API's <NAME>_DISABLE=true)
1935
+ JSON shape: {api, lead, workers:[...]} — or {api, workers:[...]} with --no-lead.
1936
+
1937
+ Role-scoped env (layer ON TOP of the shared --env-file/--secret, never replace):
1938
+ --api-env-file <path> Env file applied only to the API sandbox (repeatable)
1939
+ --lead-env-file <path> Env file applied only to the lead sandbox (repeatable)
1940
+ --worker-env-file <path> Env file applied only to worker sandboxes (repeatable)
1941
+ --api-secret KEY=VALUE Secret applied only to the API sandbox (repeatable)
1942
+ --lead-secret KEY=VALUE Secret applied only to the lead sandbox (repeatable)
1943
+ --worker-secret KEY=VALUE Secret applied only to worker sandboxes (repeatable)
1944
+ Precedence (highest wins): forward-keys < --env-file < --<scope>-env-file
1945
+ < --secret < --<scope>-secret < forced API_KEY/AGENT_SWARM_API_KEY.
1946
+
1947
+ swarms (group by metadata.swarm slug):
1948
+ list Group sandboxes by swarm (ungrouped → "(ungrouped)")
1949
+ info <slug> API URL, key source (masked), roles, per-sandbox TTL,
1950
+ a one-shot /health probe, and the dashboard deep-link
1951
+ kill <slug> | --all Tear down a swarm (API last) or every swarm (--all)
1952
+ add <slug> Add worker(s)/--add-lead to an existing swarm, TTL
1953
+ re-synced to the group's current end. No slug on a
1954
+ TTY → swarm picker. --workers <n> sets the count.
1955
+ logs <slug> Stream a sandbox's entrypoint log (envd-tracked +
1956
+ tee'd to file). --role api|lead|worker (default api),
1957
+ --follow to tail live, --tail <n> history lines
1958
+ (default 200). Output is scrubbed for secrets.
1959
+ --reveal-key Embed the swarm API key in the dashboard deep-link
1960
+ (printed RAW — the URL is a secret; hidden otherwise)
1961
+
1962
+ extend:
1963
+ Extend (or reduce) a live sandbox's TTL. E2B clamps to your tier max, so the
1964
+ printed expiry reflects what was actually applied. --dry-run never contacts E2B.
1965
+
1966
+ kill:
1967
+ --all Kill every sandbox launched by this dispatcher
1968
+ (metadata.launcher === agent-swarm-e2b)
1969
+ --yes Skip the multi-sandbox confirmation prompt (required in CI)
1970
+
1971
+ Global:
679
1972
  --json Print machine-readable output
680
1973
  --dry-run Print/derive planned work without touching E2B
681
1974
  `);
@@ -714,6 +2007,12 @@ export async function runE2BCommand(argv: string[]): Promise<void> {
714
2007
  case "list":
715
2008
  await listCommand(flags, cwd);
716
2009
  return;
2010
+ case "swarms":
2011
+ await swarmsCommand(flags, cwd);
2012
+ return;
2013
+ case "extend":
2014
+ await extendCommand(flags, cwd);
2015
+ return;
717
2016
  case "kill":
718
2017
  await killCommand(flags, cwd);
719
2018
  return;