@desplega.ai/agent-swarm 1.92.2 → 1.93.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 (76) hide show
  1. package/openapi.json +63 -3
  2. package/package.json +5 -5
  3. package/src/be/db.ts +91 -6
  4. package/src/be/memory/boot-reembed.ts +0 -1
  5. package/src/be/memory/providers/sqlite-store.ts +42 -25
  6. package/src/be/memory/raters/llm-client.ts +12 -5
  7. package/src/be/memory/types.ts +3 -0
  8. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  9. package/src/be/migrations/089_harness_variant.sql +2 -0
  10. package/src/be/modelsdev-cache.json +1222 -986
  11. package/src/be/seed-pricing.ts +1 -0
  12. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  13. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  14. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  15. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  16. package/src/be/seed-scripts/index.ts +5 -5
  17. package/src/be/skill-sync.ts +28 -179
  18. package/src/commands/runner.ts +124 -7
  19. package/src/http/api-keys.ts +42 -0
  20. package/src/http/mcp-bridge.ts +1 -1
  21. package/src/http/memory.ts +23 -24
  22. package/src/http/tasks.ts +10 -6
  23. package/src/providers/claude-adapter.ts +33 -1
  24. package/src/providers/claude-managed-adapter.ts +3 -0
  25. package/src/providers/claude-managed-models.ts +7 -0
  26. package/src/providers/codex-adapter.ts +8 -1
  27. package/src/providers/codex-models.ts +1 -0
  28. package/src/providers/codex-oauth/auth-json.ts +1 -0
  29. package/src/providers/harness-version.ts +7 -0
  30. package/src/providers/opencode-adapter.ts +11 -4
  31. package/src/providers/pi-mono-adapter.ts +12 -2
  32. package/src/providers/types.ts +2 -0
  33. package/src/scripts-runtime/egress-secrets.ts +83 -0
  34. package/src/scripts-runtime/eval-harness.ts +4 -0
  35. package/src/scripts-runtime/executors/types.ts +7 -0
  36. package/src/scripts-runtime/loader.ts +2 -0
  37. package/src/server-user.ts +2 -2
  38. package/src/slack/channel-join.ts +41 -0
  39. package/src/tests/additive-buffer.test.ts +0 -1
  40. package/src/tests/api-key-tracking.test.ts +113 -0
  41. package/src/tests/approval-requests.test.ts +0 -6
  42. package/src/tests/claude-managed-setup.test.ts +0 -4
  43. package/src/tests/codex-pool.test.ts +2 -6
  44. package/src/tests/http-api-integration.test.ts +4 -6
  45. package/src/tests/memory-edges.test.ts +0 -2
  46. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  47. package/src/tests/memory-rater-e2e.test.ts +0 -2
  48. package/src/tests/memory-store.test.ts +19 -1
  49. package/src/tests/memory.test.ts +51 -0
  50. package/src/tests/model-control.test.ts +1 -1
  51. package/src/tests/reload-config.test.ts +33 -17
  52. package/src/tests/runner-skills-refresh.test.ts +216 -46
  53. package/src/tests/script-runs-http.test.ts +7 -1
  54. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  55. package/src/tests/seed-scripts.test.ts +13 -1
  56. package/src/tests/session-attach.test.ts +6 -6
  57. package/src/tests/skill-fs-writer.test.ts +250 -0
  58. package/src/tests/slack-attachments-block.test.ts +0 -1
  59. package/src/tests/slack-blocks.test.ts +0 -1
  60. package/src/tests/slack-channel-join.test.ts +80 -0
  61. package/src/tests/slack-identity-resolution.test.ts +0 -1
  62. package/src/tests/structured-output.test.ts +0 -2
  63. package/src/tests/use-dismissible-card.test.ts +0 -4
  64. package/src/tools/schedules/create-schedule.ts +2 -2
  65. package/src/tools/schedules/update-schedule.ts +1 -1
  66. package/src/tools/send-task.ts +2 -2
  67. package/src/tools/slack-post.ts +18 -15
  68. package/src/tools/slack-read.ts +9 -11
  69. package/src/tools/slack-reply.ts +18 -15
  70. package/src/tools/slack-start-thread.ts +17 -14
  71. package/src/tools/task-action.ts +2 -2
  72. package/src/types.ts +11 -0
  73. package/src/utils/context-window.ts +3 -0
  74. package/src/utils/credentials.ts +22 -2
  75. package/src/utils/skill-fs-writer.ts +220 -0
  76. package/src/utils/skills-refresh.ts +123 -40
