@desplega.ai/agent-swarm 1.90.0 → 1.92.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 (96) hide show
  1. package/README.md +2 -1
  2. package/openapi.json +803 -150
  3. package/package.json +5 -5
  4. package/src/artifact-sdk/server.ts +2 -1
  5. package/src/be/db.ts +337 -1
  6. package/src/be/memory/providers/sqlite-store.ts +6 -1
  7. package/src/be/memory/types.ts +1 -0
  8. package/src/be/migrations/083_script_workflows.sql +51 -0
  9. package/src/be/modelsdev-cache.json +42352 -38595
  10. package/src/be/scripts/typecheck.ts +181 -1
  11. package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
  12. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
  13. package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
  14. package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
  15. package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
  16. package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
  17. package/src/be/seed-scripts/index.ts +54 -0
  18. package/src/be/seed-skills/index.ts +7 -0
  19. package/src/be/swarm-config-guard.ts +17 -0
  20. package/src/commands/artifact.ts +3 -2
  21. package/src/commands/profile-sync.ts +310 -0
  22. package/src/commands/runner.ts +134 -3
  23. package/src/hooks/hook.ts +32 -9
  24. package/src/http/db-query.ts +20 -5
  25. package/src/http/index.ts +57 -0
  26. package/src/http/integrations.ts +6 -1
  27. package/src/http/mcp-bridge.ts +117 -0
  28. package/src/http/mcp-oauth.ts +97 -39
  29. package/src/http/memory.ts +5 -2
  30. package/src/http/openapi.ts +2 -2
  31. package/src/http/pages-public.ts +10 -11
  32. package/src/http/pages.ts +7 -11
  33. package/src/http/script-runs.ts +555 -0
  34. package/src/http/scripts.ts +24 -1
  35. package/src/http/utils.ts +11 -4
  36. package/src/jira/app.ts +2 -3
  37. package/src/jira/webhook-lifecycle.ts +2 -1
  38. package/src/linear/app.ts +2 -3
  39. package/src/prompts/session-templates.ts +24 -4
  40. package/src/providers/claude-adapter.ts +86 -13
  41. package/src/script-workflows/executor.ts +110 -0
  42. package/src/script-workflows/harness.ts +73 -0
  43. package/src/script-workflows/label-lint.ts +51 -0
  44. package/src/script-workflows/limits.ts +22 -0
  45. package/src/script-workflows/supervisor.ts +139 -0
  46. package/src/script-workflows/workflow-ctx.ts +205 -0
  47. package/src/scripts-runtime/executors/native.ts +1 -0
  48. package/src/scripts-runtime/sdk-allowlist.ts +124 -0
  49. package/src/scripts-runtime/swarm-sdk.ts +198 -3
  50. package/src/scripts-runtime/types/stdlib.d.ts +287 -0
  51. package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
  52. package/src/server.ts +2 -0
  53. package/src/slack/handlers.ts +11 -4
  54. package/src/slack/message-text.ts +98 -0
  55. package/src/slack/thread-buffer.ts +5 -3
  56. package/src/tests/claude-adapter-binary.test.ts +147 -4
  57. package/src/tests/claude-adapter-otel.test.ts +85 -1
  58. package/src/tests/db-query.test.ts +28 -0
  59. package/src/tests/error-tracker.test.ts +121 -0
  60. package/src/tests/harness-provider-resolution.test.ts +33 -0
  61. package/src/tests/hook-registration-nudge.test.ts +69 -0
  62. package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
  63. package/src/tests/mcp-tools.test.ts +6 -0
  64. package/src/tests/pages-public-html.test.ts +41 -0
  65. package/src/tests/pages-public-json-redirect.test.ts +37 -2
  66. package/src/tests/profile-sync.test.ts +282 -0
  67. package/src/tests/prompt-template-session.test.ts +34 -5
  68. package/src/tests/script-runs-http.test.ts +278 -0
  69. package/src/tests/script-workflows-label-lint.test.ts +43 -0
  70. package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
  71. package/src/tests/scripts-mcp-e2e.test.ts +49 -2
  72. package/src/tests/scripts-runtime.test.ts +33 -0
  73. package/src/tests/seed-scripts.test.ts +347 -2
  74. package/src/tests/slack-message-text.test.ts +250 -0
  75. package/src/tests/system-default-skills.test.ts +40 -0
  76. package/src/tools/create-metric.ts +2 -3
  77. package/src/tools/create-page.ts +3 -6
  78. package/src/tools/db-query.ts +16 -6
  79. package/src/tools/memory-rate.ts +2 -1
  80. package/src/tools/memory-search.ts +1 -0
  81. package/src/tools/register-kapso-number.ts +2 -4
  82. package/src/tools/request-human-input.ts +2 -1
  83. package/src/tools/script-common.ts +2 -4
  84. package/src/tools/script-run.ts +7 -0
  85. package/src/tools/script-runs.ts +123 -0
  86. package/src/tools/slack-read.ts +12 -3
  87. package/src/tools/tool-config.ts +4 -1
  88. package/src/types.ts +52 -0
  89. package/src/utils/constants.ts +58 -8
  90. package/src/utils/error-tracker.ts +40 -1
  91. package/src/utils/internal-ai/complete-structured.ts +10 -4
  92. package/src/workflows/executors/raw-llm.ts +76 -59
  93. package/templates/skills/pages/content.md +205 -55
  94. package/templates/skills/script-workflows/config.json +14 -0
  95. package/templates/skills/script-workflows/content.md +68 -0
  96. package/templates/skills/swarm-scripts/content.md +45 -7
