@desplega.ai/agent-swarm 1.77.2 → 1.78.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.
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.77.2",
5
+ "version": "1.78.0",
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.77.2",
3
+ "version": "1.78.0",
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>",
package/src/be/db.ts CHANGED
@@ -1411,9 +1411,14 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1411
1411
  params.push(filters.createdAfter);
1412
1412
  }
1413
1413
 
1414
- // Exclude heartbeat tasks by default
1414
+ // Exclude system/heartbeat tasks by default. The flag is still called
1415
+ // `includeHeartbeat` for backward compat with existing API callers, but we
1416
+ // also gate boot-triage + heartbeat-checklist behind it since those are
1417
+ // equally noisy in the dashboard task list.
1415
1418
  if (!filters?.includeHeartbeat) {
1416
- conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
1419
+ conditions.push(
1420
+ "(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
1421
+ );
1417
1422
  }
1418
1423
 
1419
1424
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -1509,9 +1514,14 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1509
1514
  params.push(filters.createdAfter);
1510
1515
  }
1511
1516
 
1512
- // Exclude heartbeat tasks by default
1517
+ // Exclude system/heartbeat tasks by default. The flag is still called
1518
+ // `includeHeartbeat` for backward compat with existing API callers, but we
1519
+ // also gate boot-triage + heartbeat-checklist behind it since those are
1520
+ // equally noisy in the dashboard task list.
1513
1521
  if (!filters?.includeHeartbeat) {
1514
- conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
1522
+ conditions.push(
1523
+ "(IFNULL(taskType, '') NOT IN ('heartbeat', 'heartbeat-checklist', 'boot-triage') AND tags NOT LIKE '%\"heartbeat\"%')",
1524
+ );
1515
1525
  }
1516
1526
 
