@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
@@ -3,14 +3,15 @@
3
3
  *
4
4
  * To make a new entity kind seedable: implement a `Seeder`, add it here, done.
5
5
  * The harness ({@link ./runner}) and the boot/CLI entry points pick it up
6
- * automatically. Scripts is the only kind today.
6
+ * automatically.
7
7
  */
8
8
 
9
9
  import { scriptsSeeder } from "../seed-scripts";
10
+ import { skillsSeeder } from "../seed-skills";
10
11
  import { runSeeders } from "./runner";
11
12
  import type { Seeder, SeederResult } from "./types";
12
13
 
13
- export const SEEDERS: Seeder[] = [scriptsSeeder];
14
+ export const SEEDERS: Seeder[] = [scriptsSeeder, skillsSeeder];
14
15
 
15
16
  /** Apply every registered seeder. Called at API boot and by the seed CLI. */
16
17
  export function runAllSeeders(opts?: { quiet?: boolean }): Promise<SeederResult[]> {
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Built-in swarm skills catalog.
3
+ *
4
+ * Skill templates live under `templates/skills/<name>/`. Entries with
5
+ * `runAllSeedersCandidate: true` are seeded into the DB at swarm scope and are
6
+ * versioned by the generic seeder harness, so pristine built-ins update while
7
+ * user-modified skills are preserved.
8
+ */
9
+
10
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import artifactsConfig from "../../../templates/skills/artifacts/config.json" with { type: "text" };
13
+ import artifactsContent from "../../../templates/skills/artifacts/content.md" with { type: "text" };
14
+ import kvStorageConfig from "../../../templates/skills/kv-storage/config.json" with {
15
+ type: "text",
16
+ };
17
+ import kvStorageContent from "../../../templates/skills/kv-storage/content.md" with {
18
+ type: "text",
19
+ };
20
+ import pagesConfig from "../../../templates/skills/pages/config.json" with { type: "text" };
21
+ import pagesContent from "../../../templates/skills/pages/content.md" with { type: "text" };
22
+ import scheduledTaskResilienceConfig from "../../../templates/skills/scheduled-task-resilience/config.json" with {
23
+ type: "text",
24
+ };
25
+ import scheduledTaskResilienceContent from "../../../templates/skills/scheduled-task-resilience/content.md" with {
26
+ type: "text",
27
+ };
28
+ import swarmScriptsConfig from "../../../templates/skills/swarm-scripts/config.json" with {
29
+ type: "text",
30
+ };
31
+ import swarmScriptsContent from "../../../templates/skills/swarm-scripts/content.md" with {
32
+ type: "text",
33
+ };
34
+ import workflowIterateConfig from "../../../templates/skills/workflow-iterate/config.json" with {
35
+ type: "text",
36
+ };
37
+ import workflowIterateContent from "../../../templates/skills/workflow-iterate/content.md" with {
38
+ type: "text",
39
+ };
40
+ import workflowStructuredOutputConfig from "../../../templates/skills/workflow-structured-output/config.json" with {
41
+ type: "text",
42
+ };
43
+ import workflowStructuredOutputContent from "../../../templates/skills/workflow-structured-output/content.md" with {
44
+ type: "text",
45
+ };
46
+ import { computeContentHash, createSkill, getSkillByName, updateSkill } from "../db";
47
+ import type { Seeder, SeedItem } from "../seed/types";
48
+
49
+ type SkillTemplateConfig = {
50
+ name: string;
51
+ description: string;
52
+ runAllSeedersCandidate?: boolean;
53
+ systemDefault?: boolean;
54
+ };
55
+
56
+ export type SeedSkill = {
57
+ name: string;
58
+ description: string;
59
+ content: string;
60
+ systemDefault: boolean;
61
+ };
62
+
63
+ const BUILT_IN_SKILL_SOURCES = [
64
+ { config: artifactsConfig, body: artifactsContent },
65
+ { config: kvStorageConfig, body: kvStorageContent },
66
+ { config: pagesConfig, body: pagesContent },
67
+ { config: scheduledTaskResilienceConfig, body: scheduledTaskResilienceContent },
68
+ { config: swarmScriptsConfig, body: swarmScriptsContent },
69
+ { config: workflowIterateConfig, body: workflowIterateContent },
70
+ { config: workflowStructuredOutputConfig, body: workflowStructuredOutputContent },
71
+ ];
72
+
73
+ function buildSkillContent(config: SkillTemplateConfig, body: string): string {
74
+ return `---\nname: ${config.name}\ndescription: ${config.description}\n---\n\n${body.trim()}\n`;
75
+ }
76
+
77
+ function skillSeedHash(content: string, systemDefault: boolean): string {
78
+ return computeContentHash(`${content}\n\n# seed:systemDefault=${systemDefault ? "1" : "0"}\n`);
79
+ }
80
+
81
+ function seedSkillFromSource(
82
+ configRaw: string | SkillTemplateConfig,
83
+ body: string,
84
+ ): SeedSkill | null {
85
+ const config =
86
+ typeof configRaw === "string" ? (JSON.parse(configRaw) as SkillTemplateConfig) : configRaw;
87
+ if (!config.runAllSeedersCandidate) return null;
88
+ return {
89
+ name: config.name,
90
+ description: config.description,
91
+ content: buildSkillContent(config, body),
92
+ systemDefault: config.systemDefault === true,
93
+ };
94
+ }
95
+
96
+ export function loadSeedSkills(templatesDir?: string): SeedSkill[] {
97
+ if (!templatesDir) {
98
+ return BUILT_IN_SKILL_SOURCES.map(({ config, body }) => seedSkillFromSource(config, body))
99
+ .filter((skill): skill is SeedSkill => skill !== null)
100
+ .sort((a, b) => a.name.localeCompare(b.name));
101
+ }
102
+
103
+ if (!existsSync(templatesDir)) return [];
104
+
105
+ const skills: SeedSkill[] = [];
106
+ for (const entry of readdirSync(templatesDir, { withFileTypes: true })) {
107
+ if (!entry.isDirectory()) continue;
108
+
109
+ const dir = join(templatesDir, entry.name);
110
+ const configPath = join(dir, "config.json");
111
+ const contentPath = join(dir, "content.md");
112
+ if (!existsSync(configPath) || !existsSync(contentPath)) continue;
113
+
114
+ const config = JSON.parse(readFileSync(configPath, "utf-8")) as SkillTemplateConfig;
115
+ if (!config.runAllSeedersCandidate) continue;
116
+
117
+ const body = readFileSync(contentPath, "utf-8");
118
+ skills.push({
119
+ name: config.name,
120
+ description: config.description,
121
+ content: buildSkillContent(config, body),
122
+ systemDefault: config.systemDefault === true,
123
+ });
124
+ }
125
+
126
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
127
+ }
128
+
129
+ type SkillSeedItem = SeedItem & { skill: SeedSkill };
130
+
131
+ export const skillsSeeder: Seeder<SkillSeedItem> = {
132
+ kind: "skill",
133
+
134
+ items(): SkillSeedItem[] {
135
+ return loadSeedSkills().map((skill) => ({
136
+ key: skill.name,
137
+ contentHash: skillSeedHash(skill.content, skill.systemDefault),
138
+ skill,
139
+ }));
140
+ },
141
+
142
+ upstreamHash(item): string | null {
143
+ const existing = getSkillByName(item.key, "swarm");
144
+ return existing ? skillSeedHash(existing.content, existing.systemDefault) : null;
145
+ },
146
+
147
+ apply(item): void {
148
+ const { skill } = item;
149
+ const existing = getSkillByName(skill.name, "swarm");
150
+
151
+ if (existing) {
152
+ updateSkill(existing.id, {
153
+ name: skill.name,
154
+ description: skill.description,
155
+ content: skill.content,
156
+ scope: "swarm",
157
+ systemDefault: skill.systemDefault,
158
+ });
159
+ return;
160
+ }
161
+
162
+ createSkill({
163
+ name: skill.name,
164
+ description: skill.description,
165
+ content: skill.content,
166
+ type: "personal",
167
+ scope: "swarm",
168
+ ownerAgentId: undefined,
169
+ systemDefault: skill.systemDefault,
170
+ });
171
+ },
172
+ };
package/src/cli.tsx CHANGED
@@ -303,14 +303,43 @@ const COMMAND_HELP: Record<
303
303
  " unpublish-template <template...> Make E2B templates private",
304
304
  " start-api --template <name> Start the API in an E2B sandbox",
305
305
  " start-worker --api-url <url> Start a worker against a public API URL",
306
- " start-stack Start API plus one or more workers",
307
- " list | kill <sandbox-id...> Inspect or clean up sandboxes",
308
- " -h, --help Show this help",
306
+ " start-stack Start API + lead + N workers (wizard on a TTY)",
307
+ " list List dispatcher sandboxes",
308
+ " swarms list|info|kill|add|logs Group/inspect/teardown/grow/tail swarms by slug",
309
+ " extend <sandbox-id...> Extend a sandbox TTL (--timeout-sec <s>)",
310
+ " kill <sandbox-id...> | --all Clean up sandboxes (--all sweeps the fleet)",
311
+ "",
312
+ " swarms options:",
313
+ " swarms list Group sandboxes by metadata.swarm slug",
314
+ " swarms info <slug> API URL, key source (masked), roles, TTL, health",
315
+ " swarms kill <slug> | --all Tear down a swarm (API last), or every swarm",
316
+ " swarms add <slug> [--workers <n>] Add worker(s)/--add-lead to an existing swarm",
317
+ " swarms logs <slug> [--role r] Stream a sandbox entrypoint log (--follow to tail)",
318
+ " --reveal-key Embed the swarm key in the dashboard deep-link (raw)",
319
+ "",
320
+ " start-stack options:",
321
+ " --swarm <slug> Swarm name/slug (wizard + echoed one-shot command)",
322
+ " --workers <n> Worker count (default 1)",
323
+ " --no-lead Legacy topology: API + N workers, no lead",
324
+ " --lead-agent-id <id> Lead agent ID (default e2b-lead-<sandbox-id>)",
325
+ " --yes / --non-interactive Skip the wizard; use flags + defaults (headless)",
326
+ " --integrations <csv> Allowlist of integrations to keep on",
327
+ " --no-slack|github|jira|linear Disable an integration (sets API <NAME>_DISABLE)",
328
+ "",
329
+ " --provider <name> Harness provider for workers (default claude)",
330
+ " --timeout-sec <s> Sandbox TTL (default 3600)",
331
+ " --env-file / --secret Shared env/secrets applied to all roles (repeatable)",
332
+ " --<api|lead|worker>-env-file <path> Role-scoped env file, layers on the shared one (repeatable)",
333
+ " --<api|lead|worker>-secret KEY=VAL Role-scoped secret, layers on the shared one (repeatable)",
334
+ " --json Machine-readable output",
335
+ " --dry-run Derive planned work without touching E2B",
336
+ " -h, --help Show this help",
309
337
  ].join("\n"),
