@desplega.ai/agent-swarm 1.61.0 → 1.63.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/README.md CHANGED
@@ -49,7 +49,7 @@ Agent Swarm lets you run a team of AI coding agents that coordinate autonomously
49
49
  - **Templates registry** — Pre-built agent templates (9 official: lead, coder, researcher, reviewer, tester, FDE, content-writer, content-reviewer, content-strategist) with a gallery UI and docker-compose builder
50
50
  - **GitLab integration** — Full GitLab webhook support alongside GitHub via provider adapter pattern
51
51
  - **Working directory support** — Tasks can specify a custom starting directory for agents via the `dir` parameter
52
- - **Multi-provider** — Run agents with Claude Code or pi-mono (`HARNESS_PROVIDER=claude|pi`)
52
+ - **Multi-provider** — Run agents with Claude Code, pi-mono, or OpenAI Codex (`HARNESS_PROVIDER=claude|pi|codex`)
53
53
  - **Agent-fs integration** — Persistent, searchable filesystem shared across the swarm with auto-registration on first boot
54
54
  - **Debug dashboard** — SQL query interface with Monaco editor and AG Grid results for database inspection
55
55
  - **Workflow engine** — DAG-based workflow automation with executor registry, checkpoint durability, webhook/schedule/manual triggers, per-step retry, structured I/O schemas, fan-out/convergence, configurable failure handling, and version history
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.61.0",
5
+ "version": "1.63.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": [
@@ -1677,6 +1677,71 @@
1677
1677
  }
1678
1678
  }
1679
1679
  },
