@desplega.ai/agent-swarm 1.80.1 → 1.80.2

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.
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.80.1",
5
+ "version": "1.80.2",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.80.1",
3
+ "version": "1.80.2",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -0,0 +1 @@
1
+ ALTER TABLE scripts ADD COLUMN argsJsonSchema TEXT;
@@ -20,6 +20,7 @@ type ScriptWriteArgs = ScriptIdentity & {
20
20
  description: string;
21
21
  intent: string;
22
22
  signatureJson: string;
23
+ argsJsonSchema?: string | null;
23
24
  isScratch?: boolean;
24
25
  typeChecked?: boolean;
25
26
  fsMode?: ScriptFsMode;
@@ -115,6 +116,7 @@ export function insertScript(args: ScriptWriteArgs): ScriptRecord {
115
116
  string,
116
117
  string,
117
118
  string,
119
+ string | null,
118
120
  string,
119
121
  number,
120
122
  number,
@@ -126,9 +128,9 @@ export function insertScript(args: ScriptWriteArgs): ScriptRecord {
126
128
  >(
127
129
  `INSERT INTO scripts (
128
130
  id, name, scope, scopeId, source, description, intent, signatureJson,
129
- contentHash, isScratch, typeChecked, fsMode, createdByAgentId, createdAt, updatedAt
131
+ argsJsonSchema, contentHash, isScratch, typeChecked, fsMode, createdByAgentId, createdAt, updatedAt
130
132
  )
131
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
133
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
132
134
  RETURNING *`,
133
135
  )
134
136
  .get(
@@ -140,6 +142,7 @@ export function insertScript(args: ScriptWriteArgs): ScriptRecord {
140
142
  args.description,
141
143
  args.intent,
142
144
  args.signatureJson,
145
+ args.argsJsonSchema ?? null,
143
146
  contentHash,
144
147
  isScratch,
145
148
  typeChecked,
@@ -193,10 +196,13 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
193
196
  const fsMode = args.fsMode ?? existing.fsMode;
194
197
  const isScratch = args.isScratch ?? existing.isScratch;
195
198
  const typeChecked = args.typeChecked ?? existing.typeChecked;
199
+ const argsJsonSchema =
200
+ args.argsJsonSchema !== undefined ? args.argsJsonSchema : existing.argsJsonSchema;
196
201
  const trackedMetadataChanged =
197
202
  args.description !== existing.description ||
198
203
  args.intent !== existing.intent ||
199
- args.signatureJson !== existing.signatureJson;
204
+ args.signatureJson !== existing.signatureJson ||
205
+ argsJsonSchema !== existing.argsJsonSchema;
200
206
  const promotedFromScratch = existing.isScratch && !isScratch;
201
207
  if (
202
208
  fsMode !== existing.fsMode ||
@@ -205,9 +211,12 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
205
211
  trackedMetadataChanged
206
212
  ) {
207
213
  const row = getDb()
208
- .prepare<ScriptRow, [string, string, string, number, number, string, string, string]>(
214
+ .prepare<
215
+ ScriptRow,
216
+ [string, string, string, string | null, number, number, string, string, string]
217
+ >(
209
218
  `UPDATE scripts
210
- SET description = ?, intent = ?, signatureJson = ?,
219
+ SET description = ?, intent = ?, signatureJson = ?, argsJsonSchema = ?,
211
220
  isScratch = ?, typeChecked = ?, fsMode = ?, updatedAt = ?
212
221
  WHERE id = ?
213
222
  RETURNING *`,
@@ -216,6 +225,7 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
216
225
  args.description,
217
226
  args.intent,
218
227
  args.signatureJson,
228
+ argsJsonSchema ?? null,
219
229
  isScratch ? 1 : 0,
220
230
  typeChecked ? 1 : 0,
221
231
  fsMode,
@@ -247,16 +257,31 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
247
257
  const fsMode = args.fsMode ?? existing.fsMode;
248
258
  const isScratch = args.isScratch ?? existing.isScratch;
249
259
  const typeChecked = args.typeChecked ?? existing.typeChecked;
260
+ const argsJsonSchema =
261
+ args.argsJsonSchema !== undefined ? args.argsJsonSchema : existing.argsJsonSchema;
250
262
 
251
263
  const txn = getDb().transaction(() => {
252
264
  const row = getDb()
253
265
  .prepare<
254
266
  ScriptRow,
255
- [string, string, string, string, string, number, number, number, string, string, string]
267
+ [
268
+ string,
269
+ string,
270
+ string,
271
+ string,
272
+ string | null,
273
+ string,
274
+ number,
275
+ number,
276
+ number,
277
+ string,
278
+ string,
279
+ string,
280
+ ]
256
281
  >(
257
282
  `UPDATE scripts
258
- SET source = ?, description = ?, intent = ?, signatureJson = ?, contentHash = ?,
259
- version = ?, isScratch = ?, typeChecked = ?, fsMode = ?, updatedAt = ?
283
+ SET source = ?, description = ?, intent = ?, signatureJson = ?, argsJsonSchema = ?,
284
+ contentHash = ?, version = ?, isScratch = ?, typeChecked = ?, fsMode = ?, updatedAt = ?
260
285
  WHERE id = ?
261
286
  RETURNING *`,
262
287
  )
@@ -265,6 +290,7 @@ export async function upsertScriptByName(args: ScriptWriteArgs): Promise<UpsertS
265
290
  args.description,
266
291
  args.intent,
267
292
  args.signatureJson,
293
+ argsJsonSchema ?? null,
268
294
  contentHash,
269
295
  newVersion,
270
296
  isScratch ? 1 : 0,
@@ -22,6 +22,7 @@ type ScriptEmbeddingCandidateRow = ScriptEmbeddingRow & {
22
22
  description: string;
23
23
  intent: string;
24
24
  signatureJson: string;
25
+ argsJsonSchema: string | null;
25
26
  contentHash: string;
26
27
  version: number;
27
28
  isScratch: number;
@@ -63,6 +64,7 @@ function rowToScript(row: ScriptEmbeddingCandidateRow): ScriptRecord {
63
64
  description: row.description,
64
65
  intent: row.intent,
65
66
  signatureJson: row.signatureJson,
67
+ argsJsonSchema: row.argsJsonSchema ?? null,
66
68
  contentHash: row.contentHash,
67
69
  version: row.version,
68
70
  isScratch: row.isScratch === 1,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Server-side helper: spawns a subprocess to extract the argsJsonSchema
3
+ * from a user script's `argsSchema` Zod export. Returns the JSON Schema
4
+ * serialized as a string, or null if the script has no argsSchema or
5
+ * if extraction fails.
6
+ *
7
+ * Extraction is best-effort and non-blocking — failures return null.
8
+ */
9
+
10
+ const TIMEOUT_MS = 5_000;
11
+
12
+ function extractorPath(): string {
13
+ return new URL("../../scripts-runtime/extract-args-schema.ts", import.meta.url).pathname;
14
+ }
15
+
16
+ export async function extractArgsJsonSchema(source: string): Promise<string | null> {
17
+ const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/schema-extract-${crypto.randomUUID()}`;
18
+
19
+ try {
20
+ await Bun.$`mkdir -p ${tmpdir}`.quiet();
21
+
22
+ const sourceFile = `${tmpdir}/source.ts`;
23
+ const resultFile = `${tmpdir}/result.json`;
24
+ await Bun.write(sourceFile, source);
25
+
26
+ const proc = Bun.spawn(["bun", "run", extractorPath()], {
27
+ env: {
28
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
29
+ HOME: process.env.HOME ?? "/tmp",
30
+ TMPDIR: tmpdir,
31
+ SWARM_SCHEMA_SOURCE_FILE: sourceFile,
32
+ SWARM_SCHEMA_RESULT_FILE: resultFile,
33
+ SWARM_SCHEMA_TMPDIR: tmpdir,
34
+ },
35
+ cwd: tmpdir,
36
+ stdout: "ignore",
37
+ stderr: "ignore",
38
+ });
39
+
40
+ const timeout = setTimeout(() => proc.kill(), TIMEOUT_MS);
41
+ const exitCode = await proc.exited.catch(() => 1);
42
+ clearTimeout(timeout);
43
+
44
+ if (exitCode !== 0) return null;
45
+
46
+ const result = await Bun.file(resultFile).text();
47
+ const parsed: unknown = JSON.parse(result);
48
+ if (parsed === null) return null;
49
+ return JSON.stringify(parsed);
50
+ } catch {
51
+ return null;
52
+ } finally {
53
+ await Bun.$`rm -rf ${tmpdir}`.quiet().nothrow();
54
+ }
55
+ }
@@ -120,6 +120,10 @@ function createCompilerHost(
120
120
  return files.get(normalized) ?? ts.sys.readFile(fileName);
121
121
  };
122
122
 
123
+ // Resolve external packages (e.g. "zod") from the project root rather than
124
+ // the virtual path "/virtual/..." so TypeScript can find real node_modules.
125
+ const projectBase = new URL("../../index.ts", import.meta.url).pathname;
126
+
123
127
  host.resolveModuleNames = (moduleNames, containingFile) =>
124
128
  moduleNames.map((moduleName) => {
125
129
  if (moduleName === "./user-script") {
@@ -131,7 +135,9 @@ function createCompilerHost(
131
135
  if (moduleName === "stdlib") {
132
136
  return { resolvedFileName: STDLIB_FILE, extension: ts.Extension.Dts };
133
137
  }
134
- return ts.resolveModuleName(moduleName, containingFile, options, host).resolvedModule;
138
+ // For external packages, resolve from project root so node_modules is found
139
+ const base = containingFile.startsWith("/virtual/") ? projectBase : containingFile;
140
+ return ts.resolveModuleName(moduleName, base, options, host).resolvedModule;
135
141
  });
136
142
 
137
143
  // In compiled binary mode, TypeScript's lib .d.ts files live alongside
@@ -368,7 +368,17 @@ const SWARM_TOOL_LABELS: Record<string, string | null> = {
368
368
  "inject-learning": "🧠 Storing learning",
369
369
  "memory-search": "🧠 Searching memory",
370
370
  "memory-get": "🧠 Retrieving memory",
371
+ "memory-delete": "🧠 Deleting memory",
371
372
  "update-profile": "🪪 Updating profile",
373
+ // Users
374
+ "manage-user": "👤 Managing user",
375
+ "resolve-user": "👤 Resolving user",
376
+ // Key-value store
377
+ "kv-get": "🔑 Reading KV value",
378
+ "kv-set": "🔑 Setting KV value",
379
+ "kv-list": "🔑 Listing KV keys",
380
+ "kv-delete": "🔑 Deleting KV value",
381
+ "kv-incr": "🔑 Incrementing KV value",
372
382
  // Slack
373
383
  "slack-post": "💬 Posting to Slack",
374
384
  "slack-start-thread": "💬 Starting Slack thread",
@@ -388,20 +398,37 @@ const SWARM_TOOL_LABELS: Record<string, string | null> = {
388
398
  "get-workflow": "⚙️ Checking workflow",
389
399
  "list-workflows": "⚙️ Listing workflows",
390
400
  "create-workflow": "⚙️ Creating workflow",
401
+ "update-workflow": "⚙️ Updating workflow",
402
+ "delete-workflow": "⚙️ Deleting workflow",
403
+ "patch-workflow": "⚙️ Patching workflow",
404
+ "patch-workflow-node": "⚙️ Patching workflow node",
405
+ "get-workflow-run": "⚙️ Checking workflow run",
406
+ "list-workflow-runs": "⚙️ Listing workflow runs",
407
+ "cancel-workflow-run": "⚙️ Cancelling workflow run",
408
+ "retry-workflow-run": "⚙️ Retrying workflow run",
391
409
  // Skills
392
410
  "skill-search": "🔎 Searching skills",
393
411
  "skill-install": "📦 Installing skill",
394
412
  "skill-install-remote": "📦 Installing remote skill",
395
413
  "skill-get": "📦 Getting skill details",
396
414
  "skill-list": "📦 Listing skills",
415
+ "skill-create": "📦 Creating skill",
416
+ "skill-update": "📦 Updating skill",
417
+ "skill-delete": "📦 Deleting skill",
418
+ "skill-publish": "📦 Publishing skill",
419
+ "skill-uninstall": "📦 Uninstalling skill",
420
+ "skill-sync-remote": "📦 Syncing remote skills",
397
421
  // Config
398
422
  "get-config": "⚙️ Reading config",
399
423
  "set-config": "⚙️ Setting config",
400
424
  "list-config": "⚙️ Listing config",
425
+ "delete-config": "⚙️ Deleting config",
401
426
  // Schedules
402
427
  "create-schedule": "📅 Creating schedule",
403
428
  "list-schedules": "📅 Listing schedules",
404
429
  "run-schedule-now": "📅 Running schedule",
430
+ "update-schedule": "📅 Updating schedule",
431
+ "delete-schedule": "📅 Deleting schedule",
405
432
  // Context
406
433
  "context-diff": "📜 Viewing context diff",
407
434
  "context-history": "📜 Viewing context history",
@@ -422,10 +449,34 @@ const SWARM_TOOL_LABELS: Record<string, string | null> = {
422
449
  "script-query-types": "📜 Reading script types",
423
450
  };
424
451
 
425
- /** Convert kebab-case to sentence case: "get-task-details" "Get task details" */
452
+ /** Words that keep specific casing when humanizing tool names. */
453
+ const TOOL_NAME_ACRONYMS: Record<string, string> = {
454
+ mcp: "MCP",
455
+ kv: "KV",
456
+ api: "API",
457
+ url: "URL",
458
+ id: "ID",
459
+ };
460
+
461
+ /**
462
+ * Convert kebab/snake-case to sentence case, preserving known acronyms.
463
+ * "get-task-details" → "Get task details"; "mcp-server-create" → "MCP server create".
464
+ */
426
465
  export function humanizeToolName(name: string): string {
427
466
  if (!name) return name;
428
- return name.charAt(0).toUpperCase() + name.slice(1).replaceAll("-", " ");
467
+ const words = name
468
+ .replaceAll("_", " ")
469
+ .replaceAll("-", " ")
470
+ .trim()
471
+ .split(/\s+/)
472
+ .filter(Boolean)
473
+ .map((w) => TOOL_NAME_ACRONYMS[w.toLowerCase()] ?? w);
474
+ const first = words[0];
475
+ if (!first) return name;
476
+ const head = TOOL_NAME_ACRONYMS[first.toLowerCase()]
477
+ ? first
478
+ : first.charAt(0).toUpperCase() + first.slice(1);
479
+ return [head, ...words.slice(1)].join(" ");
429
480
  }
430
481
 
431
482
  /**
@@ -501,7 +552,7 @@ export function toolCallToProgress(toolName: string, args: unknown): string | nu
501
552
  if (label === null) return null;
502
553
  if (label) return label;
503
554
 
504
- return `🔧 ${toolName}`;
555
+ return `🔧 ${humanizeToolName(toolName)}`;
505
556
  }
506
557
  }
507
558
  }
@@ -2681,15 +2732,35 @@ async function checkCompletedProcesses(
2681
2732
  credentialInfo &&
2682
2733
  /rate.?limit|hit your limit|usage[ _-]?limit|too many requests/i.test(failureReason)
2683
2734
  ) {
2684
- // Try to extract reset time from the error message (e.g. "resets 3pm (UTC)")
2685
- const parsedResetTime = parseRateLimitResetTime(failureReason);
2686
- const defaultCooldownMs = 5 * 60 * 1000;
2687
- const rateLimitedUntil =
2688
- parsedResetTime ?? new Date(Date.now() + defaultCooldownMs).toISOString();
2689
- if (parsedResetTime) {
2735
+ // Three-tier reset-time resolver (most to least precise):
2736
+ // Tier 1: structured rate_limit_event from Claude CLI (resetsAt epoch sec)
2737
+ // Tier 2: regex on the error message (e.g. "resets 3pm (UTC)")
2738
+ // Tier 3: 5-min hard fallback — only when both structured and regex fail
2739
+ // Tiers 1 & 2 are clamped to [now+60s, now+6h] at their source.
2740
+ const clampResetTime = (isoString: string): string => {
2741
+ const nowMs = Date.now();
2742
+ const minMs = nowMs + 60_000;
2743
+ const maxMs = nowMs + 6 * 60 * 60 * 1000;
2744
+ const candidateMs = new Date(isoString).getTime();
2745
+ return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
2746
+ };
2747
+
2748
+ let rateLimitedUntil: string;
2749
+ if (result.rateLimitResetAt) {
2750
+ rateLimitedUntil = clampResetTime(result.rateLimitResetAt);
2690
2751
  console.log(
2691
- `[credentials] Parsed rate limit reset time from error: ${parsedResetTime}`,
2752
+ `[credentials] Rate limit reset from rate_limit_event: ${rateLimitedUntil}`,
2692
2753
  );
2754
+ } else {
2755
+ const parsedResetTime = parseRateLimitResetTime(failureReason);
2756
+ if (parsedResetTime) {
2757
+ rateLimitedUntil = clampResetTime(parsedResetTime);
2758
+ console.log(
2759
+ `[credentials] Parsed rate limit reset time from error: ${rateLimitedUntil}`,
2760
+ );
2761
+ } else {
2762
+ rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
2763
+ }
2693
2764
  }
2694
2765
  reportKeyRateLimit(
2695
2766
  apiConfig.apiUrl,
@@ -4,6 +4,7 @@ import { getAgentById } from "../be/db";
4
4
  import { createEvent } from "../be/events";
5
5
  import { deleteScript, getScript, upsertScriptByName } from "../be/scripts/db";
6
6
  import { searchScripts } from "../be/scripts/embeddings";
7
+ import { extractArgsJsonSchema } from "../be/scripts/extract-schema";
7
8
  import { SCRIPT_SDK_TYPES, SCRIPT_STDLIB_TYPES, typecheckScript } from "../be/scripts/typecheck";
8
9
  import { extractScriptSignature } from "../scripts-runtime/extract-signature";
9
10
  import { runScript } from "../scripts-runtime/loader";
@@ -217,6 +218,7 @@ export async function handleScripts(
217
218
  parsed.body.scope === "global"
218
219
  ? getScript({ name: parsed.body.name, scope: "agent", scopeId: agent.id })
219
220
  : null;
221
+ const argsJsonSchema = await extractArgsJsonSchema(parsed.body.source);
220
222
  const result = await upsertScriptByName({
221
223
  name: parsed.body.name,
222
224
  scope: parsed.body.scope,
@@ -225,6 +227,7 @@ export async function handleScripts(
225
227
  description: parsed.body.description,
226
228
  intent: parsed.body.intent,
227
229
  signatureJson: signatureJsonFor(parsed.body.source),
230
+ argsJsonSchema,
228
231
  fsMode: parsed.body.fsMode,
229
232
  agentId: agent.id,
230
233
  isScratch: false,
@@ -331,6 +334,9 @@ export async function handleScripts(
331
334
  results: matches.map(({ script, score }) => ({
332
335
  name: script.name,
333
336
  signature: JSON.parse(script.signatureJson),
337
+ argsJsonSchema: script.argsJsonSchema
338
+ ? (JSON.parse(script.argsJsonSchema) as unknown)
339
+ : null,
334
340
  description: script.description,
335
341
  score,
336
342
  })),
@@ -351,6 +357,7 @@ export async function handleScripts(
351
357
  }
352
358
  json(res, {
353
359
  signature: JSON.parse(script.signatureJson),
360
+ argsJsonSchema: script.argsJsonSchema ? (JSON.parse(script.argsJsonSchema) as unknown) : null,
354
361
  sdkTypes: SCRIPT_SDK_TYPES,
355
362
  stdlibTypes: SCRIPT_STDLIB_TYPES,
356
363
  });
@@ -458,6 +458,7 @@ class ClaudeSession implements ProviderSession {
458
458
  cost: lastCost,
459
459
  isError: (exitCode ?? 1) !== 0,
460
460
  failureReason,
461
+ rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
461
462
  };
462
463
  }
463
464
 
@@ -116,6 +116,14 @@ export interface ProviderResult {
116
116
  errorCategory?: string;
117
117
  /** Human-readable failure reason built from error tracking. */
118
118
  failureReason?: string;
119
+ /**
120
+ * ISO timestamp of the rate limit reset time, parsed from a structured
121
+ * `rate_limit_event` line in the Claude CLI stream. Only set by the Claude
122
+ * adapter when a `status: "rejected"` event is present. Already clamped to
123
+ * [now+60s, now+6h] at the source. The runner uses this as tier-1 of the
124
+ * three-tier cooldown resolver.
125
+ */
126
+ rateLimitResetAt?: string;
119
127
  }
120
128
 
121
129
  /** Behavioral traits that govern prompt assembly and feature gating. */
@@ -31,7 +31,31 @@ try {
31
31
  throw new Error("Swarm script must export a default function");
32
32
  }
33
33
 
34
- const result = await mod.default(parsedArgs, ctx);
34
+ let validatedArgs = parsedArgs;
35
+ if (mod.argsSchema && typeof mod.argsSchema === "object" && "parse" in mod.argsSchema) {
36
+ try {
37
+ // biome-ignore lint/suspicious/noExplicitAny: argsSchema is a Zod schema at runtime
38
+ validatedArgs = (mod.argsSchema as any).parse(parsedArgs);
39
+ } catch (err) {
40
+ // Format ZodError issues into a readable message
41
+ if (
42
+ err &&
43
+ typeof err === "object" &&
44
+ "issues" in err &&
45
+ Array.isArray((err as { issues: unknown[] }).issues)
46
+ ) {
47
+ const issues = (
48
+ err as { issues: Array<{ path: (string | number)[]; message: string }> }
49
+ ).issues
50
+ .map((i) => ` ${i.path.length ? i.path.join(".") : "(root)"}: ${i.message}`)
51
+ .join("\n");
52
+ throw new Error(`argsSchema validation failed:\n${issues}`);
53
+ }
54
+ throw err;
55
+ }
56
+ }
57
+
58
+ const result = await mod.default(validatedArgs, ctx);
35
59
  await Bun.write(requiredEnv("SWARM_SCRIPT_RESULT_FILE"), JSON.stringify(result ?? null));
36
60
  } catch (error) {
37
61
  console.error(error instanceof Error ? error.stack || error.message : String(error));
@@ -90,6 +90,9 @@ async function writeBareImportShims(tmpdir: string): Promise<void> {
90
90
  }
91
91
  await writeBareImportShim(tmpdir, "stdlib", new URL("../stdlib/index.ts", import.meta.url));
92
92
  await writeBareImportShim(tmpdir, "swarm-sdk", new URL("../swarm-sdk.ts", import.meta.url));
93
+ // Allow `import { z } from "zod"` in user scripts (for argsSchema definitions).
94
+ const zodEntry = Bun.resolveSync("zod", import.meta.dir);
95
+ await writeBareImportShim(tmpdir, "zod", new URL(`file://${zodEntry}`));
93
96
  }
94
97
 
95
98
  function harnessCommand(harnessPath: string, input: ExecutorInput): string[] {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Subprocess script: extracts the argsJsonSchema from a user script module.
3
+ * Spawned by src/be/scripts/extract-schema.ts during script_upsert.
4
+ *
5
+ * Env vars (all required):
6
+ * SWARM_SCHEMA_SOURCE_FILE path to the script source file
7
+ * SWARM_SCHEMA_RESULT_FILE path where JSON Schema (or "null") is written
8
+ * SWARM_SCHEMA_TMPDIR tmpdir used for shims + the user module
9
+ */
10
+ import { toJSONSchema } from "zod";
11
+
12
+ async function createShims(tmpdir: string): Promise<void> {
13
+ const zodEntry = Bun.resolveSync("zod", import.meta.dir);
14
+ const shims: [string, URL][] = [
15
+ ["stdlib", new URL("./stdlib/index.ts", import.meta.url)],
16
+ ["swarm-sdk", new URL("./swarm-sdk.ts", import.meta.url)],
17
+ ["zod", new URL(`file://${zodEntry}`)],
18
+ ];
19
+ for (const [name, url] of shims) {
20
+ const dir = `${tmpdir}/node_modules/${name}`;
21
+ await Bun.$`mkdir -p ${dir}`.quiet();
22
+ await Bun.write(`${dir}/package.json`, JSON.stringify({ type: "module", main: "index.ts" }));
23
+ await Bun.write(`${dir}/index.ts`, `export * from ${JSON.stringify(url.href)};\n`);
24
+ }
25
+ }
26
+
27
+ const sourceFile = process.env.SWARM_SCHEMA_SOURCE_FILE;
28
+ const resultFile = process.env.SWARM_SCHEMA_RESULT_FILE;
29
+ const tmpdir = process.env.SWARM_SCHEMA_TMPDIR;
30
+
31
+ if (!sourceFile || !resultFile || !tmpdir) {
32
+ process.stderr.write("extract-args-schema: missing required env vars\n");
33
+ process.exit(1);
34
+ }
35
+
36
+ try {
37
+ await createShims(tmpdir);
38
+
39
+ const source = await Bun.file(sourceFile).text();
40
+ const userModulePath = `${tmpdir}/user-script.ts`;
41
+ await Bun.write(userModulePath, source);
42
+
43
+ let mod: Record<string, unknown>;
44
+ try {
45
+ mod = (await import(userModulePath)) as Record<string, unknown>;
46
+ } catch {
47
+ // Import failed (e.g. unresolvable imports) — not an error, just no schema
48
+ await Bun.write(resultFile, "null");
49
+ process.exit(0);
50
+ }
51
+
52
+ if (!mod.argsSchema || typeof mod.argsSchema !== "object") {
53
+ await Bun.write(resultFile, "null");
54
+ process.exit(0);
55
+ }
56
+
57
+ // biome-ignore lint/suspicious/noExplicitAny: argsSchema is a Zod schema at runtime
58
+ const schema = toJSONSchema(mod.argsSchema as any);
59
+ await Bun.write(resultFile, JSON.stringify(schema));
60
+ process.exit(0);
61
+ } catch (err) {
62
+ process.stderr.write(
63
+ `extract-args-schema: ${err instanceof Error ? err.message : String(err)}\n`,
64
+ );
65
+ try {
66
+ await Bun.write(resultFile, "null");
67
+ } catch {}
68
+ process.exit(0);
69
+ }
@@ -1,6 +1,6 @@
1
1
  import ts from "typescript";
2
2
 
3
- const ALLOWED_BARE_IMPORTS = new Set(["swarm-sdk", "stdlib"]);
3
+ const ALLOWED_BARE_IMPORTS = new Set(["swarm-sdk", "stdlib", "zod"]);
4
4
  const FORBIDDEN_HINTS = new Set(["node:", "bun:", "fs", "child_process", "crypto", "bun:sqlite"]);
5
5
 
6
6
  export type ImportAllowlistResult =
@@ -6,6 +6,38 @@ import {
6
6
  trackErrorFromJson,
7
7
  } from "../utils/error-tracker";
8
8
 
9
+ describe("SessionErrorTracker — getRateLimitResetAt", () => {
10
+ test("returns undefined when no rate_limit_event was processed", () => {
11
+ const tracker = new SessionErrorTracker();
12
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
13
+ });
14
+
15
+ test("returns ISO string after a rejected rate_limit_event", () => {
16
+ const tracker = new SessionErrorTracker();
17
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
18
+ tracker.processRateLimitEvent({
19
+ type: "rate_limit_event",
20
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
21
+ });
22
+ const result = tracker.getRateLimitResetAt();
23
+ expect(result).toBeDefined();
24
+ expect(() => new Date(result!).toISOString()).not.toThrow();
25
+ });
26
+
27
+ test("returns undefined after only allowed/allowed_warning events", () => {
28
+ const tracker = new SessionErrorTracker();
29
+ tracker.processRateLimitEvent({
30
+ type: "rate_limit_event",
31
+ rate_limit_info: { status: "allowed", resetsAt: 1779202200 },
32
+ });
33
+ tracker.processRateLimitEvent({
34
+ type: "rate_limit_event",
35
+ rate_limit_info: { status: "allowed_warning", resetsAt: 1779202200 },
36
+ });
37
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
38
+ });
39
+ });
40
+
9
41
  describe("SessionErrorTracker", () => {
10
42
  test("hasErrors returns false when no errors tracked", () => {
11
43
  const tracker = new SessionErrorTracker();
@@ -263,6 +295,18 @@ describe("trackErrorFromJson", () => {
263
295
  trackErrorFromJson({ type: "content_block_delta", delta: {} }, tracker);
264
296
  expect(tracker.hasErrors()).toBe(false);
265
297
  });
298
+
299
+ test("rate_limit_event is not treated as an error signal", () => {
300
+ const tracker = new SessionErrorTracker();
301
+ trackErrorFromJson(
302
+ {
303
+ type: "rate_limit_event",
304
+ rate_limit_info: { status: "rejected", resetsAt: 1779202200 },
305
+ },
306
+ tracker,
307
+ );
308
+ expect(tracker.hasErrors()).toBe(false);
309
+ });
266
310
  });
267
311
 
268
312
  describe("parseStderrForErrors", () => {
@@ -0,0 +1,292 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SessionErrorTracker, trackErrorFromJson } from "../utils/error-tracker";
3
+
4
+ // Verbatim fixture from Linear CAI-1279 (session logs for task b7fbbdb9-4922-41d9-88ec-21febd6c4fec)
5
+ const FIXTURE_REJECTED = {
6
+ type: "rate_limit_event",
7
+ rate_limit_info: {
8
+ status: "rejected",
9
+ resetsAt: 1779202200, // seconds since epoch — 2026-05-19T14:50:00Z
10
+ rateLimitType: "five_hour",
11
+ overageStatus: "rejected",
12
+ overageDisabledReason: "group_zero_credit_limit",
13
+ isUsingOverage: false,
14
+ },
15
+ uuid: "ff6e5299-429c-4fcb-ab34-0ce4e8fa6202",
16
+ session_id: "69dbe5a1-1130-45eb-983f-58a7a13c9c3c",
17
+ };
18
+
19
+ describe("SessionErrorTracker — rate_limit_event processing", () => {
20
+ test("stashes resetsAt (seconds) correctly as ms — verbatim CAI-1279 fixture", () => {
21
+ const tracker = new SessionErrorTracker();
22
+ tracker.processRateLimitEvent(FIXTURE_REJECTED);
23
+
24
+ const result = tracker.getRateLimitResetAt();
25
+ expect(result).toBeDefined();
26
+
27
+ // resetsAt: 1779202200 sec → 2026-05-19T14:50:00.000Z
28
+ // But since we clamp to [now+60s, now+6h] and this is a past timestamp,
29
+ // the value will be clamped to now+60s. What matters is the sec→ms conversion works.
30
+ // We verify the unit is correct by checking that 1779202200 * 1000 = ms,
31
+ // which is NOT the same as treating it as ms (would be 1970-01-21).
32
+ const parsedMs = new Date(result!).getTime();
33
+ const nowMs = Date.now();
34
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 59_000); // clamped to at least now+60s
35
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 7 * 60 * 60 * 1000); // not absurdly far
36
+ });
37
+
38
+ test("resetsAt treated as seconds, not milliseconds (unit conversion boundary)", () => {
39
+ const tracker = new SessionErrorTracker();
40
+ // A future resetsAt value (in seconds) — 1 hour from now
41
+ const oneHourFromNowSec = Math.floor(Date.now() / 1000) + 3600;
42
+ tracker.processRateLimitEvent({
43
+ type: "rate_limit_event",
44
+ rate_limit_info: {
45
+ status: "rejected",
46
+ resetsAt: oneHourFromNowSec,
47
+ },
48
+ });
49
+
50
+ const result = tracker.getRateLimitResetAt();
51
+ expect(result).toBeDefined();
52
+
53
+ const parsedMs = new Date(result!).getTime();
54
+ const nowMs = Date.now();
55
+ // Should be ~1h from now (not 1970 if treated as ms, not year 57,000 if multiplied wrong)
56
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 50 * 60_000); // at least 50 min from now
57
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 70 * 60_000); // at most 70 min from now
58
+ });
59
+
60
+ test("status: rejected → stashes resetsAt", () => {
61
+ const tracker = new SessionErrorTracker();
62
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
63
+ tracker.processRateLimitEvent({
64
+ type: "rate_limit_event",
65
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
66
+ });
67
+ expect(tracker.getRateLimitResetAt()).toBeDefined();
68
+ });
69
+
70
+ test("status: allowed → does NOT stash (no cooldown needed)", () => {
71
+ const tracker = new SessionErrorTracker();
72
+ tracker.processRateLimitEvent({
73
+ type: "rate_limit_event",
74
+ rate_limit_info: { status: "allowed", resetsAt: 1779202200 },
75
+ });
76
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
77
+ });
78
+
79
+ test("status: allowed_warning → does NOT stash", () => {
80
+ const tracker = new SessionErrorTracker();
81
+ tracker.processRateLimitEvent({
82
+ type: "rate_limit_event",
83
+ rate_limit_info: { status: "allowed_warning", resetsAt: 1779202200 },
84
+ });
85
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
86
+ });
87
+
88
+ test("malformed event (missing rate_limit_info) → does NOT stash, no throw", () => {
89
+ const tracker = new SessionErrorTracker();
90
+ tracker.processRateLimitEvent({ type: "rate_limit_event" });
91
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
92
+ });
93
+
94
+ test("malformed event (resetsAt is string) → does NOT stash, no throw", () => {
95
+ const tracker = new SessionErrorTracker();
96
+ tracker.processRateLimitEvent({
97
+ type: "rate_limit_event",
98
+ rate_limit_info: { status: "rejected", resetsAt: "not-a-number" },
99
+ });
100
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
101
+ });
102
+
103
+ test("malformed event (resetsAt is negative) → does NOT stash", () => {
104
+ const tracker = new SessionErrorTracker();
105
+ tracker.processRateLimitEvent({
106
+ type: "rate_limit_event",
107
+ rate_limit_info: { status: "rejected", resetsAt: -1 },
108
+ });
109
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
110
+ });
111
+
112
+ test("resetsAt already in the past → clamped to now+60s (clock skew defense)", () => {
113
+ const tracker = new SessionErrorTracker();
114
+ // Use a known-past timestamp (year 2020)
115
+ tracker.processRateLimitEvent({
116
+ type: "rate_limit_event",
117
+ rate_limit_info: { status: "rejected", resetsAt: 1577836800 }, // 2020-01-01T00:00:00Z
118
+ });
119
+
120
+ const result = tracker.getRateLimitResetAt();
121
+ expect(result).toBeDefined();
122
+ const parsedMs = new Date(result!).getTime();
123
+ const nowMs = Date.now();
124
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 59_000);
125
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 65_000);
126
+ });
127
+
128
+ test("resetsAt absurdly far in future → clamped to now+6h (malformed defense)", () => {
129
+ const tracker = new SessionErrorTracker();
130
+ // Year 2099 in seconds
131
+ tracker.processRateLimitEvent({
132
+ type: "rate_limit_event",
133
+ rate_limit_info: { status: "rejected", resetsAt: 4102444800 }, // 2100-01-01 in seconds
134
+ });
135
+
136
+ const result = tracker.getRateLimitResetAt();
137
+ expect(result).toBeDefined();
138
+ const parsedMs = new Date(result!).getTime();
139
+ const nowMs = Date.now();
140
+ const sixHoursMs = 6 * 60 * 60 * 1000;
141
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + sixHoursMs + 1000); // within 6h (+1s tolerance)
142
+ });
143
+
144
+ test("multiple rate_limit_event lines → last rejected one wins", () => {
145
+ const tracker = new SessionErrorTracker();
146
+ const firstResetsAtSec = Math.floor(Date.now() / 1000) + 1800; // 30 min from now
147
+ const secondResetsAtSec = Math.floor(Date.now() / 1000) + 3600; // 60 min from now
148
+
149
+ tracker.processRateLimitEvent({
150
+ type: "rate_limit_event",
151
+ rate_limit_info: { status: "rejected", resetsAt: firstResetsAtSec },
152
+ });
153
+ tracker.processRateLimitEvent({
154
+ type: "rate_limit_event",
155
+ rate_limit_info: { status: "rejected", resetsAt: secondResetsAtSec },
156
+ });
157
+
158
+ const result = tracker.getRateLimitResetAt();
159
+ expect(result).toBeDefined();
160
+ const parsedMs = new Date(result!).getTime();
161
+ const nowMs = Date.now();
162
+ // Should reflect the SECOND event (~60 min), not the first (~30 min)
163
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 55 * 60_000);
164
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 65 * 60_000);
165
+ });
166
+
167
+ test("allowed event between two rejected events → last rejected wins", () => {
168
+ const tracker = new SessionErrorTracker();
169
+ const firstSec = Math.floor(Date.now() / 1000) + 1800;
170
+ const secondSec = Math.floor(Date.now() / 1000) + 3600;
171
+
172
+ tracker.processRateLimitEvent({
173
+ type: "rate_limit_event",
174
+ rate_limit_info: { status: "rejected", resetsAt: firstSec },
175
+ });
176
+ tracker.processRateLimitEvent({
177
+ type: "rate_limit_event",
178
+ rate_limit_info: { status: "allowed", resetsAt: 9999999999 }, // should be ignored
179
+ });
180
+ tracker.processRateLimitEvent({
181
+ type: "rate_limit_event",
182
+ rate_limit_info: { status: "rejected", resetsAt: secondSec },
183
+ });
184
+
185
+ const result = tracker.getRateLimitResetAt();
186
+ expect(result).toBeDefined();
187
+ const parsedMs = new Date(result!).getTime();
188
+ const nowMs = Date.now();
189
+ // Should reflect the third (second rejected) event (~60 min)
190
+ expect(parsedMs).toBeGreaterThanOrEqual(nowMs + 55 * 60_000);
191
+ expect(parsedMs).toBeLessThanOrEqual(nowMs + 65 * 60_000);
192
+ });
193
+
194
+ test("no rate_limit_event at all → getRateLimitResetAt returns undefined", () => {
195
+ const tracker = new SessionErrorTracker();
196
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
197
+ });
198
+ });
199
+
200
+ describe("trackErrorFromJson — rate_limit_event routing", () => {
201
+ test("routes rate_limit_event to processRateLimitEvent, stashes reset time", () => {
202
+ const tracker = new SessionErrorTracker();
203
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
204
+
205
+ trackErrorFromJson(
206
+ {
207
+ type: "rate_limit_event",
208
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
209
+ },
210
+ tracker,
211
+ );
212
+
213
+ expect(tracker.getRateLimitResetAt()).toBeDefined();
214
+ // rate_limit_event itself is NOT an error signal — it's informational
215
+ expect(tracker.hasErrors()).toBe(false);
216
+ });
217
+
218
+ test("rate_limit_event with allowed status → no reset stashed, no errors", () => {
219
+ const tracker = new SessionErrorTracker();
220
+ trackErrorFromJson(
221
+ {
222
+ type: "rate_limit_event",
223
+ rate_limit_info: { status: "allowed", resetsAt: 1779202200 },
224
+ },
225
+ tracker,
226
+ );
227
+
228
+ expect(tracker.getRateLimitResetAt()).toBeUndefined();
229
+ expect(tracker.hasErrors()).toBe(false);
230
+ });
231
+
232
+ test("rate_limit_event does not block subsequent event processing", () => {
233
+ const tracker = new SessionErrorTracker();
234
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
235
+
236
+ trackErrorFromJson(
237
+ {
238
+ type: "rate_limit_event",
239
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
240
+ },
241
+ tracker,
242
+ );
243
+ trackErrorFromJson(
244
+ { type: "result", is_error: true, result: "Your group's usage limit is set to $0" },
245
+ tracker,
246
+ );
247
+
248
+ expect(tracker.getRateLimitResetAt()).toBeDefined();
249
+ expect(tracker.hasErrors()).toBe(true);
250
+ });
251
+ });
252
+
253
+ describe("three-tier resolver logic (unit test via clamp helper)", () => {
254
+ // Mirrors the clampResetTime inline helper in runner.ts
255
+ function clampResetTime(isoString: string): string {
256
+ const nowMs = Date.now();
257
+ const minMs = nowMs + 60_000;
258
+ const maxMs = nowMs + 6 * 60 * 60 * 1000;
259
+ const candidateMs = new Date(isoString).getTime();
260
+ return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
261
+ }
262
+
263
+ test("tier 1: rateLimitResetAt from structured event → used directly (after clamp)", () => {
264
+ const futureResetsAtSec = Math.floor(Date.now() / 1000) + 3600;
265
+ const tracker = new SessionErrorTracker();
266
+ tracker.processRateLimitEvent({
267
+ type: "rate_limit_event",
268
+ rate_limit_info: { status: "rejected", resetsAt: futureResetsAtSec },
269
+ });
270
+
271
+ const rateLimitResetAt = tracker.getRateLimitResetAt();
272
+ expect(rateLimitResetAt).toBeDefined();
273
+
274
+ // Simulate tier-1 branch: result.rateLimitResetAt is set
275
+ const rateLimitedUntil = clampResetTime(rateLimitResetAt!);
276
+ expect(rateLimitedUntil).toBeDefined();
277
+ const resolvedMs = new Date(rateLimitedUntil).getTime();
278
+ const nowMs = Date.now();
279
+ expect(resolvedMs).toBeGreaterThanOrEqual(nowMs + 59_000);
280
+ });
281
+
282
+ test("tier 3 fallback: no structured event, no parseable message → 5-min default", () => {
283
+ // Simulate: rateLimitResetAt is undefined, parseRateLimitResetTime returns undefined
284
+ const defaultCooldownMs = 5 * 60 * 1000;
285
+ const rateLimitedUntil = new Date(Date.now() + defaultCooldownMs).toISOString();
286
+
287
+ const resolvedMs = new Date(rateLimitedUntil).getTime();
288
+ const nowMs = Date.now();
289
+ expect(resolvedMs).toBeGreaterThanOrEqual(nowMs + 4 * 60_000);
290
+ expect(resolvedMs).toBeLessThanOrEqual(nowMs + 6 * 60_000);
291
+ });
292
+ });
@@ -347,4 +347,57 @@ describe("/api/scripts HTTP", () => {
347
347
  expect(await del.json()).toEqual({ deleted: true });
348
348
  expect(getScript({ name: "lookup-helper", scope: "agent", scopeId: workerId })).toBeNull();
349
349
  });
350
+
351
+ test("script_query_types returns argsJsonSchema for a script with argsSchema export", async () => {
352
+ const source = `
353
+ import { z } from "zod";
354
+ export const argsSchema = z.object({
355
+ repo: z.string(),
356
+ limit: z.number().default(10),
357
+ });
358
+ export default async (args: z.infer<typeof argsSchema>) => ({ repo: args.repo });
359
+ `;
360
+ await upsert({ name: "schema-script", source });
361
+
362
+ const types = await dispatch("/api/scripts/schema-script/types", { agentId: workerId });
363
+ expect(types.status).toBe(200);
364
+ const body = (await types.json()) as { argsJsonSchema: unknown };
365
+ expect(body.argsJsonSchema).not.toBeNull();
366
+ expect(typeof body.argsJsonSchema).toBe("object");
367
+ // JSON Schema should describe the repo and limit properties
368
+ const schema = body.argsJsonSchema as { properties?: Record<string, unknown> };
369
+ expect(schema.properties).toHaveProperty("repo");
370
+ expect(schema.properties).toHaveProperty("limit");
371
+ });
372
+
373
+ test("script_query_types returns argsJsonSchema: null for a script without argsSchema", async () => {
374
+ await upsert({ name: "no-schema-script", source: validSource(3) });
375
+
376
+ const types = await dispatch("/api/scripts/no-schema-script/types", { agentId: workerId });
377
+ expect(types.status).toBe(200);
378
+ const body = (await types.json()) as { argsJsonSchema: unknown };
379
+ expect(body.argsJsonSchema).toBeNull();
380
+ });
381
+
382
+ test("script_search includes argsJsonSchema in results", async () => {
383
+ const source = `
384
+ import { z } from "zod";
385
+ export const argsSchema = z.object({ query: z.string() });
386
+ export default async (args: z.infer<typeof argsSchema>) => ({ result: args.query });
387
+ `;
388
+ await upsert({ name: "search-with-schema", source, description: "search result helper" });
389
+
390
+ const search = await dispatch("/api/scripts/search", {
391
+ method: "POST",
392
+ agentId: workerId,
393
+ body: JSON.stringify({ query: "search result helper", limit: 5 }),
394
+ });
395
+ expect(search.status).toBe(200);
396
+ const body = (await search.json()) as {
397
+ results: Array<{ name: string; argsJsonSchema: unknown }>;
398
+ };
399
+ const result = body.results.find((r) => r.name === "search-with-schema");
400
+ expect(result).toBeDefined();
401
+ expect(result?.argsJsonSchema).not.toBeNull();
402
+ });
350
403
  });
@@ -286,4 +286,59 @@ describe("runScript", () => {
286
286
  await Bun.$`rm -rf ${tmpdir}`;
287
287
  }
288
288
  });
289
+
290
+ test("argsSchema rejects invalid args with a formatted Zod error", async () => {
291
+ const output = await runScript({
292
+ agentId: "agent-1",
293
+ args: {},
294
+ resources,
295
+ source: `
296
+ import { z } from "zod";
297
+ export const argsSchema = z.object({
298
+ repo: z.string(),
299
+ });
300
+ export default async (args: z.infer<typeof argsSchema>) => ({ repo: args.repo });
301
+ `,
302
+ });
303
+
304
+ expect(output.error).toBeDefined();
305
+ expect(output.exitCode).not.toBe(0);
306
+ expect(output.stderr).toContain("argsSchema validation failed");
307
+ expect(output.stderr).toContain("repo");
308
+ });
309
+
310
+ test("argsSchema applies .default() values when fields are omitted", async () => {
311
+ const output = await runScript({
312
+ agentId: "agent-1",
313
+ args: { repo: "owner/name" },
314
+ resources,
315
+ source: `
316
+ import { z } from "zod";
317
+ export const argsSchema = z.object({
318
+ repo: z.string(),
319
+ limit: z.number().default(10),
320
+ });
321
+ export default async (args: z.infer<typeof argsSchema>) => ({ repo: args.repo, limit: args.limit });
322
+ `,
323
+ });
324
+
325
+ expect(output.error).toBeUndefined();
326
+ expect(output.result).toEqual({ repo: "owner/name", limit: 10 });
327
+ expect(output.exitCode).toBe(0);
328
+ });
329
+
330
+ test("script without argsSchema still works (backward-compat)", async () => {
331
+ const output = await runScript({
332
+ agentId: "agent-1",
333
+ args: { value: 42 },
334
+ resources,
335
+ source: `
336
+ export default async (args: { value: number }) => ({ doubled: args.value * 2 });
337
+ `,
338
+ });
339
+
340
+ expect(output.error).toBeUndefined();
341
+ expect(output.result).toEqual({ doubled: 84 });
342
+ expect(output.exitCode).toBe(0);
343
+ });
289
344
  });
package/src/types.ts CHANGED
@@ -1320,6 +1320,7 @@ export const ScriptRecordSchema = z.object({
1320
1320
  description: z.string(),
1321
1321
  intent: z.string(),
1322
1322
  signatureJson: z.string(),
1323
+ argsJsonSchema: z.string().nullable(),
1323
1324
  contentHash: z.string(),
1324
1325
  version: z.number(),
1325
1326
  isScratch: z.boolean(),
@@ -10,8 +10,21 @@ export interface ErrorSignal {
10
10
  timestamp: string;
11
11
  }
12
12
 
13
+ /**
14
+ * Clamps a candidate reset timestamp (ms) to [now+60s, now+6h].
15
+ * Protects against past timestamps (clock skew) and absurdly far future values (malformed).
16
+ */
17
+ function clampRateLimitResetMs(candidateMs: number): number {
18
+ const nowMs = Date.now();
19
+ const minMs = nowMs + 60_000;
20
+ const maxMs = nowMs + 6 * 60 * 60 * 1000;
21
+ return Math.min(Math.max(candidateMs, minMs), maxMs);
22
+ }
23
+
13
24
  export class SessionErrorTracker {
14
25
  private errors: ErrorSignal[] = [];
26
+ /** Stashed reset time (ms) from the last rejected rate_limit_event in this session. */
27
+ private rateLimitResetAtMs: number | undefined;
15
28
 
16
29
  /** Record an error from an assistant message with message.error field */
17
30
  addApiError(errorCategory: string, message: string): void {
@@ -53,6 +66,45 @@ export class SessionErrorTracker {
53
66
  });
54
67
  }
55
68
 
69
+ /**
70
+ * Process a parsed rate_limit_event JSON object from the Claude CLI stream.
71
+ * Only stashes the reset time when status === "rejected"; ignores all others.
72
+ * Last call wins — if the CLI emits multiple events, the final rejected one is used.
73
+ *
74
+ * `resetsAt` is **seconds** since epoch (empirically verified; Linear description is wrong).
75
+ * Conversion to ms happens here at this single well-named boundary.
76
+ */
77
+ processRateLimitEvent(json: Record<string, unknown>): void {
78
+ try {
79
+ const info = json.rate_limit_info as Record<string, unknown> | undefined;
80
+ if (!info) return;
81
+
82
+ if (info.status !== "rejected") return;
83
+
84
+ const resetsAtSec = info.resetsAt;
85
+ if (typeof resetsAtSec !== "number" || !Number.isFinite(resetsAtSec) || resetsAtSec <= 0) {
86
+ console.warn(
87
+ `[rate_limit_event] Malformed resetsAt value: ${JSON.stringify(resetsAtSec)} — ignoring`,
88
+ );
89
+ return;
90
+ }
91
+
92
+ const resetsAtMs = resetsAtSec * 1000;
93
+ this.rateLimitResetAtMs = clampRateLimitResetMs(resetsAtMs);
94
+ } catch (err) {
95
+ console.warn(`[rate_limit_event] Failed to process event: ${err}`);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Returns the stashed rate limit reset time as an ISO string, or undefined
101
+ * if no rejected rate_limit_event was seen in this session.
102
+ */
103
+ getRateLimitResetAt(): string | undefined {
104
+ if (this.rateLimitResetAtMs === undefined) return undefined;
105
+ return new Date(this.rateLimitResetAtMs).toISOString();
106
+ }
107
+
56
108
  hasErrors(): boolean {
57
109
  return this.errors.length > 0;
58
110
  }
@@ -137,6 +189,12 @@ export function trackErrorFromJson(
137
189
  json: Record<string, unknown>,
138
190
  tracker: SessionErrorTracker,
139
191
  ): void {
192
+ // 0. Structured rate limit event — stash resetsAt for the three-tier resolver in runner.ts
193
+ if (json.type === "rate_limit_event") {
194
+ tracker.processRateLimitEvent(json);
195
+ return;
196
+ }
197
+
140
198
  // 1. Assistant messages with API errors (rate_limit, auth, billing, etc.)
141
199
  if (json.type === "assistant") {
142
200
  const message = json.message as Record<string, unknown> | undefined;