@@ -6,85 +6,19 @@
6
6
  * so Claude Code, Pi, and Codex discover them natively.
7
7
  *
8
8
  * This runs on the API side — workers call it via POST /api/skills/sync-filesystem.
9
+ * The actual FS write logic lives in the worker-safe src/utils/skill-fs-writer.ts
10
+ * so workers can also call it locally with their own homedir().
9
11
  */
10
12
 
11
- import type { Dirent } from "node:fs";
12
- import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
- import { dirname, join } from "node:path";
14
+ import {
15
+ type SkillFsEntry,
16
+ type SkillSyncResult,
17
+ writeSkillsToFilesystem,
18
+ } from "../utils/skill-fs-writer";
15
19
  import { getAgentSkills, getSkillFiles } from "./db";
16
20
 
17
- export interface SkillSyncResult {
18
- synced: number;
19
- removed: number;
20
- errors: string[];
21
- }
22
-
23
- /**
24
- * Marker file written into every swarm-managed skill directory. Cleanup
25
- * only ever removes directories that contain this marker, so unrelated
26
- * personal skills the user installed via the harness's own tooling (e.g.
27
- * `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
28
- * untouched even when the API server shares a HOME with the worker (local
29
- * dev). See `~/.codex/skills` blast-radius note in PR #555.
30
- */
31
- const SWARM_MARKER_FILE = ".swarm-managed";
32
-
33
- function reconcileManagedSkillFiles(skillDir: string, currentRelativeFiles: Set<string>): number {
34
- if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) return 0;
35
-
36
- let removed = 0;
37
-
38
- const walk = (dir: string, relativeDir = ""): boolean => {
39
- let entries: Dirent[];
40
- try {
41
- entries = readdirSync(dir, { withFileTypes: true });
42
- } catch {
43
- return false;
44
- }
45
-
46
- let hasEntries = false;
47
- for (const entry of entries) {
48
- const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
49
- const fullPath = join(dir, entry.name);
50
-
51
- if (entry.isDirectory()) {
52
- const childHasEntries = walk(fullPath, relativePath);
53
- if (!childHasEntries) {
54
- try {
55
- rmSync(fullPath, { recursive: true, force: true });
56
- } catch {
57
- hasEntries = true;
58
- }
59
- } else {
60
- hasEntries = true;
61
- }
62
- continue;
63
- }
64
-
65
- if (
66
- relativePath === "SKILL.md" ||
67
- relativePath === SWARM_MARKER_FILE ||
68
- currentRelativeFiles.has(relativePath)
69
- ) {
70
- hasEntries = true;
71
- continue;
72
- }
73
-
74
- try {
75
- rmSync(fullPath, { force: true });
76
- removed++;
77
- } catch {
78
- hasEntries = true;
79
- }
80
- }
81
-
82
- return hasEntries;
83
- };
84
-
85
- walk(skillDir);
86
- return removed;
87
- }
21
+ export type { SkillSyncResult };
88
22
 
89
23
  /**
90
24
  * Sync agent's installed skills to the filesystem.
@@ -92,6 +26,9 @@ function reconcileManagedSkillFiles(skillDir: string, currentRelativeFiles: Set<
92
26
  * For simple skills (content in DB): writes SKILL.md to ~/.claude/skills/<name>/
93
27
  * For DB-backed complex skills: writes SKILL.md plus bundled skill_files rows.
94
28
  * Legacy complex skills without skill_files remain handled by npx in entrypoint.
29
+ *
30
+ * API-side adapter: fetches skill data from DB, builds SkillFsEntry[], then
31
+ * delegates all FS writes to writeSkillsToFilesystem() from skill-fs-writer.ts.
95
32
  */