1680
+ "/api/keys/name": {
1681
+ "patch": {
1682
+ "summary": "Set or clear the human-friendly label on a pooled credential",
1683
+ "tags": [
1684
+ "API Keys"
1685
+ ],
1686
+ "security": [
1687
+ {
1688
+ "bearerAuth": []
1689
+ }
1690
+ ],
1691
+ "requestBody": {
1692
+ "content": {
1693
+ "application/json": {
1694
+ "schema": {
1695
+ "type": "object",
1696
+ "properties": {
1697
+ "keyType": {
1698
+ "type": "string",
1699
+ "minLength": 1
1700
+ },
1701
+ "keySuffix": {
1702
+ "type": "string",
1703
+ "minLength": 1,
1704
+ "maxLength": 10
1705
+ },
1706
+ "name": {
1707
+ "type": [
1708
+ "string",
1709
+ "null"
1710
+ ],
1711
+ "maxLength": 60
1712
+ },
1713
+ "scope": {
1714
+ "type": "string"
1715
+ },
1716
+ "scopeId": {
1717
+ "type": "string"
1718
+ }
1719
+ },
1720
+ "required": [
1721
+ "keyType",
1722
+ "keySuffix",
1723
+ "name"
1724
+ ]
1725
+ }
1726
+ }
1727
+ }
1728
+ },
1729
+ "responses": {
1730
+ "200": {
1731
+ "description": "Name updated"
1732
+ },
1733
+ "400": {
1734
+ "description": "Validation error"
1735
+ },
1736
+ "401": {
1737
+ "description": "Unauthorized"
1738
+ },
1739
+ "404": {
1740
+ "description": "Key not found"
1741
+ }
1742
+ }
1743
+ }
1744
+ },
1680
1745
  "/api/events": {
1681
1746
  "post": {
1682
1747
  "summary": "Store a single event",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.61.0",
3
+ "version": "1.63.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>",
@@ -105,6 +105,7 @@
105
105
  "@mariozechner/pi-ai": "^0.57.1",
106
106
  "@mariozechner/pi-coding-agent": "^0.57.1",
107
107
  "@modelcontextprotocol/sdk": "^1.25.1",
108
+ "@openai/codex-sdk": "^0.118.0",
108
109
  "@openfort/openfort-node": "^0.9.1",
109
110
  "@slack/bolt": "^4.6.0",
110
111
  "@types/react": "^19.2.7",
package/src/be/db.ts CHANGED
@@ -58,6 +58,7 @@ import type {
58
58
  WorkflowSnapshot,
59
59
  WorkflowVersion,
60
60
  } from "../types";
61
+ import { deriveProviderFromKeyType } from "../utils/credentials";
61
62
  import { normalizeDate, normalizeDateRequired } from "./date-utils";
62
63
  import { runMigrations } from "./migrations/runner";
63
64
  import { seedDefaultTemplates } from "./seed";
@@ -7576,6 +7577,10 @@ export interface ApiKeyStatus {
7576
7577
  lastRateLimitAt: string | null;
7577
7578
  totalUsageCount: number;
7578
7579
  rateLimitCount: number;
7580
+ /** Optional human-friendly label set from the dashboard. */
7581
+ name: string | null;
7582
+ /** Auto-derived harness provider (claude/pi/codex) — see deriveProviderFromKeyType. */
7583
+ provider: string;
7579
7584
  createdAt: string;
7580
7585
  updatedAt: string;
7581
7586
  }
@@ -7634,17 +7639,21 @@ export function recordKeyUsage(
7634
7639
  const db = getDb();
7635
7640
  const effectiveScopeId = scopeId ?? "";
7636
7641
 
7637
- // Upsert key status record
7642
+ // Upsert key status record. Sets `provider` on insert (auto-derived from
7643
+ // keyType — see deriveProviderFromKeyType in src/utils/credentials.ts).
7644
+ // The `name` column is left null on insert and only set via the
7645
+ // setApiKeyName API endpoint when the user manually labels the key.
7646
+ const provider = deriveProviderFromKeyType(keyType);
7638
7647
  db.prepare(
7639
- `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, lastUsedAt, totalUsageCount, updatedAt)
7640
- VALUES (?, ?, ?, ?, ?, ?, 1, ?)
7648
+ `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, lastUsedAt, totalUsageCount, provider, updatedAt)
7649
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
7641
7650
  ON CONFLICT(keyType, keySuffix, scope, scopeId)
7642
7651
  DO UPDATE SET
7643
7652
  lastUsedAt = excluded.lastUsedAt,
7644
7653
  totalUsageCount = totalUsageCount + 1,
7645
7654
  keyIndex = excluded.keyIndex,
7646
7655
  updatedAt = excluded.updatedAt`,
7647
- ).run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, now, now);
7656
+ ).run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, now, provider, now);
7648
7657
 
7649
7658
  // Record which key was used on the task
7650
7659
  if (taskId) {
@@ -7667,10 +7676,11 @@ export function markKeyRateLimited(
7667
7676
  ): void {
7668
7677
  const now = new Date().toISOString();
7669
7678
  const effectiveScopeId = scopeId ?? "";
7679
+ const provider = deriveProviderFromKeyType(keyType);
7670
7680
  getDb()
7671
7681
  .prepare(
7672
- `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, status, rateLimitedUntil, lastRateLimitAt, rateLimitCount, updatedAt)
7673
- VALUES (?, ?, ?, ?, ?, 'rate_limited', ?, ?, 1, ?)
7682
+ `INSERT INTO api_key_status (keyType, keySuffix, keyIndex, scope, scopeId, status, rateLimitedUntil, lastRateLimitAt, rateLimitCount, provider, updatedAt)
7683
+ VALUES (?, ?, ?, ?, ?, 'rate_limited', ?, ?, 1, ?, ?)
7674
7684
  ON CONFLICT(keyType, keySuffix, scope, scopeId)
7675
7685
  DO UPDATE SET
7676
7686
  status = 'rate_limited',
@@ -7680,7 +7690,39 @@ export function markKeyRateLimited(
7680
7690
  keyIndex = excluded.keyIndex,
7681
7691
  updatedAt = excluded.updatedAt`,
7682
7692
  )
7683
- .run(keyType, keySuffix, keyIndex, scope, effectiveScopeId, rateLimitedUntil, now, now);
7693
+ .run(
7694
+ keyType,
7695
+ keySuffix,
7696
+ keyIndex,
7697
+ scope,
7698
+ effectiveScopeId,
7699
+ rateLimitedUntil,
7700
+ now,
7701
+ provider,
7702
+ now,
7703
+ );
7704
+ }
7705
+
7706
+ /**
7707
+ * Set or clear the human-friendly `name` label on a pooled credential.
7708
+ * Identified by the natural key (keyType + keySuffix + scope + scopeId).
7709
+ * Returns true if a row was updated, false if no matching key exists.
7710
+ */
7711
+ export function setApiKeyName(
7712
+ keyType: string,
7713
+ keySuffix: string,
7714
+ name: string | null,
7715
+ scope = "global",
7716
+ scopeId: string | null = null,
7717
+ ): boolean {
7718
+ const result = getDb()
7719
+ .prepare(
7720
+ `UPDATE api_key_status
7721
+ SET name = ?, updatedAt = ?
7722
+ WHERE keyType = ? AND keySuffix = ? AND scope = ? AND scopeId = ?`,
7723
+ )
7724
+ .run(name, new Date().toISOString(), keyType, keySuffix, scope, scopeId ?? "");
7725
+ return result.changes > 0;
7684
7726
  }