1517
1527
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
@@ -2257,8 +2257,14 @@ async function checkCompletedProcesses(
2257
2257
  failureReason = result.failureReason;
2258
2258
  console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
2259
2259
 
2260
- // If rate-limited and we know which key was used, report it
2261
- if (credentialInfo && /rate.?limit|hit your limit/i.test(failureReason)) {
2260
+ // If rate-limited and we know which key was used, report it.
2261
+ // Codex adapter prefixes failure reasons with `[rate-limit]` /
2262
+ // `[usage-limit]` (see codex-adapter.formatTerminalError); Claude
2263
+ // surfaces "rate limit" / "hit your limit" via SessionErrorTracker.
2264
+ if (
2265
+ credentialInfo &&
2266
+ /rate.?limit|hit your limit|usage[ _-]?limit|too many requests/i.test(failureReason)
2267
+ ) {
2262
2268
  // Try to extract reset time from the error message (e.g. "resets 3pm (UTC)")
2263
2269
  const parsedResetTime = parseRateLimitResetTime(failureReason);
2264
2270
  const defaultCooldownMs = 5 * 60 * 1000;
@@ -748,7 +748,10 @@ class CodexSession implements ProviderSession {
748
748
  }
749
749
  case "error": {
750
750
  const errItem = item as ErrorItem;
751
- this.emit({ type: "error", message: this.formatTerminalError(errItem.message) });
751
+ this.emit({
752
+ type: "error",
753
+ message: this.formatTerminalError(errItem.message).message,
754
+ });
752
755
  break;
753
756
  }
754
757
  }
@@ -792,12 +795,12 @@ class CodexSession implements ProviderSession {
792
795
  break;
793
796
  }
794
797
  case "turn.failed": {
795
- const message = this.formatTerminalError(event.error.message);
798
+ const { message } = this.formatTerminalError(event.error.message);
796
799
  this.emit({ type: "error", message });
797
800
  break;
798
801
  }
799
802
  case "error": {
800
- const message = this.formatTerminalError(event.message);
803
+ const { message } = this.formatTerminalError(event.message);
801
804
  this.emit({ type: "error", message });
802
805
  break;
803
806
  }
@@ -805,22 +808,27 @@ class CodexSession implements ProviderSession {
805
808
  }
806
809
 
807
810
  /**
808
- * Detect context-window-exceeded errors from the Codex CLI / SDK and rewrite
809
- * them with a clearer, actionable message. Codex does not auto-compact like
810
- * Claude does — when context fills, the next model call hard-fails. We can't
811
- * compact retroactively, so we just mark the failure with a recognizable
812
- * `[context-overflow]` prefix that the runner can flag in dashboards. See
813
- * Linear DES-143 (codex auto-compaction follow-up) for the long-term fix.
811
+ * Categorize a terminal error from the Codex SDK and rewrite with a clearer
812
+ * prefix that the runner / dashboard can key on. The Codex app-server emits a
813
+ * structured `codexErrorInfo` discriminator
814
+ * (https://developers.openai.com/codex/app-server#errors) with values like
815
+ * `ContextWindowExceeded`, `UsageLimitExceeded`, `Unauthorized`, etc. but
816
+ * `@openai/codex-sdk`'s `ThreadError` only surfaces the flat `message`
817
+ * string, so we still detect by pattern. Patterns below match the canonical
818
+ * `codexErrorInfo` name (which sometimes appears literally in the message)
819
+ * AND the human-readable text Codex puts in `error.message`.
814
820
  *
815
- * Patterns observed in the wild (case-insensitive):
816
- * - "context length exceeded"
817
- * - "maximum context length"
818
- * - "too many tokens"
819
- * - "input too long"
820
- * - "request too large"
821
+ * Categories returned are consumed two ways:
822
+ * 1. `errorCategory` on the `result` event (dashboard surfacing).
823
+ * 2. The bracketed prefix in `failureReason` (`[usage-limit]` etc.) is
824
+ * what runner.ts pattern-matches to flag the credential as
825
+ * rate-limited in the rotation pool.
821
826
  */
822
- private formatTerminalError(raw: string): string {
827
+ private formatTerminalError(raw: string): { message: string; category?: string } {
823
828
  const normalized = raw.toLowerCase();
829
+
830
+ // Context window exceeded — Codex has no auto-compact like Claude.
831
+ // See Linear DES-143 for the long-term fix.
824
832
  const overflowPatterns = [
825
833
  "context length exceeded",
826
834
  "maximum context length",
@@ -828,11 +836,60 @@ class CodexSession implements ProviderSession {
828
836
  "input too long",
829
837
  "request too large",
830
838
  "context_length_exceeded",
839
+ "contextwindowexceeded",
831
840
  ];
832
841
  if (overflowPatterns.some((p) => normalized.includes(p))) {
833
- return `[context-overflow] Codex turn exceeded the model's context window for ${this.resolvedModel} (${this.contextWindow.toLocaleString()} tokens). Codex does not auto-compact conversation history like Claude does — start a fresh task or split the work into smaller turns. Original error: ${raw}`;
842
+ return {
843
+ message: `[context-overflow] Codex turn exceeded the model's context window for ${this.resolvedModel} (${this.contextWindow.toLocaleString()} tokens). Codex does not auto-compact conversation history like Claude does — start a fresh task or split the work into smaller turns. Original error: ${raw}`,
844
+ category: "context_overflow",
845
+ };
846
+ }
847
+
848
+ // Pro / business quota exhausted — codexErrorInfo: "UsageLimitExceeded".
849
+ // Message text typically reads "You've hit your usage limit. Upgrade to Pro …".
850
+ const usageLimitPatterns = ["usage limit", "upgrade to pro", "usagelimitexceeded"];
851
+ if (usageLimitPatterns.some((p) => normalized.includes(p))) {
852
+ return {
853
+ message: `[usage-limit] Codex account quota exhausted — upgrade plan or wait for monthly reset. Original error: ${raw}`,
854
+ category: "usage_limit",
855
+ };
856
+ }
857
+
858
+ // Per-minute / per-hour API rate limiting (HTTP 429).
859
+ const rateLimitPatterns = [
860
+ "rate limit",
861
+ "rate_limit",
862
+ "ratelimit",
863
+ "too many requests",
864
+ "http 429",
865
+ " 429 ",
866
+ ];
867
+ if (rateLimitPatterns.some((p) => normalized.includes(p))) {
868
+ return {
869
+ message: `[rate-limit] Codex API rate limit hit. Original error: ${raw}`,
870
+ category: "rate_limit",
871
+ };
872
+ }
873
+
874
+ // Bad / missing / invalid API key — codexErrorInfo: "Unauthorized".
875
+ const authPatterns = [
876
+ "unauthorized",
877
+ "http 401",
878
+ " 401 ",
879
+ "invalid api key",
880
+ "invalid_api_key",
881
+ "missing api key",
882
+ "no api key",
883
+ "authentication failed",
884
+ ];
885
+ if (authPatterns.some((p) => normalized.includes(p))) {
886
+ return {
887
+ message: `[auth-error] Codex authentication failed — check OPENAI_API_KEY or ChatGPT login. Original error: ${raw}`,
888
+ category: "authentication_failed",
889
+ };
834
890
  }
835
- return raw;
891
+
892
+ return { message: raw };
836
893
  }
837
894
 
838
895
  private async runSession(): Promise<void> {
@@ -840,7 +897,7 @@ class CodexSession implements ProviderSession {
840
897
  // Expose the controller to the swarm event handler so it can trigger an
841
898
  // abort from outside this method (tool-loop detection, cancellation poll).
842
899
  this.abortRef.current = this.abortController;
843
- let terminalError: string | undefined;
900
+ let terminalError: { message: string; category?: string } | undefined;
844
901
  let sawTurnCompleted = false;
845
902
 
846
903
  try {
@@ -897,14 +954,14 @@ class CodexSession implements ProviderSession {
897
954
  type: "result",
898
955
  cost,
899
956
  isError,
900
- errorCategory: terminalError ? "turn_failed" : undefined,
957
+ errorCategory: terminalError ? (terminalError.category ?? "turn_failed") : undefined,
901
958
  });
902
959
  this.settle({
903
960
  exitCode: isError ? 1 : 0,
904
961
  sessionId: this._sessionId,
905
962
  cost,
906
963
  isError,
907
- failureReason: terminalError,
964
+ failureReason: terminalError?.message,
908
965
  });
909
966
  } catch (err) {
910
967
  const message = err instanceof Error ? err.message : String(err);
@@ -38,24 +38,31 @@ import type {
38
38
  } from "./types";
39
39
 
40
40
  /**
41
- * Map a `MODEL_OVERRIDE` string to the env var that satisfies it. Mirrors
42
- * `resolveModel` above (shortname → anthropic, `provider/model-id` → that
43
- * provider). Returns `null` when the override is empty (boot-loop should
44
- * treat it as the permissive case) or the provider can't be inferred.
41
+ * Map a `MODEL_OVERRIDE` string to the env var(s) that can satisfy it.
42
+ *
43
+ * Anthropic shortnames (`sonnet` / `haiku` / `opus`) accept EITHER
44
+ * `ANTHROPIC_API_KEY` (preferred talks to Anthropic directly) OR
45
+ * `OPENROUTER_API_KEY` — in the latter case `resolveModel` swaps to the
46
+ * OpenRouter mirror of the same model so pi-ai's anthropic-provider env
47
+ * lookup (which only checks `ANTHROPIC_*`) doesn't fail with "No API key
48
+ * found for anthropic". Provider-prefixed model IDs only accept that one
49
+ * provider's key. Returns `null` for the permissive case (no MODEL_OVERRIDE
50
+ * or bare unprefixed model name).
45
51
  */
46
- function modelToCredKey(modelStr: string | undefined): string | null {
52
+ function modelToCredKeys(modelStr: string | undefined): string[] | null {
47
53
  if (!modelStr) return null;
48
54
  const lower = modelStr.toLowerCase();
49
- // Hard-coded shortnames map straight to anthropic.
55
+ // Hard-coded shortnames: anthropic-shape but pi-mono can route through
56
+ // OpenRouter (see `resolveModel`) when only an OR key is available.
50
57
  if (lower === "opus" || lower === "sonnet" || lower === "haiku") {
51
- return "ANTHROPIC_API_KEY";
58
+ return ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"];
52
59
  }
53
60
  if (modelStr.includes("/")) {
54
61
  const provider = modelStr.slice(0, modelStr.indexOf("/")).toLowerCase();
55
- if (provider === "anthropic") return "ANTHROPIC_API_KEY";
56
- if (provider === "openrouter") return "OPENROUTER_API_KEY";
57
- if (provider === "openai") return "OPENAI_API_KEY";
58
- if (provider === "google") return "GOOGLE_API_KEY";
62
+ if (provider === "anthropic") return ["ANTHROPIC_API_KEY"];
63
+ if (provider === "openrouter") return ["OPENROUTER_API_KEY"];
64
+ if (provider === "openai") return ["OPENAI_API_KEY"];
65
+ if (provider === "google") return ["GOOGLE_API_KEY"];
59
66
  }
60
67
  // Bare model name with no provider prefix — adapter falls through to a
61
68
  // best-effort resolution against multiple providers, so the boot loop
@@ -83,15 +90,16 @@ export function checkPiMonoCredentials(
83
90
  return { ready: true, missing: [], satisfiedBy: "file" };
84
91
  }
85
92
 
86
- const requiredKey = modelToCredKey(env.MODEL_OVERRIDE);
87
- if (requiredKey) {
88
- if (env[requiredKey]) {
93
+ const requiredKeys = modelToCredKeys(env.MODEL_OVERRIDE);
94
+ if (requiredKeys) {
95
+ if (requiredKeys.some((k) => env[k])) {
89
96
  return { ready: true, missing: [], satisfiedBy: "env" };
90
97
  }
98
+ const keyList = requiredKeys.join(" / ");
91
99
  return {
92
100
  ready: false,
93
- missing: [requiredKey, authFile],
94
- hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} requires ${requiredKey}; or run \`pi auth login\` to create ${authFile}.`,
101
+ missing: [...requiredKeys, authFile],
102
+ hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} requires one of ${keyList}; or run \`pi auth login\` to create ${authFile}.`,
95
103
  };
96
104
  }
97
105
 
@@ -136,18 +144,67 @@ function mcpToolsToDefinitions(
136
144
  }));
137
145
  }
138
146
 
139
- /** Resolve a model string to a pi-ai Model object */
140
- function resolveModel(modelStr: string) {
147
+ /**
148
+ * Anthropic-shortname → OpenRouter-mirror model IDs. Used by `resolveModel`
149
+ * when the worker only has `OPENROUTER_API_KEY` so pi-ai's anthropic
150
+ * provider env lookup (`ANTHROPIC_OAUTH_TOKEN` / `ANTHROPIC_API_KEY` only)
151
+ * doesn't fail with "No API key found for anthropic".
152
+ *
153
+ * The mirror IDs match pi-ai's generated OpenRouter model catalog
154
+ * (`anthropic/claude-{opus,sonnet,haiku}-*`).
155
+ */
156
+ const ANTHROPIC_SHORTNAME_OPENROUTER_MIRROR: Record<string, string> = {
157
+ opus: "anthropic/claude-opus-4",
158
+ sonnet: "anthropic/claude-sonnet-4",
159
+ haiku: "anthropic/claude-haiku-4.5",
160
+ };
161
+
162
+ function envHasAnthropicCred(env: Record<string, string | undefined>): boolean {
163
+ return !!(env.ANTHROPIC_API_KEY || env.ANTHROPIC_OAUTH_TOKEN);
164
+ }
165
+
166
+ /**
167
+ * Resolve a model string to a pi-ai Model object.
168
+ *
169
+ * When `modelStr` is an anthropic shortname (`sonnet`/`haiku`/`opus`) AND
170
+ * the env only has `OPENROUTER_API_KEY` (no `ANTHROPIC_API_KEY` /
171
+ * `ANTHROPIC_OAUTH_TOKEN`), the shortname is rerouted through the
172
+ * OpenRouter mirror of the same model. This prevents pi-ai's
173
+ * anthropic-provider env lookup from failing at session-start with
174
+ * "No API key found for anthropic" — see task 37a4a87a and the chronic
175
+ * weekly-fire pattern (2026-04-13 → 2026-05-11) tracked in HEARTBEAT.md.
176
+ */
177
+ export function resolveModel(
178
+ modelStr: string,
179
+ env: Record<string, string | undefined> = process.env,
180
+ ) {
141
181
  if (!modelStr) return undefined;
142
182
 
143
- // Map common shortnames to provider/model pairs
183
+ const lower = modelStr.toLowerCase();
184
+ const isAnthropicShortname = lower === "opus" || lower === "sonnet" || lower === "haiku";
185
+
186
+ // Reroute anthropic shortnames through OpenRouter when no anthropic cred
187
+ // is available. The OpenRouter mirror IDs (`anthropic/claude-sonnet-4`,
188
+ // etc.) are present in pi-ai's model catalog.
189
+ if (isAnthropicShortname && !envHasAnthropicCred(env) && env.OPENROUTER_API_KEY) {
190
+ const orModelId = ANTHROPIC_SHORTNAME_OPENROUTER_MIRROR[lower];
191
+ if (orModelId) {
192
+ try {
193
+ return getModel("openrouter" as "anthropic", orModelId as never);
194
+ } catch {
195
+ // Fall through to native anthropic mapping below.
196
+ }
197
+ }
198
+ }
199
+
200
+ // Map common shortnames to provider/model pairs (native anthropic path).
144
201
  const shortnames: Record<string, [string, string]> = {
145
202
  opus: ["anthropic", "claude-opus-4-20250514"],
146
203
  sonnet: ["anthropic", "claude-sonnet-4-20250514"],
147
204
  haiku: ["anthropic", "claude-haiku-4-5-20251001"],
148
205
  };
149
206
 
150
- const mapping = shortnames[modelStr.toLowerCase()];
207
+ const mapping = shortnames[lower];
151
208
  if (mapping) {
152
209
  try {
153
210
  return getModel(mapping[0] as "anthropic", mapping[1] as never);
package/src/server.ts CHANGED
@@ -120,8 +120,7 @@ import {
120
120
 
121
121
  // Capability-based feature flags
122
122
  // Default: all capabilities enabled
123
- const DEFAULT_CAPABILITIES =
124
- "core,task-pool,messaging,profiles,services,scheduling,memory,workflows";
123
+ const DEFAULT_CAPABILITIES = "core,task-pool,profiles,services,scheduling,memory,workflows";
125
124
  const CAPABILITIES = new Set(
126
125
  (process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
127
126
  );
@@ -204,13 +203,15 @@ export function createServer() {
204
203
  registerTaskActionTool(server);
205
204
  }
206
205
 
207
- // Messaging capability - channel-based communication
206
+ // Core messaging tools - always registered (post/read are CORE_TOOLS)
207
+ registerPostMessageTool(server);
208
+ registerReadMessagesTool(server);
209
+
210
+ // Messaging capability - channel management (CRUD on channels)
208
211
  if (hasCapability("messaging")) {
209
212
  registerListChannelsTool(server);
210
213
  registerCreateChannelTool(server);
211
214
  registerDeleteChannelTool(server);
212
- registerPostMessageTool(server);
213
- registerReadMessagesTool(server);
214
215
  }
215
216
 
216
217
  // Profiles capability - agent profile management
@@ -389,6 +389,88 @@ describe("CodexSession event mapping", () => {
389
389
  expect(result.failureReason).toContain("[context-overflow]");
390
390
  });
391
391
 
392
+ test("turn.failed with usage-limit message rewrites + sets errorCategory=usage_limit", async () => {
393
+ // Codex Pro-quota exhausted: codexErrorInfo: "UsageLimitExceeded".
394
+ // Adapter must prefix `[usage-limit]` so runner.ts marks the credential
395
+ // as rate-limited in the rotation pool.
396
+ const events: ThreadEvent[] = [
397
+ { type: "thread.started", thread_id: "thread-usage" },
398
+ { type: "turn.started" },
399
+ {
400
+ type: "turn.failed",
401
+ error: {
402
+ message: "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/pricing).",
403
+ },
404
+ },
405
+ ];
406
+
407
+ const { emitted, result } = await runSessionWithFakeThread(
408
+ events,
409
+ testConfig({ logFile: join(tmpLogDir, "usage.log"), cwd: "" }),
410
+ );
411
+
412
+ const errorEvent = emitted.find((e) => e.type === "error");
413
+ expect(errorEvent?.type === "error" && errorEvent.message).toContain("[usage-limit]");
414
+
415
+ const resultEvent = emitted.findLast((e) => e.type === "result");
416
+ expect(resultEvent?.type === "result" && resultEvent.errorCategory).toBe("usage_limit");
417
+
418
+ expect(result.isError).toBe(true);
419
+ expect(result.failureReason).toContain("[usage-limit]");
420
+ });
421
+
422
+ test("turn.failed with rate-limit message rewrites + sets errorCategory=rate_limit", async () => {
423
+ const events: ThreadEvent[] = [
424
+ { type: "thread.started", thread_id: "thread-rate" },
425
+ { type: "turn.started" },
426
+ {
427
+ type: "turn.failed",
428
+ error: { message: "Request failed: 429 Too Many Requests — rate_limit_exceeded." },
429
+ },
430
+ ];
431
+
432
+ const { emitted, result } = await runSessionWithFakeThread(
433
+ events,
434
+ testConfig({ logFile: join(tmpLogDir, "rate.log"), cwd: "" }),
435
+ );
436
+
437
+ const errorEvent = emitted.find((e) => e.type === "error");
438
+ expect(errorEvent?.type === "error" && errorEvent.message).toContain("[rate-limit]");
439
+
440
+ const resultEvent = emitted.findLast((e) => e.type === "result");
441
+ expect(resultEvent?.type === "result" && resultEvent.errorCategory).toBe("rate_limit");
442
+
443
+ expect(result.isError).toBe(true);
444
+ expect(result.failureReason).toContain("[rate-limit]");
445
+ });
446
+
447
+ test("turn.failed with auth error rewrites + sets errorCategory=authentication_failed", async () => {
448
+ const events: ThreadEvent[] = [
449
+ { type: "thread.started", thread_id: "thread-auth" },
450
+ { type: "turn.started" },
451
+ {
452
+ type: "turn.failed",
453
+ error: { message: "Request failed: HTTP 401 Unauthorized — Invalid API key provided." },
454
+ },
455
+ ];
456
+
457
+ const { emitted, result } = await runSessionWithFakeThread(
458
+ events,
459
+ testConfig({ logFile: join(tmpLogDir, "auth.log"), cwd: "" }),
460
+ );
461
+
462
+ const errorEvent = emitted.find((e) => e.type === "error");
463
+ expect(errorEvent?.type === "error" && errorEvent.message).toContain("[auth-error]");
464
+
465
+ const resultEvent = emitted.findLast((e) => e.type === "result");
466
+ expect(resultEvent?.type === "result" && resultEvent.errorCategory).toBe(
467
+ "authentication_failed",
468
+ );
469
+
470
+ expect(result.isError).toBe(true);
471
+ expect(result.failureReason).toContain("[auth-error]");
472
+ });
473
+
392
474
  test("abort() resolves the session with cancelled result", async () => {
393
475
  // Patch startThread with a fake whose runStreamed yields a long stream
394
476
  // that respects the AbortSignal — yields one event, awaits, and only
@@ -192,7 +192,13 @@ describe("checkPiMonoCredentials", () => {
192
192
  ).toBe(false);
193
193
  });
194
194
 
195
- test("strict: shortname `sonnet` resolves to anthropic", () => {
195
+ test("shortname `sonnet` accepts ANTHROPIC_API_KEY *or* OPENROUTER_API_KEY", () => {
196
+ // Anthropic-shortname models (sonnet/haiku/opus) prefer the native
197
+ // ANTHROPIC_* credential, but pi-mono-adapter reroutes through the
198
+ // OpenRouter mirror when only OPENROUTER_API_KEY is available — so the
199
+ // boot-time cred check must accept either key. See task 37a4a87a and
200
+ // the chronic pi-mono → "No API key found for anthropic" recurrence
201
+ // tracked in HEARTBEAT.md (2026-04-13 → 2026-05-11).
196
202
  const env = { MODEL_OVERRIDE: "sonnet" };
197
203
  expect(
198
204
  checkPiMonoCredentials({ ...env, ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
@@ -201,7 +207,22 @@ describe("checkPiMonoCredentials", () => {
201
207
  expect(
202
208
  checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
203
209
  .ready,
204
- ).toBe(false);
210
+ ).toBe(true);
211
+ // Neither key set → still not ready, and missing includes both options.
212
+ const empty = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
213
+ expect(empty.ready).toBe(false);
214
+ expect(empty.missing).toContain("ANTHROPIC_API_KEY");
215
+ expect(empty.missing).toContain("OPENROUTER_API_KEY");
216
+ });
217
+
218
+ test("haiku and opus shortnames also accept OPENROUTER_API_KEY", () => {
219
+ for (const model of ["haiku", "opus"]) {
220
+ const env = { MODEL_OVERRIDE: model };
221
+ expect(
222
+ checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
223
+ .ready,
224
+ ).toBe(true);
225
+ }
205
226
  });
206
227
  });
207
228
 
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { PiMonoAdapter } from "../providers/pi-mono-adapter";
4
+ import { PiMonoAdapter, resolveModel } from "../providers/pi-mono-adapter";
5
5
 
6
6
  describe("PiMonoAdapter", () => {
7
7
  test("name is 'pi'", () => {
@@ -115,6 +115,68 @@ describe("Model name mapping", () => {
115
115
  });
116
116
  });
117
117
 
118
+ describe("resolveModel — OpenRouter reroute for anthropic shortnames", () => {
119
+ // Regression coverage for task 37a4a87a: workers spawned with
120
+ // `provider: pi` + `OPENROUTER_API_KEY` (no ANTHROPIC_API_KEY) and a task
121
+ // model of `sonnet` / `haiku` / `opus` previously crashed at
122
+ // session-start with "No API key found for anthropic" because pi-ai's
123
+ // anthropic provider only checks ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY.
124
+ // The adapter now reroutes the shortname through the OpenRouter mirror.
125
+
126
+ test("sonnet → openrouter/anthropic/claude-sonnet-4 when only OPENROUTER_API_KEY is set", () => {
127
+ const env = { OPENROUTER_API_KEY: "sk-or-..." };
128
+ const model = resolveModel("sonnet", env);
129
+ expect(model).toBeDefined();
130
+ expect(model?.provider).toBe("openrouter");
131
+ expect(model?.id).toBe("anthropic/claude-sonnet-4");
132
+ });
133
+
134
+ test("haiku → openrouter/anthropic/claude-haiku-4.5 when only OPENROUTER_API_KEY is set", () => {
135
+ const env = { OPENROUTER_API_KEY: "sk-or-..." };
136
+ const model = resolveModel("haiku", env);
137
+ expect(model).toBeDefined();
138
+ expect(model?.provider).toBe("openrouter");
139
+ expect(model?.id).toBe("anthropic/claude-haiku-4.5");
140
+ });
141
+
142
+ test("opus → openrouter/anthropic/claude-opus-4 when only OPENROUTER_API_KEY is set", () => {
143
+ const env = { OPENROUTER_API_KEY: "sk-or-..." };
144
+ const model = resolveModel("opus", env);
145
+ expect(model).toBeDefined();
146
+ expect(model?.provider).toBe("openrouter");
147
+ expect(model?.id).toBe("anthropic/claude-opus-4");
148
+ });
149
+
150
+ test("anthropic native path wins when ANTHROPIC_API_KEY is set (even alongside OPENROUTER_API_KEY)", () => {
151
+ const env = { ANTHROPIC_API_KEY: "sk-ant-...", OPENROUTER_API_KEY: "sk-or-..." };
152
+ const model = resolveModel("sonnet", env);
153
+ expect(model).toBeDefined();
154
+ expect(model?.provider).toBe("anthropic");
155
+ expect(model?.id).toBe("claude-sonnet-4-20250514");
156
+ });
157
+
158
+ test("ANTHROPIC_OAUTH_TOKEN alone also wins over OPENROUTER reroute", () => {
159
+ const env = { ANTHROPIC_OAUTH_TOKEN: "sk-ant-oat-...", OPENROUTER_API_KEY: "sk-or-..." };
160
+ const model = resolveModel("sonnet", env);
161
+ expect(model).toBeDefined();
162
+ expect(model?.provider).toBe("anthropic");
163
+ });
164
+
165
+ test("no rerouting for non-shortname `anthropic/<model>` strings", () => {
166
+ // Explicit provider prefix should not be silently swapped — that path is
167
+ // the caller's explicit choice, surface as-is.
168
+ const env = { OPENROUTER_API_KEY: "sk-or-..." };
169
+ const model = resolveModel("anthropic/claude-sonnet-4-20250514", env);
170
+ expect(model?.provider).toBe("anthropic");
171
+ });
172
+
173
+ test("default env arg falls back to process.env (smoke test — no creds set)", () => {
174
+ // Just confirm the default parameter doesn't throw — the actual model
175
+ // resolution depends on the test runner's env.
176
+ expect(() => resolveModel("unknown-model-id")).not.toThrow();
177
+ });
178
+ });
179
+
118
180
  describe("Pi-mono event normalization", () => {
119
181
  test("message_update with text content produces raw_log-style data", () => {
120
182
  // Simulates what PiMonoSession.handleAgentEvent does
@@ -475,22 +475,31 @@ describe("ScriptExecutor", () => {
475
475
  expect(result.nextPort).toBe("success");
476
476
  });
477
477
 
478
- test("captures stderr on failure", async () => {
478
+ test("marks step failed and captures stderr on non-zero exit", async () => {
479
479
  const result = await executor.run(
480
480
  input({ runtime: "bash", script: "echo err >&2; exit 1" }, {}),
481
481
  );
482
- expect(result.status).toBe("success"); // executor succeeds, script fails
482
+ expect(result.status).toBe("failed");
483
+ expect(result.error).toBe("err");
483
484
  const out = result.output as { exitCode: number; stdout: string; stderr: string };
484
485
  expect(out.exitCode).toBe(1);
485
486
  expect(out.stderr).toBe("err");
486
- expect(result.nextPort).toBe("failure");
487
487
  });
488
488
 
489
- test("returns failure port on non-zero exit code", async () => {
489
+ test("marks step failed on non-zero exit code (exit 1)", async () => {
490
+ const result = await executor.run(input({ runtime: "bash", script: "exit 1" }, {}));
491
+ expect(result.status).toBe("failed");
492
+ expect(result.error).toBe("Script exited with code 1");
493
+ const out = result.output as { exitCode: number };
494
+ expect(out?.exitCode).toBe(1);
495
+ });
496
+
497
+ test("marks step failed with exit code in error when no stderr (exit 42)", async () => {
490
498
  const result = await executor.run(input({ runtime: "bash", script: "exit 42" }, {}));
491
- expect(result.nextPort).toBe("failure");
499
+ expect(result.status).toBe("failed");
500
+ expect(result.error).toBe("Script exited with code 42");
492
501
  const out = result.output as { exitCode: number };
493
- expect(out.exitCode).toBe(42);
502
+ expect(out?.exitCode).toBe(42);
494
503
  });
495
504
 
496
505
  test("runs TypeScript script via bun", async () => {
@@ -261,8 +261,11 @@ describe("WaitExecutor — event mode end-to-end", () => {
261
261
 
262
262
  // Skip the 5s poller — fast-forward by directly calling the resume helper
263
263
  // with status='timeout' (the poller would do exactly this once expiresAt
264
- // passes).
265
- await new Promise((r) => setTimeout(r, 1100));
264
+ // passes). Sleep relative to the *actual* expiresAt so we don't race
265
+ // when startWorkflowExecution overhead eats the cushion on slow CI.
266
+ const expiresAtMs = new Date(ws!.expiresAt!).getTime();
267
+ const sleepMs = Math.max(0, expiresAtMs - Date.now()) + 250;
268
+ await new Promise((r) => setTimeout(r, sleepMs));
266
269
  const due = getDueWaitStates();
267
270
  expect(due.find((d) => d.id === ws!.id)).toBeDefined();
268
271
 
@@ -532,6 +532,11 @@ async function executeStep(
532
532
  retryPolicy,
533
533
  );
534
534
 
535
+ // Persist output for observability even on failure (e.g. script nodes keep {exitCode, stdout, stderr})
536
+ if (result.output !== undefined) {
537
+ updateWorkflowRunStep(stepId, { output: result.output });
538
+ }
539
+
535
540
  if (!shouldRetry) {
536
541
  throw new Error(result.error || "Step execution failed");
537
542
  }
@@ -41,11 +41,21 @@ export class ScriptExecutor extends BaseExecutor<
41
41
  try {
42
42
  const result = await Promise.race([this.runScript(config), this.timeoutPromise(timeoutMs)]);
43
43
 
44
+ // Non-zero exit code is a hard failure — mark the step failed so the
45
+ // workflow engine stops the branch and operators can see what went wrong.
46
+ if (result.exitCode !== 0) {
47
+ return {
48
+ status: "failed",
49
+ error: result.stderr || `Script exited with code ${result.exitCode}`,
50
+ output: result as unknown as z.infer<typeof ScriptOutputSchema>,
51
+ };
52
+ }
53
+
44
54
  // If stdout is valid JSON object, merge parsed fields into output
45
55
  // so downstream nodes can access them via {{myScript.field}} interpolation
46
56
  // (mirrors how agent-task nodes parse JSON in resume.ts)
47
57
  let output: Record<string, unknown> = result;
48
- if (result.exitCode === 0 && result.stdout) {
58
+ if (result.stdout) {
49
59
  try {
50
60
  const parsed = JSON.parse(result.stdout);
51
61
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
@@ -58,8 +68,8 @@ export class ScriptExecutor extends BaseExecutor<
58
68
 
59
69
  return {
60
70
  status: "success",
61
- output: output as typeof result,
62
- nextPort: result.exitCode === 0 ? "success" : "failure",
71
+ output: output as z.infer<typeof ScriptOutputSchema>,
72
+ nextPort: "success",
63
73
  };
64
74
  } catch (err) {
65
75
  return {
@@ -0,0 +1,67 @@
1
+ # x402 Payment Module
2
+
3
+ > **Alpha / Opt-in** — This module is experimental and not wired into any core swarm path. Import it explicitly if you need x402 payment support.
4
+
5
+ Gives agents the ability to make [x402](https://github.com/coinbase/x402) payments when calling external APIs that return HTTP 402 responses. Uses USDC on Base (or Base Sepolia for testing) with automatic payment handling.
6
+
7
+ ## Status
8
+
9
+ This module is **not imported by any core swarm code**. It is an opt-in integration — include it only when you need automatic micropayment support in an agent task.
10
+
11
+ Knip and other dead-code scanners will flag this directory as unused because there are no production-code imports; that is expected.
12
+
13
+ ## Signer backends
14
+
15
+ | Backend | When to use | Required env vars |
16
+ |---------|-------------|-------------------|
17
+ | Openfort (default) | Managed wallet, keys in TEE | `OPENFORT_API_KEY`, `OPENFORT_WALLET_SECRET` |
18
+ | viem | Raw EVM private key (local/dev) | `EVM_PRIVATE_KEY` |
19
+
20
+ ## Opt-in usage
21
+
22
+ ```typescript
23
+ import { createX402Fetch, createX402Client } from "@/x402";
24
+
25
+ // Simple: drop-in replacement for fetch
26
+ const paidFetch = await createX402Fetch();
27
+ const response = await paidFetch("https://api.example.com/paid-endpoint");
28
+
29
+ // Advanced: full client with spending tracking
30
+ const client = await createX402Client();
31
+ const response = await client.fetch("https://api.example.com/paid-endpoint");
32
+ console.log(client.getSpendingSummary());
33
+ ```
34
+
35
+ ## Env vars
36
+
37
+ | Variable | Required | Description |
38
+ |----------|----------|-------------|
39
+ | `X402_SIGNER_TYPE` | No | `openfort` (default) or `viem` |
40
+ | `X402_NETWORK` | No | `eip155:8453` (Base mainnet, default) or `eip155:84532` (Base Sepolia) |
41
+ | `X402_MAX_AUTO_APPROVE_USD` | No | Per-request auto-approve ceiling in USD |
42
+ | `X402_DAILY_LIMIT_USD` | No | Daily spending cap in USD |
43
+ | `OPENFORT_API_KEY` | Openfort only | Openfort API key |
44
+ | `OPENFORT_WALLET_SECRET` | Openfort only | Openfort wallet secret |
45
+ | `OPENFORT_WALLET_ADDRESS` | No | Pre-existing wallet address (optional) |
46
+ | `EVM_PRIVATE_KEY` | viem only | Raw 32-byte hex private key |
47
+
48
+ ## Architecture
49
+
50
+ ```
51
+ src/x402/
52
+ index.ts # Public exports
53
+ client.ts # X402PaymentClient — wraps fetch with payment handling
54
+ config.ts # Env-var loader and config types
55
+ openfort-signer.ts # Openfort managed-wallet signer adapter
56
+ spending-tracker.ts # Per-request and daily spending limits
57
+ cli.ts # CLI helper for inspecting wallet / config
58
+ ```
59
+
60
+ ## Dependencies
61
+
62
+ `@x402/core`, `@x402/evm`, `@x402/fetch`, `viem`, `@openfort/openfort-node` are intentionally kept in `package.json` — they belong to this opt-in module.
63
+
64
+ ## References
65
+
66
+ - [x402 protocol](https://github.com/coinbase/x402)
67
+ - [Openfort docs](https://openfort.xyz/docs)
package/src/x402/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  /**
2
- * x402 Payment Module
2
+ * x402 Payment Module — **Alpha / Opt-in**
3
+ *
4
+ * @alpha
5
+ *
6
+ * This module is experimental and not imported by any core swarm path.
7
+ * Include it explicitly when you need automatic x402 micropayment support.
8
+ * See `src/x402/README.md` for setup, env vars, and usage examples.
3
9
  *
4
10
  * Gives agents the ability to make x402 payments when calling external APIs.
5
11
  * Uses USDC on Base (or Base Sepolia for testing) with automatic 402 handling.