96
33
  export function syncSkillsToFilesystem(
97
34
  agentId: string,
@@ -100,112 +37,24 @@ export function syncSkillsToFilesystem(
100
37
  ): SkillSyncResult {
101
38
  const skills = getAgentSkills(agentId);
102
39
  const home = homeOverride ?? homedir();
103
- const errors: string[] = [];
104
- let synced = 0;
105
- let removed = 0;
106
-
107
- // Directories to write to
108
- const skillDirs: string[] = [];
109
- if (harnessType === "claude" || harnessType === "all") {
110
- skillDirs.push(join(home, ".claude", "skills"));
111
- }
112
- if (harnessType === "pi" || harnessType === "all") {
113
- skillDirs.push(join(home, ".pi", "agent", "skills"));
114
- }
115
- if (harnessType === "codex" || harnessType === "all") {
116
- skillDirs.push(join(home, ".codex", "skills"));
117
- }
118
-
119
- // Ensure base dirs exist
120
- for (const dir of skillDirs) {
121
- mkdirSync(dir, { recursive: true });
122
- }
123
-
124
- // Track which skill names we write (for cleanup)
125
- const writtenNames = new Set<string>();
126
-
127
- for (const skill of skills) {
128
- if (!skill.isActive || !skill.isEnabled) continue;
129
- const bundledFiles = skill.isComplex ? getSkillFiles(skill.id) : [];
130
- if (skill.isComplex && bundledFiles.length === 0) continue; // Legacy complex skills handled by npx
131
- if (!skill.content) continue;
132
-
133
- // Sanitize skill name to prevent path traversal (strip /, .., and non-safe chars)
134
- const safeName = skill.name.replace(/[^a-zA-Z0-9_-]/g, "_");
135
- if (!safeName) continue;
136
-
137
- writtenNames.add(safeName);
138
- const currentBundledFilePaths = new Set(
139
- bundledFiles.filter((file) => !file.isBinary).map((file) => file.path),
140
- );
141
-
142
- for (const baseDir of skillDirs) {
143
- const skillDir = join(baseDir, safeName);
144
- const skillFile = join(skillDir, "SKILL.md");
145
- const markerFile = join(skillDir, SWARM_MARKER_FILE);
146
-
147
- try {
148
- mkdirSync(skillDir, { recursive: true });
149
- removed += reconcileManagedSkillFiles(skillDir, currentBundledFilePaths);
150
- writeFileSync(skillFile, skill.content, "utf-8");
151
- writeFileSync(markerFile, "", "utf-8");
152
- synced++;
153
- } catch (err) {
154
- const msg = err instanceof Error ? err.message : "Unknown error";
155
- errors.push(`${skill.name} -> ${skillDir}: ${msg}`);
156
- console.error(
157
- `[skill-sync] Failed to write SKILL.md for ${skill.name} to ${skillDir}: ${msg}`,
158
- );
159
- }
160
-
161
- for (const file of bundledFiles) {
162
- if (file.isBinary) {
163
- console.log(`[skill-sync] Skipping binary skill file ${skill.name}/${file.path}`);
164
- continue;
165
- }
166
-
167
- const targetPath = join(skillDir, file.path);
168
- try {
169
- mkdirSync(dirname(targetPath), { recursive: true });
170
- writeFileSync(targetPath, file.content, "utf-8");
171
- } catch (err) {
172
- const msg = err instanceof Error ? err.message : "Unknown error";
173
- errors.push(`${skill.name}/${file.path} -> ${targetPath}: ${msg}`);
174
- console.error(
175
- `[skill-sync] Failed to write bundled file ${skill.name}/${file.path} to ${targetPath}: ${msg}`,
176
- );
177
- }
178
- }
179
- }
180
- }
181
-
182
- // Cleanup: only remove directories WE previously created (marker file
183
- // present). Leaves user-installed personal skills alone — important on
184
- // local dev where ~/.codex/skills holds skills the user installed
185
- // outside the swarm.
186
- for (const baseDir of skillDirs) {
187
- if (!existsSync(baseDir)) continue;
188
-
189
- try {
190
- const existing = readdirSync(baseDir, { withFileTypes: true });
191
- for (const entry of existing) {
192
- if (!entry.isDirectory()) continue;
193
- if (writtenNames.has(entry.name)) continue;
194
- const skillDir = join(baseDir, entry.name);
195
- if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
196
- try {
197
- rmSync(skillDir, { recursive: true, force: true });
198
- removed++;
199
- } catch {
200
- // Non-fatal — skip cleanup errors
201
- }
202
- }
203
- } catch {
204
- // Non-fatal — skip if we can't read the directory
205
- }
206
- }
207
40
 
208
- return { synced, removed, errors };
41
+ const entries: SkillFsEntry[] = skills.map((skill) => ({
42
+ id: skill.id,
43
+ name: skill.name,
44
+ content: skill.content ?? null,
45
+ isComplex: skill.isComplex,
46
+ isEnabled: skill.isEnabled,
47
+ isActive: skill.isActive,
48
+ files: skill.isComplex
49
+ ? getSkillFiles(skill.id).map((f) => ({
50
+ path: f.path,
51
+ content: f.content,
52
+ isBinary: f.isBinary,
53
+ }))
54
+ : [],
55
+ }));
56
+
57
+ return writeSkillsToFilesystem(entries, harnessType, home);
209
58
  }
210
59
 
211
60
  export interface SkillsSignature {
@@ -1110,6 +1110,35 @@ async function reportKeyRateLimit(
1110
1110
  }
1111
1111
  }
1112
1112
 
1113
+ /** Clear a stale rate-limit record after a successful task (fire-and-forget) */
1114
+ async function reportKeyClearRateLimit(
1115
+ apiUrl: string,
1116
+ apiKey: string,
1117
+ keyType: string,
1118
+ keySuffix: string,
1119
+ ): Promise<void> {
1120
+ try {
1121
+ const resp = await fetch(`${apiUrl}/api/keys/clear-rate-limit`, {
1122
+ method: "POST",
1123
+ headers: {
1124
+ "Content-Type": "application/json",
1125
+ Authorization: `Bearer ${apiKey}`,
1126
+ },
1127
+ body: JSON.stringify({ keyType, keySuffix }),
1128
+ });
1129
+ if (resp.ok) {
1130
+ const data = (await resp.json()) as { cleared?: boolean };
1131
+ if (data.cleared) {
1132
+ console.log(
1133
+ `[credentials] Cleared stale rate-limit for ...${keySuffix} after successful task`,
1134
+ );
1135
+ }
1136
+ }
1137
+ } catch {
1138
+ // Non-blocking
1139
+ }
1140
+ }
1141
+
1113
1142
  /**
1114
1143
  * Supersede a task via the API (for graceful shutdown / context-limit /
1115
1144
  * operator-triggered). Returns `{ ok: true, resumeTaskId }` on success.
@@ -1472,6 +1501,8 @@ interface RunningTask {
1472
1501
  * provider before it completed, and vice versa).
1473
1502
  */
1474
1503
  hasLocalEnvironment: boolean;
1504
+ /** Harness variant captured on session_init (e.g. "bridge" or "stock") */
1505
+ harnessVariant?: string;
1475
1506
  }