7685
7727
 
7686
7728
  /**
@@ -0,0 +1,22 @@
1
+ -- Add user-facing `name` (manually settable from the dashboard) and
2
+ -- automatic `provider` (claude/pi/codex, derived from keyType) columns to
3
+ -- api_key_status. This lets users label their pooled credentials and lets
4
+ -- the dashboard / runner group keys by harness without re-deriving the
5
+ -- mapping at every read site.
6
+
7
+ ALTER TABLE api_key_status ADD COLUMN name TEXT;
8
+ ALTER TABLE api_key_status ADD COLUMN provider TEXT NOT NULL DEFAULT 'claude';
9
+
10
+ -- Backfill provider for existing rows based on the keyType, mirroring the
11
+ -- PROVIDER_CREDENTIAL_VARS mapping in src/utils/credentials.ts. ANTHROPIC_API_KEY
12
+ -- is shared between claude and pi-mono — we default it to claude (the primary
13
+ -- consumer) since the runner sets the provider on every subsequent usage.
14
+ UPDATE api_key_status SET provider = 'claude'
15
+ WHERE keyType IN ('CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY');
16
+ UPDATE api_key_status SET provider = 'pi'
17
+ WHERE keyType = 'OPENROUTER_API_KEY';
18
+ UPDATE api_key_status SET provider = 'codex'
19
+ WHERE keyType = 'OPENAI_API_KEY';
20
+
21
+ CREATE INDEX IF NOT EXISTS idx_api_key_status_provider
22
+ ON api_key_status(provider);
@@ -232,7 +232,15 @@ async function fetchResolvedEnv(
232
232
  }
233
233
  }
234
234
 
235
- const credentialSelections = await resolveCredentialPools(env, { apiUrl, apiKey });
235
+ const credentialSelections = await resolveCredentialPools(env, {
236
+ apiUrl,
237
+ apiKey,
238
+ // Provider-aware selection: codex tasks should not get a
239
+ // CLAUDE_CODE_OAUTH_TOKEN stamped on their task record (and vice
240
+ // versa) just because both env vars happen to be set in the worker
241
+ // container. See `PROVIDER_CREDENTIAL_VARS` in src/utils/credentials.ts.
242
+ provider: process.env.HARNESS_PROVIDER,
243
+ });
236
244
 
237
245
  return { env, credentialSelections };
238
246
  }
@@ -6,6 +6,7 @@ import {
6
6
  getKeyStatuses,
7
7
  markKeyRateLimited,
8
8
  recordKeyUsage,
9
+ setApiKeyName,
9
10
  } from "../be/db";
10
11
  import { route } from "./route-def";
11
12
  import { json, jsonError } from "./utils";
@@ -110,6 +111,29 @@ const getCosts = route({
110
111
  auth: { apiKey: true },
111
112
  });
112
113
 
114
+ const setKeyName = route({
115
+ method: "patch",
116
+ path: "/api/keys/name",
117
+ pattern: ["api", "keys", "name"],
118
+ summary: "Set or clear the human-friendly label on a pooled credential",
119
+ tags: ["API Keys"],
120
+ body: z.object({
121
+ keyType: z.string().min(1),
122
+ keySuffix: z.string().min(1).max(10),
123
+ /** Pass null or empty string to clear the existing label. */
124
+ name: z.string().max(60).nullable(),
125
+ scope: z.string().optional(),
126
+ scopeId: z.string().optional(),
127
+ }),
128
+ responses: {
129
+ 200: { description: "Name updated" },
130
+ 400: { description: "Validation error" },
131
+ 401: { description: "Unauthorized" },
132
+ 404: { description: "Key not found" },
133
+ },
134
+ auth: { apiKey: true },
135
+ });
136
+
113
137
  // ─── Handler ─────────────────────────────────────────────────────────────────
114
138
 
