@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,120 @@
1
+ /**
2
+ * Outcome grader — evaluates agent output against a rubric.
3
+ *
4
+ * Runs as a direct Anthropic API call from the Node.js process (NOT
5
+ * `claude -p` on the container) to avoid corrupting Claude session state.
6
+ * Uses tool_use with a structured result schema for reliable parsing.
7
+ */
8
+ import { getConfig } from "../config";
9
+
10
+ export type GraderResult = "satisfied" | "needs_revision" | "failed";
11
+
12
+ export interface GraderEvaluation {
13
+ result: GraderResult;
14
+ feedback: string;
15
+ usage: { input_tokens: number; output_tokens: number };
16
+ }
17
+
18
+ const GRADER_SYSTEM = `You are an evaluation grader. You evaluate whether an agent's work output satisfies a rubric.
19
+
20
+ You MUST call the evaluate_outcome tool with your assessment. Do not respond with plain text.`;
21
+
22
+ const EVALUATE_TOOL = {
23
+ name: "evaluate_outcome",
24
+ description: "Submit your evaluation of the agent's output against the rubric.",
25
+ input_schema: {
26
+ type: "object" as const,
27
+ properties: {
28
+ result: {
29
+ type: "string" as const,
30
+ enum: ["satisfied", "needs_revision", "failed"],
31
+ description: "satisfied: output meets the rubric. needs_revision: output is close but needs specific changes. failed: output fundamentally fails to meet the rubric.",
32
+ },
33
+ feedback: {
34
+ type: "string" as const,
35
+ description: "Brief explanation of the evaluation. If needs_revision, include specific actionable feedback for the agent.",
36
+ },
37
+ },
38
+ required: ["result", "feedback"],
39
+ },
40
+ };
41
+
42
+ /**
43
+ * Call the Anthropic API directly to grade agent output against a rubric.
44
+ * Falls back to a simple "satisfied" if the API key is not configured.
45
+ */
46
+ export async function runGraderEvaluation(
47
+ rubric: string,
48
+ agentOutput: string,
49
+ model: string,
50
+ ): Promise<GraderEvaluation> {
51
+ const cfg = getConfig();
52
+ const apiKey = cfg.anthropicApiKey;
53
+
54
+ if (!apiKey) {
55
+ // No API key — can't grade, default to satisfied
56
+ return {
57
+ result: "satisfied",
58
+ feedback: "Grader skipped: no ANTHROPIC_API_KEY configured for direct API evaluation.",
59
+ usage: { input_tokens: 0, output_tokens: 0 },
60
+ };
61
+ }
62
+
63
+ const userMessage = `## Rubric\n${rubric}\n\n## Agent Output\n${agentOutput}`;
64
+
65
+ const response = await fetch("https://api.anthropic.com/v1/messages", {
66
+ method: "POST",
67
+ headers: {
68
+ "content-type": "application/json",
69
+ "x-api-key": apiKey,
70
+ "anthropic-version": "2023-06-01",
71
+ },
72
+ body: JSON.stringify({
73
+ model: model || "claude-sonnet-4-20250514",
74
+ max_tokens: 1024,
75
+ system: GRADER_SYSTEM,
76
+ tools: [EVALUATE_TOOL],
77
+ tool_choice: { type: "tool", name: "evaluate_outcome" },
78
+ messages: [{ role: "user", content: userMessage }],
79
+ }),
80
+ });
81
+
82
+ if (!response.ok) {
83
+ const errText = await response.text().catch(() => "");
84
+ console.warn(`[grader] API call failed (${response.status}): ${errText}`);
85
+ return {
86
+ result: "satisfied",
87
+ feedback: `Grader API error (${response.status}), defaulting to satisfied.`,
88
+ usage: { input_tokens: 0, output_tokens: 0 },
89
+ };
90
+ }
91
+
92
+ const data = await response.json() as {
93
+ content: Array<{ type: string; name?: string; input?: { result?: string; feedback?: string } }>;
94
+ usage: { input_tokens: number; output_tokens: number };
95
+ };
96
+
97
+ // Extract the tool_use result
98
+ const toolUse = data.content.find(
99
+ (b) => b.type === "tool_use" && b.name === "evaluate_outcome",
100
+ );
101
+
102
+ if (!toolUse?.input?.result) {
103
+ return {
104
+ result: "satisfied",
105
+ feedback: "Grader returned no structured result, defaulting to satisfied.",
106
+ usage: data.usage ?? { input_tokens: 0, output_tokens: 0 },
107
+ };
108
+ }
109
+
110
+ const validResults: GraderResult[] = ["satisfied", "needs_revision", "failed"];
111
+ const result = validResults.includes(toolUse.input.result as GraderResult)
112
+ ? (toolUse.input.result as GraderResult)
113
+ : "satisfied";
114
+
115
+ return {
116
+ result,
117
+ feedback: toolUse.input.feedback ?? "",
118
+ usage: data.usage ?? { input_tokens: 0, output_tokens: 0 },
119
+ };
120
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Interrupt handler.
3
+ *
4
+ * Must be called from inside the session's actor so the interrupt lands
5
+ * between two consecutive event appends, never in the middle of one.
6
+ */
7
+ import { getRuntime } from "../state";
8
+
9
+ export function interruptSession(sessionId: string): boolean {
10
+ const run = getRuntime().inFlightRuns.get(sessionId);
11
+ if (!run) return false;
12
+ run.controller.abort(new DOMException("interrupted", "AbortError"));
13
+ return true;
14
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Periodic sweeper: idle eviction + orphan reconcile.
3
+ *
4
+ * One unified sweeper with one `setInterval`, installed in `lib/init.ts`.
5
+ * One timer means one set of interleaving concerns.
6
+ *
7
+ * Park/restore as originally planned is infeasible (spike S2: sprites.dev
8
+ * checkpoints are per-sprite only; no stopSprite API). The correct M5
9
+ * model is: pin the sprite for the session's lifetime, and evict the
10
+ * session (release the sprite, terminate the row) after idle TTL expires.
11
+ *
12
+ * Re-entrancy: `sweeping` prevents overlapping ticks from stacking if a
13
+ * prior sweep runs longer than the interval. A stuck `releaseSession` can
14
+ * take up to `spriteTimeoutMs` (30s default) per candidate, so a bad run
15
+ * with 50 candidates could exceed the 60s interval.
16
+ *
17
+ * Shutdown cooperation: `stopping` is set by the shutdown handler so the
18
+ * sweep bails out cleanly between candidates if SIGTERM fires mid-run.
19
+ */
20
+ import { getDb } from "../db/client";
21
+ import { getConfig } from "../config";
22
+ import { nowMs } from "../util/clock";
23
+ import { getRuntime } from "../state";
24
+ import { getActor, dropActor } from "./actor";
25
+ import { appendEvent, dropEmitter } from "./bus";
26
+ import {
27
+ archiveSession,
28
+ getSessionRow,
29
+ updateSessionStatus,
30
+ } from "../db/sessions";
31
+ import { releaseSession, reconcileOrphans, reconcileDockerOrphans } from "../sprite/lifecycle";
32
+
33
+ let sweeping = false;
34
+ let stopping = false;
35
+
36
+ export function markStopping(): void {
37
+ stopping = true;
38
+ }
39
+
40
+ /** Exposed for tests only. */
41
+ export function __resetSweeperState(): void {
42
+ sweeping = false;
43
+ stopping = false;
44
+ }
45
+
46
+ export async function runSweep(): Promise<void> {
47
+ if (sweeping || stopping) return;
48
+ sweeping = true;
49
+ try {
50
+ try {
51
+ await evictIdleSessions();
52
+ } catch (e) {
53
+ console.warn("[sweeper] evict failed:", e);
54
+ }
55
+ const cfg = getConfig();
56
+ if (cfg.spriteToken) {
57
+ try {
58
+ await reconcileOrphans();
59
+ } catch (e) {
60
+ console.warn("[sweeper] reconcile sprites failed:", e);
61
+ }
62
+ }
63
+ try {
64
+ await reconcileDockerOrphans();
65
+ } catch (e) {
66
+ // Docker not available — skip silently
67
+ if (!(e instanceof Error) || !e.message.includes("ENOENT")) {
68
+ console.warn("[sweeper] reconcile docker failed:", e);
69
+ }
70
+ }
71
+ } finally {
72
+ sweeping = false;
73
+ }
74
+ }
75
+
76
+ async function evictIdleSessions(): Promise<void> {
77
+ if (stopping) return;
78
+ const cfg = getConfig();
79
+ const now = nowMs();
80
+
81
+ // COALESCE so sessions that never ran a turn (idle_since IS NULL) still
82
+ // age out from their created_at. LIMIT caps the worst case per sweep.
83
+ const rows = getDb()
84
+ .prepare(
85
+ `SELECT id FROM sessions
86
+ WHERE status = 'idle'
87
+ AND archived_at IS NULL
88
+ AND COALESCE(idle_since, created_at) + ? < ?
89
+ LIMIT ?`,
90
+ )
91
+ .all(cfg.sessionMaxAgeMs, now, 50) as { id: string }[];
92
+
93
+ if (rows.length === 0) return;
94
+
95
+ for (const { id: sessionId } of rows) {
96
+ if (stopping) return;
97
+ try {
98
+ await getActor(sessionId).enqueue(async () => {
99
+ // CRITICAL: runTurn executes OUTSIDE the actor lock (see
100
+ // app/v1/sessions/[id]/events/route.ts — enqueueTurn launches runTurn
101
+ // after releasing the actor). Checking session.status in the DB is
102
+ // not enough — it may still be "idle" for a few ms after a fresh
103
+ // POST /events fired off a new turn. The in-memory inFlightRuns map
104
+ // is the authoritative "turn in progress" signal.
105
+ const rt = getRuntime();
106
+ if (rt.inFlightRuns.has(sessionId)) return;
107
+
108
+ const row = getSessionRow(sessionId);
109
+ if (!row || row.status !== "idle" || row.archived_at != null) return;
110
+
111
+ // Re-check the TTL inside the lock — if another code path already
112
+ // bumped idle_since forward (turn completed), bail.
113
+ const base = row.idle_since ?? row.created_at;
114
+ if (base + cfg.sessionMaxAgeMs >= now) return;
115
+
116
+ await releaseSession(sessionId);
117
+
118
+ appendEvent(sessionId, {
119
+ type: "session.status_terminated",
120
+ payload: { reason: "idle_ttl" },
121
+ origin: "server",
122
+ processedAt: nowMs(),
123
+ });
124
+ updateSessionStatus(sessionId, "terminated", "idle_ttl");
125
+ archiveSession(sessionId);
126
+ });
127
+ dropActor(sessionId);
128
+ dropEmitter(sessionId);
129
+ } catch (err) {
130
+ // Per-candidate isolation: one stuck session must not block the rest
131
+ // of the sweep. `releaseSession` is already best-effort internally,
132
+ // so this catch mainly protects against appendEvent/DB failures.
133
+ console.warn(`[sweeper] evict ${sessionId} failed:`, err);
134
+ }
135
+ }
136
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Multi-agent thread orchestrator.
3
+ *
4
+ * When a parent session's agent calls `spawn_agent`, the driver delegates
5
+ * to this module. It creates a child session, runs it to completion, and
6
+ * returns the child's final agent.message text as the tool result.
7
+ *
8
+ * Depth is capped at MAX_THREAD_DEPTH to prevent infinite recursion.
9
+ */
10
+ import { createSession, getSessionRow } from "../db/sessions";
11
+ import { getAgent } from "../db/agents";
12
+ import { getSession } from "../db/sessions";
13
+ import { listEvents } from "../db/events";
14
+ import { appendEvent } from "./bus";
15
+ import { getActor } from "./actor";
16
+ import { runTurn } from "./driver";
17
+ import { nowMs } from "../util/clock";
18
+ import { ApiError } from "../errors";
19
+
20
+ const MAX_THREAD_DEPTH = 3;
21
+
22
+ /**
23
+ * Spawn a child agent session, run it to completion, and return the
24
+ * child's final agent.message text.
25
+ */
26
+ export async function handleSpawnAgent(
27
+ parentSessionId: string,
28
+ agentId: string,
29
+ prompt: string,
30
+ parentDepth: number,
31
+ ): Promise<string> {
32
+ if (parentDepth >= MAX_THREAD_DEPTH) {
33
+ throw new ApiError(
34
+ 400,
35
+ "invalid_request_error",
36
+ `thread depth limit reached (max ${MAX_THREAD_DEPTH})`,
37
+ );
38
+ }
39
+
40
+ const parentSession = getSession(parentSessionId);
41
+ if (!parentSession) {
42
+ throw new ApiError(404, "not_found_error", `parent session not found: ${parentSessionId}`);
43
+ }
44
+
45
+ const agent = getAgent(agentId);
46
+ if (!agent) {
47
+ throw new ApiError(404, "not_found_error", `agent not found: ${agentId}`);
48
+ }
49
+
50
+ // Create child session with parent reference and incremented depth
51
+ const childSession = createSession({
52
+ agent_id: agent.id,
53
+ agent_version: agent.version,
54
+ environment_id: parentSession.environment_id,
55
+ title: `Thread from ${parentSessionId}`,
56
+ metadata: { parent_session_id: parentSessionId },
57
+ parent_session_id: parentSessionId,
58
+ thread_depth: parentDepth + 1,
59
+ vault_ids: parentSession.vault_ids,
60
+ });
61
+
62
+ // Emit thread_started on parent
63
+ appendEvent(parentSessionId, {
64
+ type: "session.thread_started",
65
+ payload: { child_session_id: childSession.id, agent_id: agentId },
66
+ origin: "server",
67
+ processedAt: nowMs(),
68
+ });
69
+
70
+ // Spawn the child actor
71
+ getActor(childSession.id);
72
+
73
+ // Run the child turn
74
+ const eventId = `thread_${childSession.id}_${nowMs()}`;
75
+ await runTurn(childSession.id, [
76
+ { kind: "text", eventId, text: prompt },
77
+ ]);
78
+
79
+ // Wait for completion: poll until session is idle
80
+ const maxWaitMs = 300_000; // 5 minutes
81
+ const pollIntervalMs = 500;
82
+ const startMs = nowMs();
83
+ let childRow = getSessionRow(childSession.id);
84
+ while (childRow && childRow.status === "running" && nowMs() - startMs < maxWaitMs) {
85
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
86
+ childRow = getSessionRow(childSession.id);
87
+ }
88
+
89
+ // If timed out, interrupt and clean up the child
90
+ if (childRow && childRow.status === "running") {
91
+ const { interruptSession } = await import("./interrupt");
92
+ interruptSession(childSession.id);
93
+ }
94
+
95
+ // Extract the last agent.message text from the child's events
96
+ let resultText = "";
97
+ const events = listEvents(childSession.id, { limit: 100, order: "desc" });
98
+ for (const evt of events) {
99
+ if (evt.type === "agent.message") {
100
+ const payload = JSON.parse(evt.payload_json) as {
101
+ content?: Array<{ type: string; text?: string }>;
102
+ };
103
+ const text = (payload.content ?? [])
104
+ .filter((b) => b.type === "text" && b.text)
105
+ .map((b) => b.text!)
106
+ .join("");
107
+ if (text) {
108
+ resultText = text;
109
+ break;
110
+ }
111
+ }
112
+ }
113
+
114
+ // Emit thread_completed on parent
115
+ appendEvent(parentSessionId, {
116
+ type: "session.thread_completed",
117
+ payload: {
118
+ child_session_id: childSession.id,
119
+ result: resultText || "(no response from sub-agent)",
120
+ },
121
+ origin: "server",
122
+ processedAt: nowMs(),
123
+ });
124
+
125
+ return resultText || "(no response from sub-agent)";
126
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Resolve an agent's `tools` array into claude --allowed-tools /
3
+ * --disallowed-tools args plus the set of custom tool names.
4
+ *
5
+ * Built-in set matches the claude CLI's tool names (case-sensitive).
6
+ */
7
+ import { BUILT_IN_TOOL_NAMES, type BuiltInToolName, type ToolConfig } from "../types";
8
+
9
+ export interface ResolvedTools {
10
+ allowedTools: string[];
11
+ disallowedTools: string[];
12
+ customToolNames: Set<string>;
13
+ }
14
+
15
+ export function resolveToolset(tools: ToolConfig[]): ResolvedTools {
16
+ const customToolNames = new Set<string>();
17
+ let builtInEnabled = new Set<BuiltInToolName>(BUILT_IN_TOOL_NAMES);
18
+ let hadAgentToolset = false;
19
+
20
+ for (const tool of tools) {
21
+ if (tool.type === "agent_toolset_20260401") {
22
+ hadAgentToolset = true;
23
+ const defaultEnabled = tool.default_config?.enabled ?? true;
24
+ if (!defaultEnabled) {
25
+ builtInEnabled = new Set();
26
+ }
27
+ for (const cfg of tool.configs ?? []) {
28
+ const name = cfg.name;
29
+ if (!BUILT_IN_TOOL_NAMES.includes(name as BuiltInToolName)) continue;
30
+ if (cfg.enabled === false) {
31
+ builtInEnabled.delete(name as BuiltInToolName);
32
+ } else if (cfg.enabled === true) {
33
+ builtInEnabled.add(name as BuiltInToolName);
34
+ }
35
+ }
36
+ } else if (tool.type === "custom") {
37
+ customToolNames.add(tool.name);
38
+ }
39
+ }
40
+
41
+ // If no agent_toolset_20260401 was declared, start from empty built-ins.
42
+ if (!hadAgentToolset) {
43
+ builtInEnabled = new Set();
44
+ }
45
+
46
+ const allowedTools = Array.from(builtInEnabled);
47
+ const disallowedTools = BUILT_IN_TOOL_NAMES.filter((n) => !builtInEnabled.has(n));
48
+
49
+ return { allowedTools, disallowedTools, customToolNames };
50
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Graceful shutdown on SIGTERM and SIGINT.
3
+ *
4
+ * Aborts all in-flight turn controllers, gives them up to 5s to emit their
5
+ * `session.status_idle{stop_reason:"interrupted"}` via the driver's normal
6
+ * abort path, then exits. Sessions that don't finish in time will be picked
7
+ * up by the next startup's stale-recovery path.
8
+ *
9
+ * Pattern from
10
+ */
11
+ import { getRuntime } from "./state";
12
+ import { markStopping } from "./sessions/sweeper";
13
+ import { syncDb, closeDb } from "./db/client";
14
+
15
+ type GlobalShutdown = typeof globalThis & {
16
+ __caShutdownInstalled?: boolean;
17
+ __caSweeperHandle?: NodeJS.Timeout;
18
+ };
19
+ const g = globalThis as GlobalShutdown;
20
+
21
+ export function installShutdownHandlers(): void {
22
+ if (g.__caShutdownInstalled) return;
23
+ g.__caShutdownInstalled = true;
24
+
25
+ process.on("SIGTERM", () => {
26
+ void shutdown("SIGTERM");
27
+ });
28
+ process.on("SIGINT", () => {
29
+ void shutdown("SIGINT");
30
+ });
31
+ }
32
+
33
+ let shuttingDown = false;
34
+
35
+ async function shutdown(signal: string): Promise<void> {
36
+ if (shuttingDown) {
37
+ // Second signal — force exit immediately
38
+ console.log(`[shutdown] forced exit`);
39
+ process.exit(1);
40
+ }
41
+ shuttingDown = true;
42
+
43
+ const rt = getRuntime();
44
+ const inflight = rt.inFlightRuns.size;
45
+
46
+ if (inflight > 0) {
47
+ console.log(`[shutdown] stopping ${inflight} in-flight turn${inflight > 1 ? "s" : ""}...`);
48
+ } else {
49
+ console.log(`[shutdown] shutting down...`);
50
+ }
51
+
52
+ // Tell the sweeper to stop starting new eviction work, then clear the
53
+ // interval. Any in-progress sweep finishes its current candidate and bails.
54
+ markStopping();
55
+ if (g.__caSweeperHandle) {
56
+ clearInterval(g.__caSweeperHandle);
57
+ g.__caSweeperHandle = undefined;
58
+ }
59
+
60
+ for (const run of rt.inFlightRuns.values()) {
61
+ try {
62
+ run.controller.abort(new DOMException("shutting down", "AbortError"));
63
+ } catch {
64
+ /* ignore */
65
+ }
66
+ }
67
+
68
+ // Give drivers a moment to append their idle-interrupted events
69
+ if (inflight > 0) {
70
+ await new Promise((r) => setTimeout(r, 5000));
71
+ }
72
+
73
+ // Sync embedded replica to Turso and close the DB cleanly
74
+ syncDb();
75
+ closeDb();
76
+
77
+ process.exit(0);
78
+ }