1476
1507
 
1477
1508
  /** Runner state for tracking concurrent tasks */
@@ -1590,6 +1621,8 @@ async function saveProviderSessionId(
1590
1621
  provider?: ProviderName,
1591
1622
  providerMeta?: Record<string, unknown>,
1592
1623
  model?: string,
1624
+ harnessVariant?: string,
1625
+ harnessVariantMeta?: Record<string, unknown>,
1593
1626
  ): Promise<void> {
1594
1627
  const headers: Record<string, string> = { "Content-Type": "application/json" };
1595
1628
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
@@ -1597,13 +1630,45 @@ async function saveProviderSessionId(
1597
1630
  if (provider !== undefined) body.provider = provider;
1598
1631
  if (providerMeta !== undefined) body.providerMeta = providerMeta;
1599
1632
  if (model !== undefined && model !== "") body.model = model;
1600
- await fetch(`${apiUrl}/api/tasks/${taskId}/claude-session`, {
1633
+ if (harnessVariant !== undefined) body.harnessVariant = harnessVariant;
1634
+ if (harnessVariantMeta !== undefined) body.harnessVariantMeta = harnessVariantMeta;
1635
+ await fetch(`${apiUrl}/api/tasks/${taskId}/session`, {
1601
1636
  method: "PUT",
1602
1637
  headers,
1603
1638
  body: JSON.stringify(body),
1604
1639
  });
1605
1640
  }
1606
1641
 
1642
+ async function findBridgeFailureArtifact(cwd: string): Promise<string | undefined> {
1643
+ try {
1644
+ const bridgeDir = `${cwd}/.claude-bridge/runs`;
1645
+ const dir = await Array.fromAsync(
1646
+ new Bun.Glob("*/tmux-pane-final.txt").scan({ cwd: bridgeDir, absolute: true }),
1647
+ );
1648
+ if (dir.length === 0) return undefined;
1649
+ dir.sort();
1650
+ return dir[dir.length - 1];
1651
+ } catch {
1652
+ return undefined;
1653
+ }
1654
+ }
1655
+
1656
+ async function updateHarnessVariantMeta(
1657
+ apiUrl: string,
1658
+ apiKey: string,
1659
+ taskId: string,
1660
+ claudeSessionId: string,
1661
+ meta: Record<string, unknown>,
1662
+ ): Promise<void> {
1663
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
1664
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
1665
+ await fetch(`${apiUrl}/api/tasks/${taskId}/session`, {
1666
+ method: "PUT",
1667
+ headers,
1668
+ body: JSON.stringify({ claudeSessionId, harnessVariantMeta: meta }),
1669
+ });
1670
+ }
1671
+
1607
1672
  /** Cache of tasks that already have VCS linked — prevents repeated gh pr list calls */
1608
1673
  const vcsDetectedTasks = new Set<string>();
1609
1674
 
@@ -2493,10 +2558,19 @@ async function spawnProviderProcess(
2493
2558
  // Resolve Codex OAuth pool slot BEFORE building ProviderSessionConfig so we
2494
2559
  // can pass codexSlot through and the adapter writes token refreshes back to
2495
2560
  // the correct slot key (codex_oauth_<slot>) instead of defaulting to slot 0.
2561
+ //
2562
+ // Always resolve for codex (not just when credentialSelections is empty) so
2563
+ // that if the OPENAI_API_KEY credential is rate-limited we can fail over to
2564
+ // a CODEX_OAUTH slot — even though the keyType differs.
2496
2565
  let oauthSelection: CredentialSelection | undefined;
2497
- if (adapter.name === "codex" && credentialSelections.length === 0) {
2566
+ if (adapter.name === "codex") {
2498
2567
  oauthSelection = (await resolveCodexOAuthCredentialInfo(opts.apiUrl, opts.apiKey)) ?? undefined;
2499
- if (oauthSelection && realTaskId) {
2568
+ const oauthIsPrimary =
2569
+ credentialSelections.length === 0 ||
2570
+ (credentialSelections[0]?.isRateLimitFallback &&
2571
+ oauthSelection &&
2572
+ !oauthSelection.isRateLimitFallback);
2573
+ if (oauthSelection && realTaskId && oauthIsPrimary) {
2500
2574
  reportKeyUsage(
2501
2575
  opts.apiUrl,
2502
2576
  opts.apiKey,
@@ -2676,6 +2750,8 @@ async function spawnProviderProcess(
2676
2750
  event.provider,
2677
2751
  event.providerMeta,
2678
2752
  model,
2753
+ event.harnessVariant,
2754
+ event.harnessVariantMeta,
2679
2755
  ).catch((err) => console.warn(`[runner] Failed to save session ID: ${err}`));
2680
2756
  } else {
2681
2757
  // Pool task: save provider session ID on active session so it can be
@@ -3085,8 +3161,23 @@ async function spawnProviderProcess(
3085
3161
  }),
3086
3162
  );
3087
3163
 
3088
- // Build credential info for rate limit tracking
3089
- const primarySelection = credentialSelections[0] ?? oauthSelection;
3164
+ // Build credential info for rate limit tracking.
3165
+ // For codex: when OPENAI_API_KEY is rate-limited but CODEX_OAUTH has
3166
+ // available slots (or vice versa), prefer the healthy credential.
3167
+ let primarySelection: CredentialSelection | undefined;
3168
+ const firstCred = credentialSelections[0];
3169
+ if (firstCred && oauthSelection) {
3170
+ if (firstCred.isRateLimitFallback && !oauthSelection.isRateLimitFallback) {
3171
+ primarySelection = oauthSelection;
3172
+ console.log(
3173
+ `[credentials] Cross-keyType failover: ${firstCred.keyType} all rate-limited, using ${oauthSelection.keyType} [...${oauthSelection.keySuffix}]`,
3174
+ );
3175
+ } else {
3176
+ primarySelection = firstCred;
3177
+ }
3178
+ } else {
3179
+ primarySelection = firstCred ?? oauthSelection;
3180
+ }
3090
3181
  const credentialInfo = primarySelection
3091
3182
  ? {
3092
3183
  keyType: primarySelection.keyType,
@@ -3160,7 +3251,14 @@ async function checkCompletedProcesses(
3160
3251
  }
3161
3252
 
3162
3253
  // Remove completed tasks from the map and ensure they're marked as finished
3163
- for (const { taskId, result, cursorUpdates, workingDir, credentialInfo } of completedTasks) {
3254
+ for (const {
3255
+ taskId,
3256
+ result,
3257
+ cursorUpdates,
3258
+ workingDir,
3259
+ credentialInfo,
3260
+ harnessProvider,
3261
+ } of completedTasks) {
3164
3262
  state.activeTasks.delete(taskId);
3165
3263
  vcsDetectedTasks.delete(taskId);
3166
3264
  vcsCheckTimestamps.delete(taskId);
@@ -3251,9 +3349,28 @@ async function checkCompletedProcesses(
3251
3349
  result.exitCode,
3252
3350
  failureReason,
3253
3351
  result.output,
3254
- state.harnessProvider,
3352
+ harnessProvider,
3255
3353
  );
3256
3354
 
3355
+ if (result.exitCode === 0 && credentialInfo) {
3356
+ reportKeyClearRateLimit(
3357
+ apiConfig.apiUrl,
3358
+ apiConfig.apiKey,
3359
+ credentialInfo.keyType,
3360
+ credentialInfo.keySuffix,
3361
+ ).catch(() => {});
3362
+ }
3363
+
3364
+ if (result.exitCode !== 0 && harnessProvider === "claude" && workingDir && result.sessionId) {
3365
+ const artifactPath = await findBridgeFailureArtifact(workingDir);
3366
+ if (artifactPath) {
3367
+ console.log(`[${role}] Bridge failure artifact found: ${artifactPath}`);
3368
+ updateHarnessVariantMeta(apiConfig.apiUrl, apiConfig.apiKey, taskId, result.sessionId, {
3369
+ failureArtifact: artifactPath,
3370
+ }).catch((err) => console.warn(`[runner] Failed to update harness variant meta: ${err}`));
3371
+ }
3372
+ }
3373
+
3257
3374
  ensure({
3258
3375
  id: "worker_process_finished",
3259
3376
  flow: "task",
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import {
4
+ clearKeyRateLimit,
4
5
  getAvailableKeyIndices,
5
6
  getKeyCostSummary,
6
7
  getKeyStatuses,
@@ -134,6 +135,26 @@ const setKeyName = route({
134
135
  auth: { apiKey: true },
135
136
  });
136
137
 
138
+ const clearRateLimitRoute = route({
139
+ method: "post",
140
+ path: "/api/keys/clear-rate-limit",
141
+ pattern: ["api", "keys", "clear-rate-limit"],
142
+ summary: "Clear rate-limited status for a key after a successful use proves it is healthy",
143
+ tags: ["API Keys"],
144
+ body: z.object({
145
+ keyType: z.string(),
146
+ keySuffix: z.string().min(1).max(10),
147
+ scope: z.string().optional(),
148
+ scopeId: z.string().optional(),
149
+ }),
150
+ responses: {
151
+ 200: { description: "Rate limit cleared (or key was not rate-limited)" },
152
+ 400: { description: "Validation error" },
153
+ 401: { description: "Unauthorized" },
154
+ },
155
+ auth: { apiKey: true },
156
+ });
157
+
137
158
  // ─── Handler ─────────────────────────────────────────────────────────────────
138
159
 
139
160
  export async function handleApiKeys(
@@ -242,5 +263,26 @@ export async function handleApiKeys(
242
263
  return true;
243
264
  }
244
265
 
266
+ // POST /api/keys/clear-rate-limit
267
+ if (clearRateLimitRoute.match(req.method, pathSegments)) {
268
+ const parsed = await clearRateLimitRoute.parse(req, res, pathSegments, queryParams);
269
+ if (!parsed) return true;
270
+
271
+ const { keyType, keySuffix, scope, scopeId } = parsed.body;
272
+ try {
273
+ const cleared = clearKeyRateLimit(keyType, keySuffix, scope, scopeId ?? null);
274
+ json(res, {
275
+ success: true,
276
+ cleared,
277
+ message: cleared
278
+ ? `Rate limit cleared for ...${keySuffix}`
279
+ : `Key ...${keySuffix} was not rate-limited`,
280
+ });
281
+ } catch (err) {
282
+ jsonError(res, err instanceof Error ? err.message : "Failed to clear rate limit", 500);
283
+ }
284
+ return true;
285
+ }
286
+
245
287
  return false;
246
288
  }
@@ -16,7 +16,7 @@ function getBridgeServer(): McpServer {
16
16
  }
17
17
 
18
18
  type RegisteredTool = {
19
- handler: Function;
19
+ handler: (argsOrExtra: unknown, extra?: unknown) => unknown | Promise<unknown>;
20
20
  inputSchema?: unknown;
21
21
  enabled?: boolean;
22
22
  };
@@ -406,6 +406,7 @@ export async function handleMemory(
406
406
 
407
407
  const { query, agentId, scope, source, sourcePath, limit, offset } = parsed.body;
408
408
  const store = getMemoryStore();
409
+ const pageLimit = Math.min(limit, 100);
409
410
  const pathNeedle = sourcePath?.trim().toLowerCase();
410
411
  const matchesPath = (p: string | null) =>
411
412
  !pathNeedle || (p?.toLowerCase().includes(pathNeedle) ?? false);
@@ -416,11 +417,14 @@ export async function handleMemory(
416
417
  const queryEmbedding = await provider.embed(query.trim());
417
418
 
418
419
  if (!queryEmbedding) {
419
- json(res, { results: [], total: 0, mode: "semantic" });
420
+ json(res, { results: [], total: 0, limit: pageLimit, offset, mode: "semantic" });
420
421
  return true;
421
422
  }
422
423
 
423
- const candidateLimit = Math.min(limit, 100) * CANDIDATE_SET_MULTIPLIER;
424
+ const candidateLimit = Math.min(
425
+ 4096,
426
+ Math.max(offset + pageLimit, pageLimit) * CANDIDATE_SET_MULTIPLIER,
427
+ );
424
428
  let candidates = store.search(queryEmbedding, agentId ?? "", {
425
429
  scope,
426
430
  limit: candidateLimit,
@@ -433,10 +437,11 @@ export async function handleMemory(
433
437
  if (pathNeedle) {
434
438
  candidates = candidates.filter((c) => matchesPath(c.sourcePath));
435
439
  }
436
- const ranked = rerank(candidates, { limit: Math.min(limit, 100) });
440
+ const ranked = rerank(candidates, { limit: candidates.length });
441
+ const page = ranked.slice(offset, offset + pageLimit);
437
442
 
438
443
  json(res, {
439
- results: ranked.map((r) => ({
444
+ results: page.map((r) => ({
440
445
  id: r.id,
441
446
  name: r.name,
442
447
  content: r.content,
@@ -457,33 +462,25 @@ export async function handleMemory(
457
462
  totalChunks: r.totalChunks,
458
463
  tags: r.tags,
459
464
  })),
460
- total: ranked.length,
465
+ total: candidates.length,
466
+ limit: pageLimit,
467
+ offset,
461
468
  mode: "semantic",
462
469
  });
463
470
  return true;
464
471
  }
465
472
 
466
- // When filtering by sourcePath, over-fetch then post-filter so the visible
467
- // page isn't gutted by the in-memory filter.
468
- const fetchLimit = pathNeedle
469
- ? Math.min(500, Math.max(limit * 10, 100))
470
- : Math.min(limit, 100);
471
- let rows = store.list(agentId ?? "", {
473
+ const listOptions = {
472
474
  scope,
473
- limit: fetchLimit,
475
+ limit: pageLimit,
474
476
  offset,
475
477
  isLead: true,
476
- });
477
- if (agentId) {
478
- rows = rows.filter((r) => r.agentId === agentId);
479
- }
480
- if (source) {
481
- rows = rows.filter((r) => r.source === source);
482
- }
483
- if (pathNeedle) {
484
- rows = rows.filter((r) => matchesPath(r.sourcePath));
485
- }
486
- rows = rows.slice(0, Math.min(limit, 100));
478
+ ownerAgentId: agentId,
479
+ source,
480
+ sourcePath: pathNeedle,
481
+ };
482
+ const rows = store.list(agentId ?? "", listOptions);
483
+ const total = store.count(agentId ?? "", listOptions);
487
484
 
488
485
  json(res, {
489
486
  results: rows.map((r) => ({
@@ -504,7 +501,9 @@ export async function handleMemory(
504
501
  totalChunks: r.totalChunks,
505
502
  tags: r.tags,
506
503
  })),
507
- total: rows.length,
504
+ total,
505
+ limit: pageLimit,
506
+ offset,
508
507
  mode: "list",
509
508
  });
510
509
  } catch (err) {
package/src/http/tasks.ts CHANGED
@@ -98,11 +98,11 @@ const createTask = route({
98
98
  },
99
99
  });
100
100
 
101
- const updateClaudeSession = route({
101
+ const updateSession = route({
102
102
  method: "put",
103
- path: "/api/tasks/{id}/claude-session",
104
- pattern: ["api", "tasks", null, "claude-session"],
105
- summary: "Update Claude session ID for a task",
103
+ path: "/api/tasks/{id}/session",
104
+ pattern: ["api", "tasks", null, "session"],
105
+ summary: "Update provider session ID and harness metadata for a task",
106
106
  tags: ["Tasks"],
107
107
  params: z.object({ id: z.string() }),
108
108
  body: z.union([
@@ -121,6 +121,8 @@ const updateClaudeSession = route({
121
121
  provider: ProviderNameSchema.exclude(["devin"]).optional(),
122
122
  model: z.string().optional(),
123
123
  providerMeta: z.object({}).optional(),
124
+ harnessVariant: z.string().optional(),
125
+ harnessVariantMeta: z.record(z.string(), z.unknown()).optional(),
124
126
  }),
125
127
  ]),
126
128
  responses: {
@@ -427,8 +429,8 @@ export async function handleTasks(
427
429
  return true;
428
430
  }
429
431
 
430
- if (updateClaudeSession.match(req.method, pathSegments)) {
431
- const parsed = await updateClaudeSession.parse(req, res, pathSegments, queryParams);
432
+ if (updateSession.match(req.method, pathSegments)) {
433
+ const parsed = await updateSession.parse(req, res, pathSegments, queryParams);
432
434
  if (!parsed) return true;
433
435
  const task = updateTaskClaudeSessionId(
434
436
  parsed.params.id,
@@ -436,6 +438,8 @@ export async function handleTasks(
436
438
  parsed.body.provider,
437
439
  parsed.body.providerMeta,
438
440
  parsed.body.model,
441
+ "harnessVariant" in parsed.body ? parsed.body.harnessVariant : undefined,
442
+ "harnessVariantMeta" in parsed.body ? parsed.body.harnessVariantMeta : undefined,
439
443
  );
440
444
  if (!task) {
441
445
  jsonError(res, "Task not found", 404);