115
139
  export async function handleApiKeys(
@@ -196,5 +220,27 @@ export async function handleApiKeys(
196
220
  return true;
197
221
  }
198
222
 
223
+ // PATCH /api/keys/name
224
+ if (setKeyName.match(req.method, pathSegments)) {
225
+ const parsed = await setKeyName.parse(req, res, pathSegments, queryParams);
226
+ if (!parsed) return true;
227
+
228
+ const { keyType, keySuffix, name, scope, scopeId } = parsed.body;
229
+ try {
230
+ // Empty string is treated as "clear the label" so the dashboard's
231
+ // contenteditable can submit "" without sending an explicit null.
232
+ const value = name === "" ? null : name;
233
+ const updated = setApiKeyName(keyType, keySuffix, value, scope, scopeId ?? null);
234
+ if (!updated) {
235
+ jsonError(res, `No key matching ${keyType} ...${keySuffix}`, 404);
236
+ return true;
237
+ }
238
+ json(res, { success: true, keyType, keySuffix, name: value });
239
+ } catch (err) {
240
+ jsonError(res, err instanceof Error ? err.message : "Failed to set key name", 500);
241
+ }
242
+ return true;
243
+ }
244
+
199
245
  return false;
200
246
  }
package/src/http/index.ts CHANGED
@@ -154,6 +154,12 @@ async function shutdown() {
154
154
  // Stop Slack bot
155
155
  await stopSlackApp();
156
156
 
157
+ // Stop OAuth keepalive
158
+ if (process.env.OAUTH_KEEPALIVE_DISABLE !== "true") {
159
+ const { stopOAuthKeepalive } = await import("../oauth/keepalive");
160
+ stopOAuthKeepalive();
161
+ }
162
+
157
163
  // Close all active transports (SSE connections, etc.)
158
164
  for (const [id, transport] of Object.entries(transports)) {
159
165
  console.log(`[HTTP] Closing transport ${id}`);
@@ -242,6 +248,12 @@ httpServer
242
248
  const heartbeatMs = Number(process.env.HEARTBEAT_INTERVAL_MS) || 90000;
243
249
  startHeartbeat(heartbeatMs);
244
250
  }
251
+
252
+ // Start OAuth token keepalive (proactive refresh to prevent expiry)
253
+ if (process.env.OAUTH_KEEPALIVE_DISABLE !== "true") {
254
+ const { startOAuthKeepalive } = await import("../oauth/keepalive");
255
+ startOAuthKeepalive();
256
+ }
245
257
  })
