@agentstep/agent-sdk 0.1.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 (105) hide show
  1. package/package.json +45 -0
  2. package/src/auth/middleware.ts +38 -0
  3. package/src/backends/claude/args.ts +88 -0
  4. package/src/backends/claude/index.ts +193 -0
  5. package/src/backends/claude/permission-hook.ts +152 -0
  6. package/src/backends/claude/tool-bridge.ts +211 -0
  7. package/src/backends/claude/translator.ts +209 -0
  8. package/src/backends/claude/wrapper-script.ts +45 -0
  9. package/src/backends/codex/args.ts +69 -0
  10. package/src/backends/codex/auth.ts +35 -0
  11. package/src/backends/codex/index.ts +57 -0
  12. package/src/backends/codex/setup.ts +37 -0
  13. package/src/backends/codex/translator.ts +223 -0
  14. package/src/backends/codex/wrapper-script.ts +26 -0
  15. package/src/backends/factory/args.ts +45 -0
  16. package/src/backends/factory/auth.ts +30 -0
  17. package/src/backends/factory/index.ts +56 -0
  18. package/src/backends/factory/setup.ts +34 -0
  19. package/src/backends/factory/translator.ts +139 -0
  20. package/src/backends/factory/wrapper-script.ts +33 -0
  21. package/src/backends/gemini/args.ts +44 -0
  22. package/src/backends/gemini/auth.ts +30 -0
  23. package/src/backends/gemini/index.ts +53 -0
  24. package/src/backends/gemini/setup.ts +34 -0
  25. package/src/backends/gemini/translator.ts +139 -0
  26. package/src/backends/gemini/wrapper-script.ts +26 -0
  27. package/src/backends/opencode/args.ts +53 -0
  28. package/src/backends/opencode/auth.ts +53 -0
  29. package/src/backends/opencode/index.ts +70 -0
  30. package/src/backends/opencode/mcp.ts +67 -0
  31. package/src/backends/opencode/setup.ts +54 -0
  32. package/src/backends/opencode/translator.ts +168 -0
  33. package/src/backends/opencode/wrapper-script.ts +46 -0
  34. package/src/backends/registry.ts +38 -0
  35. package/src/backends/shared/ndjson.ts +29 -0
  36. package/src/backends/shared/translator-types.ts +69 -0
  37. package/src/backends/shared/wrap-prompt.ts +17 -0
  38. package/src/backends/types.ts +85 -0
  39. package/src/config/index.ts +95 -0
  40. package/src/db/agents.ts +185 -0
  41. package/src/db/api_keys.ts +78 -0
  42. package/src/db/batch.ts +142 -0
  43. package/src/db/client.ts +81 -0
  44. package/src/db/environments.ts +127 -0
  45. package/src/db/events.ts +208 -0
  46. package/src/db/memory.ts +143 -0
  47. package/src/db/migrations.ts +295 -0
  48. package/src/db/proxy.ts +37 -0
  49. package/src/db/sessions.ts +295 -0
  50. package/src/db/vaults.ts +110 -0
  51. package/src/errors.ts +53 -0
  52. package/src/handlers/agents.ts +194 -0
  53. package/src/handlers/batch.ts +41 -0
  54. package/src/handlers/docs.ts +87 -0
  55. package/src/handlers/environments.ts +154 -0
  56. package/src/handlers/events.ts +234 -0
  57. package/src/handlers/index.ts +12 -0
  58. package/src/handlers/memory.ts +141 -0
  59. package/src/handlers/openapi.ts +14 -0
  60. package/src/handlers/sessions.ts +223 -0
  61. package/src/handlers/stream.ts +76 -0
  62. package/src/handlers/threads.ts +26 -0
  63. package/src/handlers/ui/app.js +984 -0
  64. package/src/handlers/ui/index.html +112 -0
  65. package/src/handlers/ui/style.css +164 -0
  66. package/src/handlers/ui.ts +1281 -0
  67. package/src/handlers/vaults.ts +99 -0
  68. package/src/http.ts +35 -0
  69. package/src/index.ts +104 -0
  70. package/src/init.ts +227 -0
  71. package/src/openapi/registry.ts +8 -0
  72. package/src/openapi/schemas.ts +625 -0
  73. package/src/openapi/spec.ts +691 -0
  74. package/src/providers/apple.ts +220 -0
  75. package/src/providers/daytona.ts +217 -0
  76. package/src/providers/docker.ts +264 -0
  77. package/src/providers/e2b.ts +203 -0
  78. package/src/providers/fly.ts +276 -0
  79. package/src/providers/modal.ts +222 -0
  80. package/src/providers/podman.ts +206 -0
  81. package/src/providers/registry.ts +28 -0
  82. package/src/providers/shared.ts +11 -0
  83. package/src/providers/sprites.ts +55 -0
  84. package/src/providers/types.ts +73 -0
  85. package/src/providers/vercel.ts +208 -0
  86. package/src/proxy/forward.ts +111 -0
  87. package/src/queue/index.ts +111 -0
  88. package/src/sessions/actor.ts +53 -0
  89. package/src/sessions/bus.ts +155 -0
  90. package/src/sessions/driver.ts +818 -0
  91. package/src/sessions/grader.ts +120 -0
  92. package/src/sessions/interrupt.ts +14 -0
  93. package/src/sessions/sweeper.ts +136 -0
  94. package/src/sessions/threads.ts +126 -0
  95. package/src/sessions/tools.ts +50 -0
  96. package/src/shutdown.ts +78 -0
  97. package/src/sprite/client.ts +294 -0
  98. package/src/sprite/exec.ts +161 -0
  99. package/src/sprite/lifecycle.ts +339 -0
  100. package/src/sprite/pool.ts +65 -0
  101. package/src/sprite/setup.ts +159 -0
  102. package/src/state.ts +61 -0
  103. package/src/types.ts +339 -0
  104. package/src/util/clock.ts +7 -0
  105. package/src/util/ids.ts +11 -0
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Session-scoped sprite lifecycle.
3
+ *
4
+ * - Sprites are reserved **lazily** on the first user.message, not at
5
+ * `POST /v1/sessions` time. This keeps session creation fast and decoupled
6
+ * from sprite provisioning latency.
7
+ * - Each session is pinned 1:1 to a sprite for its lifetime. No rebalancing.
8
+ * - Park/restore as originally planned is infeasible: spike S2 proved
9
+ * sprites.dev checkpoints are per-sprite only (no cross-sprite restore)
10
+ * and there is no stopSprite/suspendSprite API. The correct M5 model is
11
+ * idle eviction — see `lib/sessions/sweeper.ts`.
12
+ * - Orphan reconciliation runs on startup and periodically from the sweeper.
13
+ */
14
+ import { createSprite, deleteSprite, listSprites } from "./client";
15
+ import * as pool from "./pool";
16
+ import { installClaudeWrapper } from "../backends/claude/wrapper-script";
17
+ import { resolveBackend } from "../backends/registry";
18
+ import { getAgent } from "../db/agents";
19
+ import { getEnvironment, getEnvironmentRow } from "../db/environments";
20
+ import {
21
+ getSession,
22
+ getSessionRow,
23
+ setSessionSprite,
24
+ } from "../db/sessions";
25
+ import { appendEvent } from "../sessions/bus";
26
+ import { getConfig } from "../config";
27
+ import { ApiError } from "../errors";
28
+ import { nowMs } from "../util/clock";
29
+ import { resolveContainerProvider } from "../providers/registry";
30
+ import { dockerProvider } from "../providers/docker";
31
+ import { getVault, listEntries } from "../db/vaults";
32
+ import type { SessionResource } from "../types";
33
+
34
+ const SPRITE_NAME_PREFIX = "ca-sess-";
35
+
36
+ function deriveSpriteName(sessionId: string): string {
37
+ // Stable prefix + ULID tail, lowercased (sprites.dev requires lowercase names).
38
+ return `${SPRITE_NAME_PREFIX}${sessionId.replace(/^sess_/, "").toLowerCase()}`;
39
+ }
40
+
41
+ /**
42
+ * Acquire a sprite for the session if one is not already bound. Called
43
+ * from the driver on the first turn (not from the session create route).
44
+ *
45
+ * Runs the agent's backend-specific `prepareOnSprite` hook after the sprite
46
+ * is created. For claude this is a sub-second wrapper install; for opencode
47
+ * it's a ~10-second npm install + symlink.
48
+ *
49
+ * For backends with non-trivial prep time, emits
50
+ * `span.environment_setup_{start,end}` events so streaming clients can see
51
+ * the delay isn't a hang. Claude's prep is fast enough that we skip the
52
+ * spans to avoid noise.
53
+ */
54
+ export async function acquireForFirstTurn(sessionId: string): Promise<string> {
55
+ const row = getSessionRow(sessionId);
56
+ if (!row) throw new ApiError(404, "not_found_error", `session not found: ${sessionId}`);
57
+ if (row.sprite_name) return row.sprite_name;
58
+
59
+ const env = getEnvironmentRow(row.environment_id);
60
+ if (!env) throw new ApiError(404, "not_found_error", "environment not found");
61
+ if (env.state !== "ready") {
62
+ throw new ApiError(
63
+ 400,
64
+ "invalid_request_error",
65
+ `environment is not ready (state=${env.state})`,
66
+ );
67
+ }
68
+
69
+ if (pool.countInEnv(env.id) >= getConfig().maxSpritesPerEnv) {
70
+ throw new ApiError(503, "server_busy", "env sprite pool exhausted");
71
+ }
72
+
73
+ const agent = getAgent(row.agent_id, row.agent_version);
74
+ if (!agent) {
75
+ throw new ApiError(404, "not_found_error", "agent not found for session");
76
+ }
77
+ const backend = resolveBackend(agent.backend);
78
+
79
+ // Resolve the container provider from the environment config.
80
+ // Defaults to "sprites" for backward compatibility.
81
+ const envObj = getEnvironment(row.environment_id);
82
+ const provider = await resolveContainerProvider(envObj?.config?.provider);
83
+
84
+ // Pre-flight: re-check provider availability (may have changed since env creation)
85
+ if (provider.checkAvailability) {
86
+ const result = await provider.checkAvailability();
87
+ if (!result.available) {
88
+ throw new Error(`provider ${provider.name} is not available: ${result.message}`);
89
+ }
90
+ }
91
+
92
+ const name = deriveSpriteName(sessionId);
93
+ await provider.create({ name });
94
+
95
+ // Backends with slow prep (e.g. opencode's npm install) bracket the work
96
+ // with span events so the client sees something on the event stream
97
+ // rather than a minute of silence. Non-spec event type — clients that
98
+ // don't recognize it should drop it silently.
99
+ const needsSlowPrep = backend.name !== "claude";
100
+ if (needsSlowPrep) {
101
+ appendEvent(sessionId, {
102
+ type: "span.environment_setup_start",
103
+ payload: { backend: backend.name },
104
+ origin: "server",
105
+ processedAt: nowMs(),
106
+ });
107
+ }
108
+
109
+ try {
110
+ await backend.prepareOnSprite(name, provider);
111
+
112
+ // Install custom tool bridge if the agent has custom tools or threads_enabled (claude backend only)
113
+ if (agent.backend === "claude") {
114
+ const customTools = agent.tools.filter(
115
+ (t): t is import("../types").CustomTool => t.type === "custom",
116
+ );
117
+ // If threads are enabled, add spawn_agent as a synthetic custom tool
118
+ const allBridgeTools = [...customTools];
119
+ if (agent.threads_enabled) {
120
+ allBridgeTools.push({
121
+ type: "custom",
122
+ name: "spawn_agent",
123
+ description: "Spawn a sub-agent to handle a task. Returns the sub-agent's response.",
124
+ input_schema: {
125
+ type: "object",
126
+ properties: {
127
+ agent_id: { type: "string", description: "ID of the agent to spawn" },
128
+ prompt: { type: "string", description: "Task for the sub-agent" },
129
+ },
130
+ required: ["agent_id", "prompt"],
131
+ },
132
+ });
133
+ }
134
+ if (allBridgeTools.length > 0) {
135
+ const { installToolBridge } = await import("../backends/claude/index");
136
+ await installToolBridge(name, allBridgeTools, provider);
137
+ }
138
+
139
+ // Install permission hook if confirmation_mode is enabled
140
+ if (agent.confirmation_mode) {
141
+ const { installPermissionHook } = await import("../backends/claude/index");
142
+ await installPermissionHook(name, provider);
143
+ }
144
+ }
145
+ } catch (err) {
146
+ await provider.delete(name).catch(() => {});
147
+ throw err;
148
+ }
149
+
150
+ if (needsSlowPrep) {
151
+ appendEvent(sessionId, {
152
+ type: "span.environment_setup_end",
153
+ payload: { backend: backend.name },
154
+ origin: "server",
155
+ processedAt: nowMs(),
156
+ });
157
+ }
158
+
159
+ // Provision resources into the container if the session has any
160
+ const session = getSession(sessionId);
161
+ if (session?.resources && session.resources.length > 0) {
162
+ await provisionResources(name, session.resources, provider);
163
+ }
164
+
165
+ // Provision vault data into the container if the session has vault_ids
166
+ if (session?.vault_ids && session.vault_ids.length > 0) {
167
+ await provisionVaults(name, session.vault_ids, provider);
168
+ }
169
+
170
+ pool.register({
171
+ spriteName: name,
172
+ envId: env.id,
173
+ sessionId,
174
+ createdAt: nowMs(),
175
+ });
176
+ setSessionSprite(sessionId, name);
177
+ return name;
178
+ }
179
+
180
+ /**
181
+ * Download/write resources into /tmp/resources/ in the container.
182
+ * URIs are fetched via global fetch; text resources are written directly.
183
+ */
184
+ async function provisionResources(
185
+ spriteName: string,
186
+ resources: SessionResource[],
187
+ provider: import("../providers/types").ContainerProvider,
188
+ ): Promise<void> {
189
+ await provider.exec(spriteName, ["mkdir", "-p", "/tmp/resources"]);
190
+
191
+ for (let i = 0; i < resources.length; i++) {
192
+ const r = resources[i];
193
+ const filename = `/tmp/resources/resource_${i}`;
194
+
195
+ if (r.type === "uri" && r.uri) {
196
+ const MAX_RESOURCE_BYTES = 50 * 1024 * 1024; // 50 MB
197
+ try {
198
+ const resp = await fetch(r.uri, { signal: AbortSignal.timeout(30000) });
199
+ if (!resp.ok) {
200
+ console.warn(`[lifecycle] failed to fetch resource ${r.uri}: ${resp.status}`);
201
+ continue;
202
+ }
203
+ const contentLength = resp.headers.get("Content-Length");
204
+ if (contentLength && parseInt(contentLength, 10) > MAX_RESOURCE_BYTES) {
205
+ console.warn(`[lifecycle] skipping resource ${r.uri}: Content-Length ${contentLength} exceeds 50 MB limit`);
206
+ continue;
207
+ }
208
+ const content = await resp.text();
209
+ if (Buffer.byteLength(content, "utf8") > MAX_RESOURCE_BYTES) {
210
+ console.warn(`[lifecycle] skipping resource ${r.uri}: body size exceeds 50 MB limit`);
211
+ continue;
212
+ }
213
+ await provider.exec(spriteName, ["bash", "-c", `cat > ${filename}`], { stdin: content });
214
+ } catch (err) {
215
+ console.warn(`[lifecycle] failed to provision URI resource ${r.uri}:`, err);
216
+ }
217
+ } else if (r.type === "text" && r.content) {
218
+ try {
219
+ await provider.exec(spriteName, ["bash", "-c", `cat > ${filename}`], { stdin: r.content });
220
+ } catch (err) {
221
+ console.warn(`[lifecycle] failed to provision text resource:`, err);
222
+ }
223
+ }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Export vault entries as JSON files into /tmp/vaults/{vault_id}.json in the
229
+ * container. Each file contains `{entries: [{key, value}, ...]}`.
230
+ */
231
+ async function provisionVaults(
232
+ spriteName: string,
233
+ vaultIds: string[],
234
+ provider: import("../providers/types").ContainerProvider,
235
+ ): Promise<void> {
236
+ await provider.exec(spriteName, ["mkdir", "-p", "/tmp/vaults"]);
237
+
238
+ for (const vaultId of vaultIds) {
239
+ const vault = getVault(vaultId);
240
+ if (!vault) {
241
+ console.warn(`[lifecycle] vault not found: ${vaultId}, skipping`);
242
+ continue;
243
+ }
244
+ const entries = listEntries(vaultId);
245
+ const json = JSON.stringify({ vault_id: vaultId, name: vault.name, entries });
246
+ try {
247
+ await provider.exec(
248
+ spriteName,
249
+ ["bash", "-c", `cat > /tmp/vaults/${vaultId}.json`],
250
+ { stdin: json },
251
+ );
252
+ } catch (err) {
253
+ console.warn(`[lifecycle] failed to provision vault ${vaultId}:`, err);
254
+ }
255
+ }
256
+ }
257
+
258
+ // Re-export for test seeds and env setup that still use the claude wrapper path
259
+ export { installClaudeWrapper };
260
+
261
+ /**
262
+ * Release and delete the sprite bound to this session. Best-effort — logs
263
+ * failures but does not throw.
264
+ */
265
+ export async function releaseSession(sessionId: string): Promise<void> {
266
+ const entry = pool.unregister(sessionId);
267
+ const row = getSessionRow(sessionId);
268
+ const name = entry?.spriteName ?? row?.sprite_name ?? null;
269
+ if (name) {
270
+ // Resolve the provider from the session's environment config
271
+ const envObj = row ? getEnvironment(row.environment_id) : null;
272
+ const provider = await resolveContainerProvider(envObj?.config?.provider);
273
+ await provider.delete(name).catch((err: unknown) => {
274
+ console.warn(`releaseSession: failed to delete container ${name}:`, err);
275
+ });
276
+ }
277
+ if (row?.sprite_name) setSessionSprite(sessionId, null);
278
+ }
279
+
280
+ /**
281
+ * Reconcile orphaned sprites. Compares sprites.dev's fleet against our
282
+ * sessions table and deletes anything with our prefix that has no active
283
+ * session. Run on startup and on a 1h interval.
284
+ */
285
+ export async function reconcileOrphans(): Promise<{ deleted: number; kept: number }> {
286
+ let deleted = 0;
287
+ let kept = 0;
288
+
289
+ const liveNames = new Set(
290
+ pool.allSessionSprites().map((e) => e.spriteName).filter(Boolean),
291
+ );
292
+
293
+ let token: string | undefined;
294
+ for (;;) {
295
+ const res = await listSprites({
296
+ prefix: SPRITE_NAME_PREFIX,
297
+ max_results: 100,
298
+ continuation_token: token,
299
+ });
300
+ for (const s of res.sprites) {
301
+ if (liveNames.has(s.name)) {
302
+ kept++;
303
+ } else {
304
+ await deleteSprite(s.name).catch(() => {});
305
+ deleted++;
306
+ }
307
+ }
308
+ if (!res.has_more) break;
309
+ token = res.next_continuation_token ?? undefined;
310
+ if (!token) break;
311
+ }
312
+ return { deleted, kept };
313
+ }
314
+
315
+ /**
316
+ * Reconcile orphaned Docker containers. Same logic as reconcileOrphans
317
+ * but uses the Docker provider's list method (`docker ps --filter`).
318
+ * Run alongside reconcileOrphans on startup and on a 1h interval.
319
+ */
320
+ export async function reconcileDockerOrphans(): Promise<{ deleted: number; kept: number }> {
321
+ let deleted = 0;
322
+ let kept = 0;
323
+
324
+ const liveNames = new Set(
325
+ pool.allSessionSprites().map((e) => e.spriteName).filter(Boolean),
326
+ );
327
+
328
+ const containers = await dockerProvider.list({ prefix: SPRITE_NAME_PREFIX });
329
+ for (const c of containers) {
330
+ if (liveNames.has(c.name)) {
331
+ kept++;
332
+ } else {
333
+ await dockerProvider.delete(c.name).catch(() => {});
334
+ deleted++;
335
+ }
336
+ }
337
+ return { deleted, kept };
338
+ }
339
+
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Per-environment sprite pool + session-to-sprite affinity.
3
+ *
4
+ * Sessions are pinned 1:1 to a sprite for their entire lifetime. The pool
5
+ * tracks which sprites are attached to which sessions so we can enforce
6
+ * `max_sprites_per_env` and clean up on session delete.
7
+ *
8
+ * Pattern inspired by
9
+ *
10
+ */
11
+ export interface SpriteEntry {
12
+ spriteName: string;
13
+ envId: string;
14
+ sessionId: string;
15
+ createdAt: number;
16
+ }
17
+
18
+ type PoolState = {
19
+ byEnv: Map<string, SpriteEntry[]>;
20
+ bySession: Map<string, SpriteEntry>;
21
+ };
22
+
23
+ type GlobalPool = typeof globalThis & { __caPool?: PoolState };
24
+ const g = globalThis as GlobalPool;
25
+
26
+ function state(): PoolState {
27
+ if (!g.__caPool) {
28
+ g.__caPool = { byEnv: new Map(), bySession: new Map() };
29
+ }
30
+ return g.__caPool;
31
+ }
32
+
33
+ export function register(entry: SpriteEntry): void {
34
+ const s = state();
35
+ const list = s.byEnv.get(entry.envId) ?? [];
36
+ list.push(entry);
37
+ s.byEnv.set(entry.envId, list);
38
+ s.bySession.set(entry.sessionId, entry);
39
+ }
40
+
41
+ export function getBySession(sessionId: string): SpriteEntry | null {
42
+ return state().bySession.get(sessionId) ?? null;
43
+ }
44
+
45
+ export function countInEnv(envId: string): number {
46
+ return state().byEnv.get(envId)?.length ?? 0;
47
+ }
48
+
49
+ export function unregister(sessionId: string): SpriteEntry | null {
50
+ const s = state();
51
+ const entry = s.bySession.get(sessionId);
52
+ if (!entry) return null;
53
+ s.bySession.delete(sessionId);
54
+ const list = s.byEnv.get(entry.envId);
55
+ if (list) {
56
+ const idx = list.findIndex((e) => e.sessionId === sessionId);
57
+ if (idx >= 0) list.splice(idx, 1);
58
+ if (list.length === 0) s.byEnv.delete(entry.envId);
59
+ }
60
+ return entry;
61
+ }
62
+
63
+ export function allSessionSprites(): SpriteEntry[] {
64
+ return Array.from(state().bySession.values());
65
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Async environment setup: package install + checkpoint.
3
+ *
4
+ * Runs in the background after `POST /v1/environments` returns. Creates a
5
+ * fresh template sprite, installs packages per `env.config.packages`, writes
6
+ * an idempotency sentinel, creates a sprites.dev checkpoint, persists the
7
+ * checkpoint id on the environment row, deletes the template sprite, and
8
+ * flips `environments.state` to `ready` (or `failed` on error).
9
+ *
10
+ * Sentinel idempotency pattern from
11
+ *
12
+ *
13
+ * NOTE: the sentinel path assumes claude runs as user `sprite` with
14
+ * `HOME=/home/sprite`. Spike S1 validates this and may change the path.
15
+ */
16
+ import crypto from "node:crypto";
17
+ import {
18
+ createSprite,
19
+ deleteSprite,
20
+ httpExec,
21
+ createCheckpoint,
22
+ } from "./client";
23
+ import {
24
+ CLAUDE_WRAPPER_PATH as WRAPPER_PATH,
25
+ installClaudeWrapper as installWrapper,
26
+ } from "../backends/claude/wrapper-script";
27
+ import {
28
+ getEnvironmentRow,
29
+ updateEnvironmentCheckpoint,
30
+ updateEnvironmentState,
31
+ } from "../db/environments";
32
+ import type { EnvironmentConfig } from "../types";
33
+ import type { ContainerProvider } from "../providers/types";
34
+ import { resolveContainerProvider } from "../providers/registry";
35
+ import { newId } from "../util/ids";
36
+
37
+ const SENTINEL_DIR = "/home/sprite";
38
+
39
+ function hashPackages(packages: EnvironmentConfig["packages"]): string {
40
+ const canonical = JSON.stringify(packages ?? {}, Object.keys(packages ?? {}).sort());
41
+ return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
42
+ }
43
+
44
+ function buildInstallCommands(packages: EnvironmentConfig["packages"]): string[] {
45
+ if (!packages) return [];
46
+ const cmds: string[] = [];
47
+ // Alphabetical order to match Managed Agents spec behavior.
48
+ if (packages.apt?.length) {
49
+ cmds.push(`apt-get update -qq && apt-get install -y -qq ${packages.apt.map(shellEscape).join(" ")}`);
50
+ }
51
+ if (packages.cargo?.length) {
52
+ cmds.push(`cargo install ${packages.cargo.map(shellEscape).join(" ")}`);
53
+ }
54
+ if (packages.gem?.length) {
55
+ cmds.push(`gem install ${packages.gem.map(shellEscape).join(" ")}`);
56
+ }
57
+ if (packages.go?.length) {
58
+ for (const pkg of packages.go) cmds.push(`go install ${shellEscape(pkg)}`);
59
+ }
60
+ if (packages.npm?.length) {
61
+ cmds.push(`npm install -g ${packages.npm.map(shellEscape).join(" ")}`);
62
+ }
63
+ if (packages.pip?.length) {
64
+ cmds.push(`pip install ${packages.pip.map(shellEscape).join(" ")}`);
65
+ }
66
+ return cmds;
67
+ }
68
+
69
+ function shellEscape(s: string): string {
70
+ return `'${s.replace(/'/g, "'\\''")}'`;
71
+ }
72
+
73
+ /**
74
+ * Prepare a freshly-created sprite: install the wrapper script and run
75
+ * the environment's setup commands. Returns when the sentinel is in place.
76
+ */
77
+ export async function prepareSprite(
78
+ spriteName: string,
79
+ packages: EnvironmentConfig["packages"],
80
+ provider?: ContainerProvider,
81
+ ): Promise<void> {
82
+ const p = provider ?? await resolveContainerProvider();
83
+ await installWrapper(spriteName, p);
84
+
85
+ const hash = hashPackages(packages);
86
+ const sentinel = `${SENTINEL_DIR}/.claude-agents-setup-${hash}`;
87
+ const installCmds = buildInstallCommands(packages);
88
+ if (installCmds.length === 0) {
89
+ await p.exec(spriteName, ["bash", "-c", `touch ${sentinel}`]);
90
+ return;
91
+ }
92
+
93
+ const script = [
94
+ "set -euo pipefail",
95
+ `if [ -f ${sentinel} ]; then exit 0; fi`,
96
+ ...installCmds,
97
+ `touch ${sentinel}`,
98
+ ].join(" && ");
99
+
100
+ const result = await p.exec(spriteName, ["bash", "-c", script], {
101
+ timeoutMs: 30 * 60_000,
102
+ });
103
+ if (result.exit_code !== 0) {
104
+ throw new Error(`setup failed (${result.exit_code}): ${result.stderr.slice(0, 500)}`);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Kick off env setup in the background. Called from `POST /v1/environments`
110
+ * after the response has been sent.
111
+ */
112
+ export function kickoffEnvironmentSetup(envId: string): void {
113
+ // Fire-and-forget. Errors are captured onto the environment row.
114
+ runEnvironmentSetup(envId).catch((err: unknown) => {
115
+ const msg = err instanceof Error ? err.message : String(err);
116
+ console.error(`[env ${envId}] setup failed:`, msg);
117
+ try {
118
+ updateEnvironmentState(envId, "failed", msg);
119
+ } catch (dbErr) {
120
+ console.error(`[env ${envId}] failed to record setup failure:`, dbErr);
121
+ }
122
+ });
123
+ }
124
+
125
+ async function runEnvironmentSetup(envId: string): Promise<void> {
126
+ const row = getEnvironmentRow(envId);
127
+ if (!row) throw new Error(`environment ${envId} not found`);
128
+ if (row.state === "ready") return;
129
+
130
+ const config = JSON.parse(row.config_json) as EnvironmentConfig;
131
+ const provider = await resolveContainerProvider(config.provider);
132
+ const hasPackages = config.packages && Object.values(config.packages).some((v) => v && v.length > 0);
133
+
134
+ if (!hasPackages) {
135
+ // No packages to install — env is ready immediately. Each session sprite
136
+ // will get the wrapper installed in lifecycle.acquireForFirstTurn().
137
+ updateEnvironmentState(envId, "ready", null);
138
+ return;
139
+ }
140
+
141
+ // With packages: create a template container, run installs, then mark ready.
142
+ // NOTE: sprites.dev checkpoints are per-sprite only, so we can't snapshot
143
+ // the template and restore onto session sprites. For now, packages are
144
+ // re-installed per session sprite (slow). M5 will optimize with a sprite
145
+ // pool or alternative approach.
146
+ const templateName = `ca-env-tpl-${newId("env").slice(4, 16).toLowerCase()}`;
147
+ await provider.create({ name: templateName });
148
+ try {
149
+ await prepareSprite(templateName, config.packages, provider);
150
+ // Record that setup succeeded but no checkpoint to share.
151
+ updateEnvironmentState(envId, "ready", null);
152
+ } catch (err) {
153
+ throw err;
154
+ } finally {
155
+ await provider.delete(templateName).catch(() => {});
156
+ }
157
+ }
158
+
159
+ export { WRAPPER_PATH, SENTINEL_DIR };
package/src/state.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Global mutable runtime state — HMR-safe via globalThis so Next.js dev
3
+ * reloads don't duplicate maps.
4
+ *
5
+ * Pattern from
6
+ *
7
+ */
8
+
9
+ export interface InFlightRun {
10
+ sessionId: string;
11
+ controller: AbortController;
12
+ startedAt: number;
13
+ }
14
+
15
+ export type TurnInput =
16
+ | { kind: "text"; eventId: string; text: string }
17
+ | {
18
+ kind: "tool_result";
19
+ eventId: string;
20
+ custom_tool_use_id: string;
21
+ content: unknown[];
22
+ };
23
+
24
+ export interface PendingUserInput {
25
+ sessionId: string;
26
+ input: TurnInput;
27
+ }
28
+
29
+ type RuntimeState = {
30
+ inFlightRuns: Map<string, InFlightRun>;
31
+ pendingUserInputs: Map<string, TurnInput[]>;
32
+ };
33
+
34
+ type GlobalState = typeof globalThis & {
35
+ __caRuntime?: RuntimeState;
36
+ };
37
+
38
+ export function getRuntime(): RuntimeState {
39
+ const g = globalThis as GlobalState;
40
+ if (!g.__caRuntime) {
41
+ g.__caRuntime = {
42
+ inFlightRuns: new Map(),
43
+ pendingUserInputs: new Map(),
44
+ };
45
+ }
46
+ return g.__caRuntime;
47
+ }
48
+
49
+ export function pushPendingUserInput(input: PendingUserInput): void {
50
+ const rt = getRuntime();
51
+ const list = rt.pendingUserInputs.get(input.sessionId) ?? [];
52
+ list.push(input.input);
53
+ rt.pendingUserInputs.set(input.sessionId, list);
54
+ }
55
+
56
+ export function drainPendingUserInputs(sessionId: string): TurnInput[] {
57
+ const rt = getRuntime();
58
+ const list = rt.pendingUserInputs.get(sessionId) ?? [];
59
+ rt.pendingUserInputs.delete(sessionId);
60
+ return list;
61
+ }