310
338
  examples: [
311
339
  ` ${binName} e2b build-template --role worker`,
312
340
  ` ${binName} e2b start-worker --api-url https://swarm.example.com --api-key "$SWARM_API_KEY"`,
313
- ` ${binName} e2b start-stack --workers 2 --api-key "$SWARM_API_KEY"`,
341
+ ` ${binName} e2b start-stack --yes --swarm demo --workers 2 --api-key "$SWARM_API_KEY"`,
342
+ ` ${binName} e2b start-stack --yes --no-lead --workers 2 --swarm demo`,
314
343
  ].join("\n"),
315
344
  },
316
345
  };
@@ -0,0 +1,394 @@
1
+ import { Select, TextInput } from "@inkjs/ui";
2
+ import { Box, Text, useApp } from "ink";
3
+ import { useState } from "react";
4
+
5
+ /**
6
+ * Interactive wizard for `e2b start-stack`. Collects the handful of decisions an
7
+ * operator makes when launching a swarm (name → slug, worker count, provider,
8
+ * TTL, env files, integrations), skipping any step whose flag was already
9
+ * provided on the command line. On finish it resolves a {@link StackWizardResult}
10
+ * back to the caller, which translates it into flags and (optionally) echoes the
11
+ * equivalent headless `--yes` command.
12
+ *
13
+ * Consistency with `onboard*.tsx`: Ink + `@inkjs/ui` `Select`/`TextInput`,
14
+ * one logical question per render, `useApp().exit()` to tear down on completion.
15
+ *
16
+ * Headless detection lives in the caller (`e2b.ts`) — this component is only
17
+ * ever rendered when interactive, so it always reads from stdin.
18
+ */
19
+
20
+ /** Integrations the wizard can toggle. Each maps to an API-side `*_DISABLE` env. */
21
+ export const STACK_INTEGRATIONS = ["slack", "github", "jira", "linear"] as const;
22
+ export type StackIntegration = (typeof STACK_INTEGRATIONS)[number];
23
+
24
+ /** Provider choices surfaced in the wizard picker (mirrors HARNESS_PROVIDER). */
25
+ export const STACK_PROVIDERS = ["claude", "codex", "pi", "devin"] as const;
26
+
27
+ export const DEFAULT_STACK_WORKERS = 1;
28
+ export const DEFAULT_STACK_TIMEOUT_SEC = 3600;
29
+
30
+ /**
31
+ * The shape the wizard resolves. `undefined` fields mean "the operator did not
32
+ * change the prebaked flag/default" — the caller already has the flag value, so
33
+ * it only overrides with wizard answers that were actually collected.
34
+ */
35
+ export type StackWizardResult = {
36
+ swarmSlug: string;
37
+ workers: number;
38
+ provider: string;
39
+ timeoutSec: number;
40
+ /** Shared --env-file paths the operator typed (comma/space separated → array). */
41
+ envFiles: string[];
42
+ /** Map of integration → enabled. A disabled integration becomes `*_DISABLE=true`. */
43
+ integrations: Record<StackIntegration, boolean>;
44
+ noLead: boolean;
45
+ };
46
+
47
+ /** Which wizard steps to skip because their value already came from a flag. */
48
+ export type StackWizardSkips = {
49
+ swarm?: boolean;
50
+ workers?: boolean;
51
+ provider?: boolean;
52
+ timeout?: boolean;
53
+ envFiles?: boolean;
54
+ integrations?: boolean;
55
+ };
56
+
57
+ export type StackWizardDefaults = {
58
+ swarmSlug?: string;
59
+ workers: number;
60
+ provider: string;
61
+ timeoutSec: number;
62
+ envFiles: string[];
63
+ integrations: Record<StackIntegration, boolean>;
64
+ noLead: boolean;
65
+ };
66
+
67
+ export type StackWizardProps = {
68
+ defaults: StackWizardDefaults;
69
+ skips: StackWizardSkips;
70
+ onComplete: (result: StackWizardResult) => void;
71
+ };
72
+
73
+ /** Lowercase, dash-separated slug suitable for a swarm name / metadata value. */
74
+ export function slugify(input: string): string {
75
+ return (
76
+ input
77
+ .trim()
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9]+/g, "-")
80
+ .replace(/^-+|-+$/g, "")
81
+ .slice(0, 48) || "swarm"
82
+ );
83
+ }
84
+
85
+ /** Human preview of when a TTL of `seconds` from now would expire. */
86
+ function expiryPreview(seconds: number): string {
87
+ const expires = new Date(Date.now() + seconds * 1000);
88
+ const hours = Math.floor(seconds / 3600);
89
+ const minutes = Math.floor((seconds % 3600) / 60);
90
+ const parts: string[] = [];
91
+ if (hours > 0) parts.push(`${hours}h`);
92
+ if (minutes > 0 || hours === 0) parts.push(`${minutes}m`);
93
+ return `${parts.join(" ")} from now (~${expires.toLocaleTimeString()})`;
94
+ }
95
+
96
+ type WizardStep =
97
+ | "mode"
98
+ | "swarm"
99
+ | "workers"
100
+ | "provider"
101
+ | "timeout"
102
+ | "env_files"
103
+ | "integrations"
104
+ | "done";
105
+
106
+ export function StackWizard({ defaults, skips, onComplete }: StackWizardProps) {
107
+ const { exit } = useApp();
108
+
109
+ // Accumulated answers, seeded from the flag-provided defaults.
110
+ const [result, setResult] = useState<StackWizardResult>({
111
+ swarmSlug: defaults.swarmSlug ?? "",
112
+ workers: defaults.workers,
113
+ provider: defaults.provider,
114
+ timeoutSec: defaults.timeoutSec,
115
+ envFiles: defaults.envFiles,
116
+ integrations: { ...defaults.integrations },
117
+ noLead: defaults.noLead,
118
+ });
119
+
120
+ // Determine the ordered list of steps, skipping any that are flag-satisfied.
121
+ const steps: WizardStep[] = ["mode"];
122
+ if (!skips.swarm) steps.push("swarm");
123
+ if (!skips.workers) steps.push("workers");
124
+ if (!skips.provider) steps.push("provider");
125
+ if (!skips.timeout) steps.push("timeout");
126
+ if (!skips.envFiles) steps.push("env_files");
127
+ if (!skips.integrations) steps.push("integrations");
128
+ steps.push("done");
129
+
130
+ const [stepIdx, setStepIdx] = useState(0);
131
+ const step = steps[stepIdx] ?? "done";
132
+ const [error, setError] = useState("");
133
+ // Toggle scratch state for the integrations multi-step.
134
+ const [integrationToggles, setIntegrationToggles] = useState<Record<StackIntegration, boolean>>({
135
+ ...defaults.integrations,
136
+ });
137
+
138
+ const advance = (partial?: Partial<StackWizardResult>) => {
139
+ setError("");
140
+ if (partial) setResult((r) => ({ ...r, ...partial }));
141
+ const nextIdx = stepIdx + 1;
142
+ setStepIdx(nextIdx);
143
+ if ((steps[nextIdx] ?? "done") === "done") {
144
+ // Resolve on the freshest result; merge any pending partial.
145
+ setResult((r) => {
146
+ const finalResult = { ...r, ...partial };
147
+ // Defer the callback so React finishes its commit before we exit.
148
+ queueMicrotask(() => {
149
+ onComplete(finalResult);
150
+ exit();
151
+ });
152
+ return finalResult;
153
+ });
154
+ }
155
+ };
156
+
157
+ if (step === "done") {
158
+ return (
159
+ <Box flexDirection="column" padding={1}>
160
+ <Text color="green">Configuration captured — launching…</Text>
161
+ </Box>
162
+ );
163
+ }
164
+
165
+ return (
166
+ <Box flexDirection="column" padding={1}>
167
+ <Text color="cyan" bold>
168
+ Agent Swarm — E2B stack launcher
169
+ </Text>
170
+ {error && (
171
+ <Box marginTop={1}>
172
+ <Text color="red">{error}</Text>
173
+ </Box>
174
+ )}
175
+
176
+ {step === "mode" && (
177
+ <Box marginTop={1} flexDirection="column">
178
+ <Text bold>Create a new swarm or add to an existing one?</Text>
179
+ <Box marginTop={1}>
180
+ <Select
181
+ options={[
182
+ { label: "Create a new swarm", value: "create" },
183
+ // Phase 4: the add-to-existing flow lives in the standalone
184
+ // `e2b swarms add` command, which has its own TTY swarm picker,
185
+ // `--workers`/`--add-lead` flags, and TTL re-sync to the group's
186
+ // end. Rather than fork that whole flow into this wizard (which
187
+ // is scoped to *creating* a stack), we point the operator at it.
188
+ // This keeps a single, fully-working add path instead of a
189
+ // half-duplicated one.
190
+ { label: "Add to an existing swarm (run: e2b swarms add <slug>)", value: "add" },
191
+ ]}
192
+ onChange={(value) => {
193
+ if (value === "add") {
194
+ setError(
195
+ "To add to an existing swarm, exit and run: e2b swarms add <slug> " +
196
+ "(no slug → it lists your swarms to pick from). Choose 'Create a new swarm' to continue here.",
197
+ );
198
+ return;
199
+ }
200
+ advance();
201
+ }}
202
+ />
203
+ </Box>
204
+ </Box>
205
+ )}
206
+
207
+ {step === "swarm" && (
208
+ <Box marginTop={1} flexDirection="column">
209
+ <Text bold>Swarm name:</Text>
210
+ <Text dimColor>Used as the group slug and dashboard name.</Text>
211
+ <Box marginTop={1}>
212
+ <TextInput
213
+ key="swarm-name"
214
+ placeholder="my-swarm"
215
+ defaultValue={result.swarmSlug}
216
+ onSubmit={(raw) => {
217
+ const slug = slugify(raw || "swarm");
218
+ advance({ swarmSlug: slug });
219
+ }}
220
+ />
221
+ </Box>
222
+ </Box>
223
+ )}
224
+
225
+ {step === "workers" && (
226
+ <Box marginTop={1} flexDirection="column">
227
+ <Text bold>How many workers?</Text>
228
+ <Box marginTop={1}>
229
+ <TextInput
230
+ key="workers"
231
+ placeholder={String(result.workers)}
232
+ defaultValue={String(result.workers)}
233
+ onSubmit={(raw) => {
234
+ const trimmed = raw.trim();
235
+ const parsed = trimmed ? Number.parseInt(trimmed, 10) : result.workers;
236
+ // Keep this in lock-step with the headless `integerFlag("workers")`
237
+ // contract, which requires a positive integer.
238
+ if (!Number.isFinite(parsed) || parsed < 1) {
239
+ setError("Enter a positive integer (at least 1 worker).");
240
+ return;
241
+ }
242
+ advance({ workers: parsed });
243
+ }}
244
+ />
245
+ </Box>
246
+ </Box>
247
+ )}
248
+
249
+ {step === "provider" && (
250
+ <Box marginTop={1} flexDirection="column">
251
+ <Text bold>Harness provider:</Text>
252
+ <Box marginTop={1}>
253
+ <Select
254
+ options={STACK_PROVIDERS.map((p) => ({ label: p, value: p }))}
255
+ onChange={(value) => advance({ provider: value })}
256
+ />
257
+ </Box>
258
+ </Box>
259
+ )}
260
+
261
+ {step === "timeout" && (
262
+ <Box marginTop={1} flexDirection="column">
263
+ <Text bold>Sandbox TTL (seconds):</Text>
264
+ <Text dimColor>
265
+ Default {result.timeoutSec}s — {expiryPreview(result.timeoutSec)}.
266
+ </Text>
267
+ <Box marginTop={1}>
268
+ <TextInput
269
+ key="timeout"
270
+ placeholder={String(result.timeoutSec)}
271
+ defaultValue={String(result.timeoutSec)}
272
+ onSubmit={(raw) => {
273
+ const trimmed = raw.trim();
274
+ const parsed = trimmed ? Number.parseInt(trimmed, 10) : result.timeoutSec;
275
+ if (!Number.isFinite(parsed) || parsed <= 0) {
276
+ setError("Enter a positive integer (seconds).");
277
+ return;
278
+ }
279
+ advance({ timeoutSec: parsed });
280
+ }}
281
+ />
282
+ </Box>
283
+ </Box>
284
+ )}
285
+
286
+ {step === "env_files" && (
287
+ <Box marginTop={1} flexDirection="column">
288
+ <Text bold>Shared env file(s):</Text>
289
+ <Text dimColor>Comma-separated paths applied to all roles, or leave blank.</Text>
290
+ <Box marginTop={1}>
291
+ <TextInput
292
+ key="env-files"
293
+ placeholder=".env"
294
+ defaultValue={result.envFiles.join(",")}
295
+ onSubmit={(raw) => {
296
+ const files = raw
297
+ .split(",")
298
+ .map((p) => p.trim())
299
+ .filter(Boolean);
300
+ advance({ envFiles: files });
301
+ }}
302
+ />
303
+ </Box>
304
+ </Box>
305
+ )}
306
+
307
+ {step === "integrations" && (
308
+ <IntegrationToggleStep
309
+ toggles={integrationToggles}
310
+ setToggles={setIntegrationToggles}
311
+ onContinue={() => advance({ integrations: { ...integrationToggles } })}
312
+ />
313
+ )}
314
+ </Box>
315
+ );
316
+ }
317
+
318
+ /**
319
+ * One-shot picker over existing swarm slugs, used by `e2b swarms add` when no
320
+ * slug was passed on an interactive TTY. Resolves the chosen slug back to the
321
+ * caller via `onSelect`, then exits. Consistent with the wizard's Ink + Select.
322
+ */
323
+ export type SwarmPickerOption = { slug: string; label: string };
324
+
325
+ export function SwarmPicker({
326
+ slugs,
327
+ onSelect,
328
+ }: {
329
+ slugs: SwarmPickerOption[];
330
+ onSelect: (slug: string) => void;
331
+ }) {
332
+ const { exit } = useApp();
333
+ return (
334
+ <Box flexDirection="column" padding={1}>
335
+ <Text color="cyan" bold>
336
+ Add to which swarm?
337
+ </Text>
338
+ <Box marginTop={1}>
339
+ <Select
340
+ options={slugs.map((s) => ({ label: s.label, value: s.slug }))}
341
+ onChange={(value) => {
342
+ // Defer so React commits before we tear down the Ink instance.
343
+ queueMicrotask(() => {
344
+ onSelect(value);
345
+ exit();
346
+ });
347
+ }}
348
+ />
349
+ </Box>
350
+ </Box>
351
+ );
352
+ }
353
+
354
+ const CONTINUE_VALUE = "__continue__";
355
+
356
+ function IntegrationToggleStep({
357
+ toggles,
358
+ setToggles,
359
+ onContinue,
360
+ }: {
361
+ toggles: Record<StackIntegration, boolean>;
362
+ setToggles: (
363
+ update: (prev: Record<StackIntegration, boolean>) => Record<StackIntegration, boolean>,
364
+ ) => void;
365
+ onContinue: () => void;
366
+ }) {
367
+ const options = [
368
+ ...STACK_INTEGRATIONS.map((key) => ({
369
+ label: `${toggles[key] ? "[x]" : "[ ]"} ${key}`,
370
+ value: key as string,
371
+ })),
372
+ { label: "Continue →", value: CONTINUE_VALUE },
373
+ ];
374
+
375
+ return (
376
+ <Box marginTop={1} flexDirection="column">
377
+ <Text bold>Integrations (enabled = on):</Text>
378
+ <Text dimColor>Disabled integrations set the matching *_DISABLE env on the API.</Text>
379
+ <Box marginTop={1}>
380
+ <Select
381
+ options={options}
382
+ onChange={(value) => {
383
+ if (value === CONTINUE_VALUE) {
384
+ onContinue();
385
+ return;
386
+ }
387
+ const key = value as StackIntegration;
388
+ setToggles((prev) => ({ ...prev, [key]: !prev[key] }));
389
+ }}
390
+ />
391
+ </Box>
392
+ </Box>
393
+ );
394
+ }