246
258
  .on("error", (err) => {
247
259
  console.error("HTTP Server Error:", err);
@@ -174,14 +174,25 @@ export async function handleMcpServers(
174
174
  const resolvedEnv: Record<string, string> = {};
175
175
  const resolvedHeaders: Record<string, string> = {};
176
176
 
177
- // Resolve env config keys (JSON object: {"ENV_VAR": "config-key-name"})
177
+ // Resolve env config keys
178
+ // Supports both array format ["KEY_A", "KEY_B"] (key = config key = element)
179
+ // and object format {"ENV_VAR": "config-key-name"}
178
180
  if (server.envConfigKeys) {
179
181
  try {
180
- const mapping = JSON.parse(server.envConfigKeys) as Record<string, string>;
181
- for (const [envVar, configKey] of Object.entries(mapping)) {
182
- const value = configMap.get(configKey);
183
- if (value !== undefined) {
184
- resolvedEnv[envVar] = value;
182
+ const parsed = JSON.parse(server.envConfigKeys);
183
+ if (Array.isArray(parsed)) {
184
+ for (const key of parsed) {
185
+ const value = configMap.get(key);
186
+ if (value !== undefined) {
187
+ resolvedEnv[key] = value;
188
+ }
189
+ }
190
+ } else {
191
+ for (const [envVar, configKey] of Object.entries(parsed as Record<string, string>)) {
192
+ const value = configMap.get(configKey);
193
+ if (value !== undefined) {
194
+ resolvedEnv[envVar] = value;
195
+ }
185
196
  }
186
197
  }
187
198
  } catch {
@@ -189,14 +200,26 @@ export async function handleMcpServers(
189
200
  }
190
201
  }
191
202
 
192
- // Resolve header config keys (JSON object: {"Header-Name": "config-key-name"})
203
+ // Resolve header config keys
204
+ // Supports both array format ["Header-A", "Header-B"] and object format {"Header-Name": "config-key-name"}
193
205
  if (server.headerConfigKeys) {
194
206
  try {
195
- const mapping = JSON.parse(server.headerConfigKeys) as Record<string, string>;
196
- for (const [headerName, configKey] of Object.entries(mapping)) {
197
- const value = configMap.get(configKey);
198
- if (value !== undefined) {
199
- resolvedHeaders[headerName] = value;
207
+ const parsed = JSON.parse(server.headerConfigKeys);
208
+ if (Array.isArray(parsed)) {
209
+ for (const key of parsed) {
210
+ const value = configMap.get(key);
211
+ if (value !== undefined) {
212
+ resolvedHeaders[key] = value;
213
+ }
214
+ }
215
+ } else {
216
+ for (const [headerName, configKey] of Object.entries(
217
+ parsed as Record<string, string>,
218
+ )) {
219
+ const value = configMap.get(configKey);
220
+ if (value !== undefined) {
221
+ resolvedHeaders[headerName] = value;
222
+ }
200
223
  }
201
224
  }
202
225
  } catch {
@@ -25,9 +25,13 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
25
25
  * Ensure a valid OAuth token exists for the given provider.
26
26
  * If the token is expiring soon, attempt to refresh it.
27
27
  * Call this before any API interaction with an OAuth-protected service.
28
+ *
29
+ * @param bufferMs - How far ahead to check for expiry. Default 5 min (reactive use).
30
+ * Keepalive callers should pass a larger value (e.g. 13h) to force
31
+ * a proactive refresh well before the token actually expires.
28
32
  */
29
- export async function ensureToken(provider: string): Promise<void> {
30
- if (!isTokenExpiringSoon(provider)) return;
33
+ export async function ensureToken(provider: string, bufferMs?: number): Promise<void> {
34
+ if (!isTokenExpiringSoon(provider, bufferMs)) return;
31
35
 
32
36
  const config = getOAuthConfig(provider);
33
37
  const tokens = getOAuthTokens(provider);
@@ -0,0 +1,76 @@
1
+ import { ensureToken } from "./ensure-token";
2
+
3
+ const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
4
+ const THIRTEEN_HOURS_MS = 13 * 60 * 60 * 1000;
5
+ const SLACK_ALERTS_CHANNEL = process.env.SLACK_ALERTS_CHANNEL || "C08JCRURPBV";
6
+
7
+ let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
8
+
9
+ /**
10
+ * Proactively refresh OAuth tokens on a schedule to prevent expiry.
11
+ * If refresh fails, posts a Slack notification so someone can re-auth manually.
12
+ */
13
+ async function runKeepalive(): Promise<void> {
14
+ console.log("[OAuth Keepalive] Running scheduled token refresh for linear...");
15
+ try {
16
+ await ensureToken("linear", THIRTEEN_HOURS_MS);
17
+ console.log("[OAuth Keepalive] linear token check completed successfully");
18
+ } catch (err) {
19
+ const message = err instanceof Error ? err.message : String(err);
20
+ console.error(`[OAuth Keepalive] Failed to refresh linear token: ${message}`);
21
+ await notifySlack(
22
+ `⚠️ *OAuth Keepalive Failed*\nProvider: \`linear\`\nError: ${message}\n\nManual re-authorization may be required.`,
23
+ );
24
+ }
25
+ }
26
+
27
+ async function notifySlack(text: string): Promise<void> {
28
+ try {
29
+ const { getSlackApp } = await import("../slack/app");
30
+ const app = getSlackApp();
31
+ if (!app) {
32
+ console.warn("[OAuth Keepalive] Slack not available, cannot send notification");
33
+ return;
34
+ }
35
+ await app.client.chat.postMessage({
36
+ channel: SLACK_ALERTS_CHANNEL,
37
+ text,
38
+ });
39
+ console.log("[OAuth Keepalive] Slack notification sent");
40
+ } catch (slackErr) {
41
+ console.error(
42
+ "[OAuth Keepalive] Failed to send Slack notification:",
43
+ slackErr instanceof Error ? slackErr.message : slackErr,
44
+ );
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Start the OAuth keepalive timer. Runs immediately then every 12 hours.
50
+ */
51
+ export function startOAuthKeepalive(): void {
52
+ if (keepaliveInterval) {
53
+ console.log("[OAuth Keepalive] Already running, skipping");
54
+ return;
55
+ }
56
+
57
+ console.log("[OAuth Keepalive] Starting (12h interval)");
58
+
59
+ // Run once after a short delay (let server finish startup)
60
+ setTimeout(() => runKeepalive(), 10_000);
61
+
62
+ keepaliveInterval = setInterval(() => {
63
+ runKeepalive();
64
+ }, TWELVE_HOURS_MS);
65
+ }
66
+
67
+ /**
68
+ * Stop the OAuth keepalive timer.
69
+ */
70
+ export function stopOAuthKeepalive(): void {
71
+ if (keepaliveInterval) {
72
+ clearInterval(keepaliveInterval);
73
+ keepaliveInterval = null;
74
+ console.log("[OAuth Keepalive] Stopped");
75
+ }
76
+ }