@@ -5,6 +5,7 @@ import { assertSelectOnlyQuery } from "@/http/db-query";
5
5
  import { snapshotMetric } from "@/metrics/version";
6
6
  import { createToolRegistrar } from "@/tools/utils";
7
7
  import { MetricDefinitionSchema } from "@/types";
8
+ import { getAppUrl } from "@/utils/constants";
8
9
 
9
10
  function slugify(input: string): string {
10
11
  const slug = input
@@ -16,9 +17,7 @@ function slugify(input: string): string {
16
17
  }
17
18
 
18
19
  function getAppBaseUrl(): string {
19
- const env = process.env.APP_URL?.trim();
20
- if (env) return env.replace(/\/+$/, "");
21
- return "http://localhost:5274";
20
+ return getAppUrl();
22
21
  }
23
22
 
24
23
  function metricEditCounter(metricId: string): number {
@@ -23,6 +23,7 @@ import { createPage, getPage, getPageBySlug, getPageVersions, updatePage } from
23
23
  import { snapshotPage } from "@/pages/version";
24
24
  import { createToolRegistrar } from "@/tools/utils";
25
25
  import { PageAuthModeSchema, PageContentTypeSchema } from "@/types";
26
+ import { getAppUrl, getPublicMcpBaseUrl } from "@/utils/constants";
26
27
 
27
28
  /** Same slugifier used by the HTTP createPage handler. */
28
29
  function slugify(input: string): string {
@@ -35,15 +36,11 @@ function slugify(input: string): string {
35
36
  }
36
37
 
37
38
  function getApiBaseUrl(): string {
38
- const env = process.env.MCP_BASE_URL?.trim();
39
- if (env) return env.replace(/\/+$/, "");
40
- return `http://localhost:${process.env.PORT || "3013"}`;
39
+ return getPublicMcpBaseUrl();
41
40
  }
42
41
 
43
42
  function getAppBaseUrl(): string {
44
- const env = process.env.APP_URL?.trim();
45
- if (env) return env.replace(/\/+$/, "");
46
- return "http://localhost:5274";
43
+ return getAppUrl();
47
44
  }
48
45
 
49
46
  /**
@@ -1,10 +1,21 @@
1
1
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
- import { executeReadOnlyQuery } from "@/http/db-query";
3
+ import { DbQueryInputShape, executeReadOnlyQuery, resolveDbQuerySql } from "@/http/db-query";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
5
 
6
6
  const MCP_MAX_ROWS = 100;
7
7
 
8
+ const DbQueryToolInputSchema = z
9
+ .object({
10
+ ...DbQueryInputShape,
11
+ sql: z.string().optional().describe("SQL query (read-only only — writes are rejected)"),
12
+ query: z.string().optional().describe("Deprecated runtime alias for sql."),
13
+ params: z.array(z.any()).optional().default([]).describe("Query parameters"),
14
+ })
15
+ .refine((body) => body.sql !== undefined || body.query !== undefined, {
16
+ message: "Either sql or query is required",
17
+ });
18
+
8
19
  export const registerDbQueryTool = (server: McpServer) => {
9
20
  createToolRegistrar(server)(
10
21
  "db-query",
@@ -13,10 +24,7 @@ export const registerDbQueryTool = (server: McpServer) => {
13
24
  description:
14
25
  "Execute a read-only SQL query against the swarm database. Available to all authenticated agents — be aware results may include secrets (oauth_tokens, configs). Results capped at 100 rows.",
15
26
  annotations: { readOnlyHint: true },
16
- inputSchema: z.object({
17
- sql: z.string().describe("SQL query (read-only only — writes are rejected)"),
18
- params: z.array(z.any()).optional().default([]).describe("Query parameters"),
19
- }),
27
+ inputSchema: DbQueryToolInputSchema,
20
28
  outputSchema: z.object({
21
29
  success: z.boolean(),
22
30
  columns: z.array(z.string()),
@@ -26,8 +34,10 @@ export const registerDbQueryTool = (server: McpServer) => {
26
34
  truncated: z.boolean(),
27
35
  }),
28
36
  },
29
- async ({ sql, params }, _requestInfo, _meta) => {
37
+ async (input, _requestInfo, _meta) => {
30
38
  try {
39
+ const sql = resolveDbQuerySql(input);
40
+ const params = input.params ?? [];
31
41
  const result = executeReadOnlyQuery(sql, params, MCP_MAX_ROWS);
32
42
  const truncated = result.total > MCP_MAX_ROWS;
33
43
 
@@ -3,6 +3,7 @@ import * as z from "zod";
3
3
  import { REFERENCES_SOURCE_MAX_LENGTH, sanitizeReferencesSource } from "@/be/memory/raters/types";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
5
  import { getApiKey } from "@/utils/api-key";
6
+ import { getMcpBaseUrl } from "@/utils/constants";
6
7
 
7
8
  /**
8
9
  * Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-5.md §1
@@ -92,7 +93,7 @@ export const registerMemoryRateTool = (server: McpServer) => {
92
93
  cleanedReferencesSource = cleaned;
93
94
  }
94
95
 
95
- const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
96
+ const apiUrl = getMcpBaseUrl();
96
97
  const apiKey = getApiKey();
97
98
 
98
99
  const event = {
@@ -125,6 +125,7 @@ export const registerMemorySearchTool = (server: McpServer) => {
125
125
  scope: scope as "agent" | "swarm" | "all",
126
126
  limit,
127
127
  isLead,
128
+ source,
128
129
  });
129
130
 
130
131
  const mapped = recent.map((r) => ({
@@ -10,13 +10,11 @@ import {
10
10
  putKapsoNumberMapping,
11
11
  } from "@/integrations/kapso/config";
12
12
  import { createToolRegistrar } from "@/tools/utils";
13
+ import { getPublicMcpBaseUrl } from "@/utils/constants";
13
14
 
14
15
  /** Build the native inbound webhook URL the swarm exposes for Kapso deliveries. */
15
16
  function nativeWebhookUrl(): string {
16
- const base = (
17
- process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`
18
- ).replace(/\/+$/, "");
19
- return `${base}/api/integrations/kapso/webhook`;
17
+ return `${getPublicMcpBaseUrl()}/api/integrations/kapso/webhook`;
20
18
  }
21
19
 
22
20
  export const registerRegisterKapsoNumberTool = (server: McpServer) => {
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import { createApprovalRequest, getAgentCurrentTask } from "@/be/db";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
+ import { getAppUrl } from "@/utils/constants";
5
6
 
6
7
  const QuestionSchema = z.object({
7
8
  id: z.string().describe("Unique ID for the question (used as key in responses)"),
@@ -94,7 +95,7 @@ export const registerRequestHumanInputTool = (server: McpServer) => {
94
95
  timeoutSeconds,
95
96
  });
96
97
 
97
- const appUrl = process.env.APP_URL || "http://localhost:5274";
98
+ const appUrl = getAppUrl();
98
99
  const url = `${appUrl}/approval-requests/${request.id}`;
99
100
 
100
101
  return {
@@ -1,6 +1,7 @@
1
1
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
2
  import * as z from "zod";
3
3
  import { getApiKey } from "@/utils/api-key";
4
+ import { getMcpBaseUrl } from "@/utils/constants";
4
5
  import type { RequestInfo } from "./utils";
5
6
 
6
7
  export const SCRIPT_TRANSPORT_ERROR =
@@ -20,10 +21,7 @@ export const scriptToolOutputSchema = z.object({
20
21
  export type ScriptToolStructuredContent = z.infer<typeof scriptToolOutputSchema>;
21
22
 
22
23
  function apiBaseUrl(): string {
23
- return (process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`).replace(
24
- /\/+$/,
25
- "",
26
- );
24
+ return getMcpBaseUrl();
27
25
  }
28
26
 
29
27
  function toolError(message: string, status = 400): CallToolResult {
@@ -28,6 +28,13 @@ export const registerScriptRunTool = (server: McpServer) => {
28
28
  fsMode: scriptFsModeSchema
29
29
  .default("none")
30
30
  .describe("Filesystem mode. v1 supports none only."),
31
+ idempotencyKey: z
32
+ .string()
33
+ .max(200)
34
+ .optional()
35
+ .describe(
36
+ "When set, output is auto-persisted to kv under script:executions/{key}. Re-running with the same key overwrites. Queryable via kv-get.",
37
+ ),
31
38
  }),
32
39
  outputSchema: scriptToolOutputSchema,
33
40
  },
@@ -0,0 +1,123 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import * as z from "zod";
3
+ import { createToolRegistrar } from "@/tools/utils";
4
+ import { ScriptRunStatusSchema } from "@/types";
5
+ import { proxyScriptsApi, scriptNameSchema, scriptToolOutputSchema } from "./script-common";
6
+
7
+ export const LAUNCH_SCRIPT_RUN_DESCRIPTION =
8
+ "Launch a durable one-off script workflow run. The run executes in the background and can be inspected with get-script-run for terminal status and journal entries.";
9
+
10
+ export const GET_SCRIPT_RUN_DESCRIPTION =
11
+ "Get a durable script workflow run by ID, including its journal entries for swarm-script, raw-llm, and agent-task steps.";
12
+
13
+ export const LIST_SCRIPT_RUNS_DESCRIPTION =
14
+ "List durable script workflow runs, optionally filtered by status or agent ID.";
15
+
16
+ export const registerScriptRunsTools = (server: McpServer) => {
17
+ const register = createToolRegistrar(server);
18
+
19
+ register(
20
+ "launch-script-run",
21
+ {
22
+ title: "Launch Script Run",
23
+ description: LAUNCH_SCRIPT_RUN_DESCRIPTION,
24
+ annotations: { openWorldHint: true },
25
+ inputSchema: z.object({
26
+ source: z.string().min(1).describe("TypeScript script workflow source."),
27
+ args: z.unknown().optional().describe("JSON-serializable workflow arguments."),
28
+ idempotencyKey: z
29
+ .string()
30
+ .min(1)
31
+ .max(200)
32
+ .optional()
33
+ .describe("Optional key that returns the existing run instead of launching a duplicate."),
34
+ scriptName: scriptNameSchema
35
+ .optional()
36
+ .describe("Optional human-readable script/workflow name for the run."),
37
+ requestedByUserId: z
38
+ .string()
39
+ .optional()
40
+ .describe("Optional canonical user ID to attribute the run to."),
41
+ }),
42
+ outputSchema: scriptToolOutputSchema,
43
+ },
44
+ async (args, requestInfo) =>
45
+ proxyScriptsApi({
46
+ method: "POST",
47
+ path: "/api/script-runs",
48
+ body: { ...args, background: true },
49
+ requestInfo,
50
+ successMessage: (data) => {
51
+ const id =
52
+ typeof data === "object" && data !== null && "id" in data
53
+ ? String((data as { id: unknown }).id)
54
+ : "unknown";
55
+ return `Script run launched: ${id}.`;
56
+ },
57
+ }),
58
+ );
59
+
60
+ register(
61
+ "get-script-run",
62
+ {
63
+ title: "Get Script Run",
64
+ description: GET_SCRIPT_RUN_DESCRIPTION,
65
+ annotations: { readOnlyHint: true, openWorldHint: false },
66
+ inputSchema: z.object({
67
+ id: z.string().uuid().describe("Script run ID."),
68
+ }),
69
+ outputSchema: scriptToolOutputSchema,
70
+ },
71
+ async ({ id }, requestInfo) =>
72
+ proxyScriptsApi({
73
+ method: "GET",
74
+ path: `/api/script-runs/${encodeURIComponent(id)}`,
75
+ requestInfo,
76
+ successMessage: (data) => {
77
+ const status =
78
+ typeof data === "object" &&
79
+ data !== null &&
80
+ "run" in data &&
81
+ typeof (data as { run?: { status?: unknown } }).run?.status === "string"
82
+ ? (data as { run: { status: string } }).run.status
83
+ : "unknown";
84
+ return `Script run ${id} status: ${status}.`;
85
+ },
86
+ }),
87
+ );
88
+
89
+ register(
90
+ "list-script-runs",
91
+ {
92
+ title: "List Script Runs",
93
+ description: LIST_SCRIPT_RUNS_DESCRIPTION,
94
+ annotations: { readOnlyHint: true, openWorldHint: false },
95
+ inputSchema: z.object({
96
+ status: ScriptRunStatusSchema.optional().describe("Optional script run status filter."),
97
+ agentId: z.string().optional().describe("Optional agent ID filter."),
98
+ limit: z.number().int().min(1).max(500).default(50).describe("Maximum runs to return."),
99
+ offset: z.number().int().min(0).default(0).describe("Pagination offset."),
100
+ }),
101
+ outputSchema: scriptToolOutputSchema,
102
+ },
103
+ async ({ status, agentId, limit, offset }, requestInfo) => {
104
+ const params = new URLSearchParams();
105
+ if (status) params.set("status", status);
106
+ if (agentId) params.set("agentId", agentId);
107
+ params.set("limit", String(limit));
108
+ params.set("offset", String(offset));
109
+ return proxyScriptsApi({
110
+ method: "GET",
111
+ path: `/api/script-runs?${params.toString()}`,
112
+ requestInfo,
113
+ successMessage: (data) => {
114
+ const total =
115
+ typeof data === "object" && data !== null && "total" in data
116
+ ? Number((data as { total: unknown }).total)
117
+ : 0;
118
+ return `Found ${Number.isFinite(total) ? total : 0} script run(s).`;
119
+ },
120
+ });
121
+ },
122
+ );
123
+ };
@@ -3,6 +3,7 @@ import * as z from "zod";
3
3
  import { getAgentById, getInboxMessageById, getTaskById } from "@/be/db";
4
4
  import { getSlackApp } from "@/slack/app";
5
5
  import { downloadFile } from "@/slack/files";
6
+ import { extractSlackMessageText } from "@/slack/message-text";
6
7
  import { createToolRegistrar } from "@/tools/utils";
7
8
 
8
9
  /**
@@ -203,6 +204,13 @@ export const registerSlackReadTool = (server: McpServer) => {
203
204
  text?: string;
204
205
  ts: string;
205
206
  files?: RawFile[];
207
+ attachments?: Array<{
208
+ fallback?: string;
209
+ text?: string;
210
+ title?: string;
211
+ pretext?: string;
212
+ }>;
213
+ blocks?: unknown[];
206
214
  };
207
215
 
208
216
  let rawMessages: RawMessage[] = [];
@@ -267,8 +275,9 @@ export const registerSlackReadTool = (server: McpServer) => {
267
275
  }> = [];
268
276
 
269
277
  for (const m of rawMessages) {
270
- // Include messages with text OR files
271
- if (!m.text && (!m.files || m.files.length === 0)) continue;
278
+ // Include messages with text, attachments, blocks, or files
279
+ const extractedText = extractSlackMessageText(m);
280
+ if (!extractedText && (!m.files || m.files.length === 0)) continue;
272
281
 
273
282
  const isBot =
274
283
  m.user === botUserId || m.bot_id !== undefined || m.subtype === "bot_message";
@@ -330,7 +339,7 @@ export const registerSlackReadTool = (server: McpServer) => {
330
339
  user: m.user,
331
340
  username,
332
341
  isBot,
333
- text: m.text || "",
342
+ text: extractedText,
334
343
  ts: m.ts,
335
344
  files,
336
345
  });
@@ -164,12 +164,15 @@ export const DEFERRED_TOOLS = new Set([
164
164
  "kv-incr",
165
165
  "kv-list",
166
166
 
167
- // Reusable scripts (5)
167
+ // Reusable scripts (8)
168
168
  "script-search",
169
169
  "script-run",
170
170
  "script-upsert",
171
171
  "script-delete",
172
172
  "script-query-types",
173
+ "launch-script-run",
174
+ "get-script-run",
175
+ "list-script-runs",
173
176
 
174
177
  // External command routes (1)
175
178
  "swarm_x",
package/src/types.ts CHANGED
@@ -1535,6 +1535,58 @@ export const WorkflowRunSchema = z.object({
1535
1535
  });
1536
1536
  export type WorkflowRun = z.infer<typeof WorkflowRunSchema>;
1537
1537
 
1538
+ // --- Script Workflow Runs ---
1539
+
1540
+ export const ScriptRunStatusSchema = z.enum([
1541
+ "running",
1542
+ "paused",
1543
+ "completed",
1544
+ "failed",
1545
+ "cancelled",
1546
+ "aborted_limit",
1547
+ ]);
1548
+ export type ScriptRunStatus = z.infer<typeof ScriptRunStatusSchema>;
1549
+
1550
+ export const TERMINAL_SCRIPT_RUN_STATUSES = [
1551
+ "completed",
1552
+ "failed",
1553
+ "cancelled",
1554
+ "aborted_limit",
1555
+ ] as const;
1556
+ export type TerminalScriptRunStatus = (typeof TERMINAL_SCRIPT_RUN_STATUSES)[number];
1557
+
1558
+ export const ScriptRunSchema = z.object({
1559
+ id: z.string().uuid(),
1560
+ agentId: z.string(),
1561
+ scriptName: z.string().optional(),
1562
+ source: z.string(),
1563
+ args: z.unknown(),
1564
+ status: ScriptRunStatusSchema,
1565
+ pid: z.number().int().optional(),
1566
+ startedAt: z.string(),
1567
+ finishedAt: z.string().optional(),
1568
+ output: z.unknown().optional(),
1569
+ error: z.string().optional(),
1570
+ lastHeartbeatAt: z.string().optional(),
1571
+ idempotencyKey: z.string().optional(),
1572
+ requestedByUserId: z.string().optional(),
1573
+ });
1574
+ export type ScriptRun = z.infer<typeof ScriptRunSchema>;
1575
+
1576
+ export const ScriptRunJournalEntrySchema = z.object({
1577
+ id: z.string().uuid(),
1578
+ runId: z.string().uuid(),
1579
+ stepKey: z.string(),
1580
+ stepType: z.string(),
1581
+ config: z.record(z.string(), z.unknown()),
1582
+ status: z.enum(["completed", "failed"]),
1583
+ result: z.unknown().optional(),
1584
+ error: z.string().optional(),
1585
+ startedAt: z.string(),
1586
+ completedAt: z.string().optional(),
1587
+ });
1588
+ export type ScriptRunJournalEntry = z.infer<typeof ScriptRunJournalEntrySchema>;
1589
+
1538
1590
  // --- Workflow Run Step ---
1539
1591
 
1540
1592
  export const WorkflowRunStepStatusSchema = z.enum([
@@ -3,19 +3,69 @@
3
3
  */
4
4
 
5
5
  /**
6
- * Default dashboard URL used when `APP_URL` is unset. Points at the public
7
- * production dashboard so links (Slack messages, approval URLs, etc.) are
8
- * always renderable. Self-hosted operators should set `APP_URL` to override.
6
+ * Default dashboard URL used when neither `APP_URL` nor the deprecated
7
+ * `DASHBOARD_URL` is set. Points at the public production dashboard so links
8
+ * (Slack messages, approval URLs, page share links, post-OAuth redirects)
9
+ * stay renderable even when an operator forgets to configure it. Local dev
10
+ * should set `APP_URL` (e.g. in `.env`) to point at the local dashboard.
9
11
  */
10
12
  export const DEFAULT_APP_URL = "https://app.agent-swarm.dev";
11
13
 
12
14
  /**
13
- * Resolve the effective app/dashboard URL from `APP_URL` (with trailing
14
- * slashes stripped), falling back to {@link DEFAULT_APP_URL}.
15
+ * Resolve every explicitly configured app/dashboard URL. Each env var may be
16
+ * a comma-separated origin list; entries are returned in precedence order with
17
+ * trailing slashes stripped.
18
+ *
19
+ * Precedence: `APP_URL` entries → `DASHBOARD_URL` entries (deprecated alias,
20
+ * kept for back-compat).
21
+ */
22
+ export function getConfiguredAppUrls(): string[] {
23
+ return [process.env.APP_URL, process.env.DASHBOARD_URL]
24
+ .flatMap((value) => (value ?? "").split(","))
25
+ .map((value) => value.trim().replace(/\/+$/, ""))
26
+ .filter(Boolean);
27
+ }
28
+
29
+ /**
30
+ * Resolve the effective app/dashboard URL — the public origin the user's
31
+ * browser is sent to (post-login redirects, Slack/approval links, page
32
+ * `app_url` share links). Trailing slashes are stripped.
33
+ *
34
+ * Precedence: first configured `APP_URL` entry → first configured
35
+ * `DASHBOARD_URL` entry (deprecated alias, kept for back-compat) → fallback.
36
+ * This is the single source of truth; call sites must not re-read
37
+ * `APP_URL`/`DASHBOARD_URL` directly.
38
+ */
39
+ export function getAppUrl(fallback = DEFAULT_APP_URL): string {
40
+ return (getConfiguredAppUrls()[0] || fallback).replace(/\/+$/, "");
41
+ }
42
+
43
+ /**
44
+ * Internal API/MCP base URL — how workers/agents and in-process callers reach
45
+ * the API server. May be a private/cluster address (e.g. the Helm ClusterIP
46
+ * `http://<release>-api:3013`). Do NOT use for browser-facing or
47
+ * externally-registered URLs (OAuth redirect URIs, webhook URLs): those must
48
+ * resolve to a host the browser / third party can reach — use
49
+ * {@link getPublicMcpBaseUrl} (no request context) or `deriveApiBaseUrl(req)`
50
+ * (request-scoped) instead. Trailing slashes are stripped.
51
+ */
52
+ export function getMcpBaseUrl(): string {
53
+ const raw = process.env.MCP_BASE_URL?.trim();
54
+ return (raw || `http://localhost:${process.env.PORT || "3013"}`).replace(/\/+$/, "");
55
+ }
56
+
57
+ /**
58
+ * Public, browser-/externally-reachable origin of the API server — where
59
+ * `/api/mcp-oauth/callback`, OAuth redirect URIs, and registered webhook URLs
60
+ * resolve. Falls back to {@link getMcpBaseUrl} when the public and internal
61
+ * hosts are the same (local dev, single-box, or an ngrok/tunnel set as
62
+ * `MCP_BASE_URL`). In split deployments (Helm), set `PUBLIC_MCP_BASE_URL` to
63
+ * the public ingress URL while `MCP_BASE_URL` stays the internal service
64
+ * address. Trailing slashes are stripped.
15
65
  */
16
- export function getAppUrl(): string {
17
- const raw = process.env.APP_URL?.trim();
18
- return (raw || DEFAULT_APP_URL).replace(/\/+$/, "");
66
+ export function getPublicMcpBaseUrl(): string {
67
+ const raw = process.env.PUBLIC_MCP_BASE_URL?.trim();
68
+ return raw ? raw.replace(/\/+$/, "") : getMcpBaseUrl();
19
69
  }
20
70
 
21
71
  /**
@@ -28,7 +28,46 @@ export const MAX_RATE_LIMIT_RESET_MS = 7 * 24 * 60 * 60 * 1000;
28
28
  * "429 Too Many Requests"; does not match "No conversation found with session ID".
29
29
  */
30
30
  export function isRateLimitMessage(s: string): boolean {
31
- return /rate.?limit|hit your[\w\s-]*limit|usage[ _-]?limit|too many requests|\b429\b/i.test(s);
31
+ return (
32
+ /rate.?limit|hit your[\w\s-]*limit|usage[ _-]?limit|too many requests|\b429\b/i.test(s) ||
33
+ isCodexCreditsExhaustedMessage(s)
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Detects Codex's workspace-credit-exhausted error, which surfaces as:
39
+ * "Your workspace is out of credits. Ask your workspace owner to refill in order to continue."
40
+ * This wording does not match the standard rate-limit patterns, so it needs its own predicate.
41
+ * Kept specific to avoid false positives — "refill" alone is intentionally excluded.
42
+ */
43
+ export function isCodexCreditsExhaustedMessage(s: string): boolean {
44
+ return /out of credits|refill in order to continue|workspace owner to refill/i.test(s);
45
+ }
46
+
47
+ /** Default cooldown applied when a Codex OAuth slot returns a credits-exhausted error.
48
+ * The workspace credit cap is weekly, so a 2-hour cooldown is conservative but avoids
49
+ * the sawtooth of the 5-minute tier-3 fallback re-handing the dead slot every 5 minutes.
50
+ */
51
+ export const CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS = 2 * 60 * 60 * 1000; // 2h
52
+
53
+ /** Floor for the operator-tunable Codex credits cooldown — never shorter than the tier-3 fallback. */
54
+ export const MIN_CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS = 5 * 60 * 1000; // 5m
55
+
56
+ /**
57
+ * Resolve the effective Codex credits-exhausted cooldown (ms) from a raw config
58
+ * value (string | number | undefined). Falls back to the default constant on
59
+ * absent / empty / non-finite / non-positive input, then clamps to
60
+ * [MIN_CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS, MAX_RATE_LIMIT_RESET_MS].
61
+ * Pure + side-effect free so it's unit-testable and cheap to call.
62
+ */
63
+ export function resolveCodexCreditsExhaustedCooldownMs(
64
+ raw: string | number | undefined | null,
65
+ ): number {
66
+ if (raw === undefined || raw === null || raw === "") return CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS;
67
+ const n =
68
+ typeof raw === "number" ? raw : /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN;
69
+ if (!Number.isFinite(n) || n <= 0) return CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS;
70
+ return Math.min(Math.max(n, MIN_CODEX_CREDITS_EXHAUSTED_COOLDOWN_MS), MAX_RATE_LIMIT_RESET_MS);
32
71
  }
33
72
 
34
73
  /**
@@ -84,10 +84,16 @@ async function defaultSpawnClaudeCli(
84
84
  signal?: AbortSignal,
85
85
  jsonSchema?: object,
86
86
  ): Promise<string> {
87
- // CLAUDE_BINARY may be a single binary ("claude", "shannon") or a
88
- // whitespace-separated command string ("bunx @dexh/shannon"). See
89
- // parseClaudeBinary in src/providers/claude-adapter.ts.
90
- const claudeBinaryArgv = (process.env.CLAUDE_BINARY ?? "claude").trim().split(/\s+/);
87
+ // SWARM_USE_CLAUDE_BRIDGE mirrors the main claude adapter's subscription-pool
88
+ // routing. Otherwise CLAUDE_BINARY may be a single binary ("claude", "shannon")
89
+ // or a whitespace-separated command string ("bunx @dexh/shannon").
90
+ const useClaudeBridge = ["true", "1"].includes(
91
+ (process.env.SWARM_USE_CLAUDE_BRIDGE ?? "").trim().toLowerCase(),
92
+ );
93
+ const claudeBinaryRaw = useClaudeBridge
94
+ ? "claude-bridge"
95
+ : (process.env.CLAUDE_BINARY ?? "claude").trim();
96
+ const claudeBinaryArgv = (claudeBinaryRaw || "claude").split(/\s+/);
91
97
  const cmd = [...claudeBinaryArgv, "-p", "--model", model, "--output-format", "json"];
92
98
  if (jsonSchema) {
93
99
  cmd.push("--json-schema", JSON.stringify(jsonSchema));