@desplega.ai/agent-swarm 1.83.0 → 1.83.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/openapi.json +177 -10
  2. package/package.json +6 -6
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +37 -4
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/be/schedules/validate.ts +21 -0
  10. package/src/be/skill-sync.ts +65 -15
  11. package/src/commands/resume-session.ts +118 -0
  12. package/src/commands/runner.ts +178 -121
  13. package/src/http/core.ts +4 -1
  14. package/src/http/index.ts +16 -0
  15. package/src/http/integrations.ts +26 -0
  16. package/src/http/mcp-user.ts +111 -0
  17. package/src/http/poll.ts +19 -5
  18. package/src/http/schedules.ts +35 -10
  19. package/src/http/skills.ts +27 -2
  20. package/src/http/users.ts +107 -2
  21. package/src/jira/client.ts +3 -5
  22. package/src/jira/oauth.ts +1 -0
  23. package/src/jira/sync.ts +2 -2
  24. package/src/oauth/ensure-token.ts +1 -0
  25. package/src/oauth/wrapper.ts +38 -7
  26. package/src/providers/claude-adapter.ts +7 -2
  27. package/src/providers/claude-managed-adapter.ts +1 -1
  28. package/src/providers/codex-adapter.ts +30 -0
  29. package/src/providers/opencode-adapter.ts +149 -14
  30. package/src/providers/pi-mono-adapter.ts +41 -1
  31. package/src/providers/types.ts +1 -1
  32. package/src/server-user.ts +117 -0
  33. package/src/tests/artifact-sdk.test.ts +23 -19
  34. package/src/tests/budget-user-scope.test.ts +376 -0
  35. package/src/tests/claude-managed-adapter.test.ts +6 -0
  36. package/src/tests/codex-adapter.test.ts +192 -0
  37. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  38. package/src/tests/db-queries-oauth.test.ts +43 -0
  39. package/src/tests/ensure-token.test.ts +93 -0
  40. package/src/tests/error-tracker.test.ts +52 -0
  41. package/src/tests/fetch-resolved-env.test.ts +33 -20
  42. package/src/tests/http-api-integration.test.ts +36 -0
  43. package/src/tests/http-users.test.ts +29 -1
  44. package/src/tests/mcp-user-route.test.ts +325 -0
  45. package/src/tests/opencode-adapter.test.ts +75 -0
  46. package/src/tests/pi-mono-adapter.test.ts +21 -1
  47. package/src/tests/rate-limit-event.test.ts +69 -6
  48. package/src/tests/resume-session.test.ts +93 -0
  49. package/src/tests/runner-skills-refresh.test.ts +200 -0
  50. package/src/tests/schedule-validation-helper.test.ts +51 -0
  51. package/src/tests/skill-sync.test.ts +73 -9
  52. package/src/tests/skills-signature.test.ts +141 -0
  53. package/src/tests/task-tools-ctx.test.ts +100 -0
  54. package/src/tests/task-tools-ownership.test.ts +167 -0
  55. package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
  56. package/src/tests/user-token-routes.test.ts +221 -0
  57. package/src/tools/cancel-task.ts +137 -83
  58. package/src/tools/get-task-details.ts +73 -59
  59. package/src/tools/get-tasks.ts +134 -126
  60. package/src/tools/schedules/update-schedule.ts +48 -8
  61. package/src/tools/send-task.ts +312 -312
  62. package/src/tools/slack-upload-file.ts +17 -5
  63. package/src/tools/task-action.ts +464 -367
  64. package/src/tools/task-tool-ctx.ts +43 -0
  65. package/src/types.ts +6 -2
  66. package/src/utils/error-tracker.ts +122 -9
  67. package/src/utils/skills-refresh.ts +123 -0
@@ -38,10 +38,15 @@ import { getApiKey } from "../utils/api-key.ts";
38
38
  import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
39
39
  import { getContextWindowSize } from "../utils/context-window.ts";
40
40
  import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
41
- import { parseRateLimitResetTime } from "../utils/error-tracker.ts";
41
+ import {
42
+ isRateLimitMessage,
43
+ MAX_RATE_LIMIT_RESET_MS,
44
+ parseRateLimitResetTime,
45
+ } from "../utils/error-tracker.ts";
42
46
  import { resolveHarnessProvider } from "../utils/harness-provider.ts";
43
47
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
44
48
  import { scrubSecrets } from "../utils/secret-scrubber.ts";
49
+ import { refreshSkillsIfChanged } from "../utils/skills-refresh.ts";
45
50
  import { detectVcsProvider } from "../vcs/index.ts";
46
51
  import { interpolate } from "../workflows/template.ts";
47
52
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
@@ -52,6 +57,11 @@ import {
52
57
  reportCredStatus,
53
58
  reportLatestModel,
54
59
  } from "./provider-credentials.ts";
60
+ import {
61
+ type ResumeSessionCandidate,
62
+ type ResumeSessionResolution,
63
+ resolveResumeSession,
64
+ } from "./resume-session.ts";
55
65
  // Side-effect import: registers runner trigger/resumption templates
56
66
  import "./templates.ts";
57
67
 
@@ -989,6 +999,8 @@ async function getPausedTasksFromAPI(config: ApiConfig): Promise<
989
999
  task: string;
990
1000
  progress?: string;
991
1001
  claudeSessionId?: string;
1002
+ provider?: ProviderName;
1003
+ providerMeta?: Record<string, unknown>;
992
1004
  parentTaskId?: string;
993
1005
  dir?: string;
994
1006
  vcsRepo?: string;
@@ -1021,6 +1033,8 @@ async function getPausedTasksFromAPI(config: ApiConfig): Promise<
1021
1033
  task: string;
1022
1034
  progress?: string;
1023
1035
  claudeSessionId?: string;
1036
+ provider?: ProviderName;
1037
+ providerMeta?: Record<string, unknown>;
1024
1038
  parentTaskId?: string;
1025
1039
  dir?: string;
1026
1040
  vcsRepo?: string;
@@ -1423,24 +1437,51 @@ async function saveProviderSessionIdOnActiveSession(
1423
1437
  });
1424
1438
  }
1425
1439
 
1426
- /** Fetch Claude session ID for a task (for --resume) */
1427
- async function fetchProviderSessionId(
1440
+ interface ProviderSessionInfo {
1441
+ sessionId: string | null;
1442
+ provider?: ProviderName;
1443
+ providerMeta?: Record<string, unknown>;
1444
+ }
1445
+
1446
+ /** Fetch provider session metadata for a task (for resume continuity). */
1447
+ async function fetchProviderSessionInfo(
1428
1448
  apiUrl: string,
1429
1449
  apiKey: string,
1430
1450
  taskId: string,
1431
- ): Promise<string | null> {
1451
+ ): Promise<ProviderSessionInfo | null> {
1432
1452
  const headers: Record<string, string> = {};
1433
1453
  if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
1434
1454
  try {
1435
1455
  const response = await fetch(`${apiUrl}/api/tasks/${taskId}`, { headers });
1436
1456
  if (!response.ok) return null;
1437
- const data = (await response.json()) as { claudeSessionId?: string };
1438
- return data.claudeSessionId || null;
1457
+ const data = (await response.json()) as {
1458
+ claudeSessionId?: string;
1459
+ provider?: ProviderName;
1460
+ providerMeta?: Record<string, unknown>;
1461
+ };
1462
+ return {
1463
+ sessionId: data.claudeSessionId || null,
1464
+ provider: data.provider,
1465
+ providerMeta: data.providerMeta,
1466
+ };
1439
1467
  } catch {
1440
1468
  return null;
1441
1469
  }
1442
1470
  }
1443
1471
 
1472
+ function logResumeResolution(role: string, resolution: ResumeSessionResolution): void {
1473
+ for (const skipped of resolution.skipped) {
1474
+ console.warn(
1475
+ `[${role}] Skipping ${skipped.source} session resume ${skipped.sessionId.slice(0, 8)}: ${skipped.reason}`,
1476
+ );
1477
+ }
1478
+
1479
+ if (resolution.resumeSessionId) {
1480
+ const source = resolution.source === "parent" ? "parent session" : "task's own session";
1481
+ console.log(`[${role}] Resuming ${source} ${resolution.resumeSessionId.slice(0, 8)}`);
1482
+ }
1483
+ }
1484
+
1444
1485
  /** Register an active session with the API (fire-and-forget) */
1445
1486
  async function registerActiveSession(
1446
1487
  config: ApiConfig,
@@ -2101,6 +2142,7 @@ async function spawnProviderProcess(
2101
2142
  iteration: number;
2102
2143
  taskId?: string;
2103
2144
  model?: string;
2145
+ resumeSessionId?: string;
2104
2146
  harnessProvider: ProviderName;
2105
2147
  cwd?: string;
2106
2148
  vcsRepo?: string;
@@ -2166,6 +2208,7 @@ async function spawnProviderProcess(
2166
2208
  vcsRepo: opts.vcsRepo,
2167
2209
  logFile: opts.logFile,
2168
2210
  additionalArgs: opts.additionalArgs,
2211
+ resumeSessionId: opts.resumeSessionId,
2169
2212
  iteration: opts.iteration,
2170
2213
  env: freshEnv as Record<string, string>,
2171
2214
  // Propagate the selected OAuth slot so the adapter refreshes back to the
@@ -2812,54 +2855,61 @@ async function checkCompletedProcesses(
2812
2855
  if (result.exitCode !== 0 && result.failureReason) {
2813
2856
  failureReason = result.failureReason;
2814
2857
  console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
2858
+ }
2815
2859
 
2816
- // If rate-limited and we know which key was used, report it.
2817
- // Codex adapter prefixes failure reasons with `[rate-limit]` /
2818
- // `[usage-limit]` (see codex-adapter.formatTerminalError); Claude
2819
- // surfaces "rate limit" / "hit your limit" via SessionErrorTracker.
2820
- if (
2821
- credentialInfo &&
2822
- /rate.?limit|hit your limit|usage[ _-]?limit|too many requests/i.test(failureReason)
2823
- ) {
2824
- // Three-tier reset-time resolver (most to least precise):
2825
- // Tier 1: structured rate_limit_event from Claude CLI (resetsAt epoch sec)
2826
- // Tier 2: regex on the error message (e.g. "resets 3pm (UTC)")
2827
- // Tier 3: 5-min hard fallback — only when both structured and regex fail
2828
- // Tiers 1 & 2 are clamped to [now+60s, now+6h] at their source.
2829
- const clampResetTime = (isoString: string): string => {
2830
- const nowMs = Date.now();
2831
- const minMs = nowMs + 60_000;
2832
- const maxMs = nowMs + 6 * 60 * 60 * 1000;
2833
- const candidateMs = new Date(isoString).getTime();
2834
- return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
2835
- };
2860
+ // If rate-limited and we know which key was used, report it.
2861
+ // Codex adapter prefixes failure reasons with `[rate-limit]` /
2862
+ // `[usage-limit]` (see codex-adapter.formatTerminalError); Claude
2863
+ // surfaces "rate limit" / "hit your limit" via SessionErrorTracker.
2864
+ //
2865
+ // The gate must also fire on a bare structured rate_limit_event: a
2866
+ // `status: "rejected"` event sets result.rateLimitResetAt but does NOT
2867
+ // set hasErrors(), so failureReason can be empty even though the key is
2868
+ // exhausted. Gating on rateLimitResetAt != null ensures the structured
2869
+ // event alone still triggers the cooldown.
2870
+ if (
2871
+ credentialInfo &&
2872
+ (result.rateLimitResetAt != null ||
2873
+ (failureReason != null && isRateLimitMessage(failureReason)))
2874
+ ) {
2875
+ // Three-tier reset-time resolver (most to least precise):
2876
+ // Tier 1: structured rate_limit_event from Claude CLI (resetsAt epoch sec)
2877
+ // Tier 2: regex on the error message (e.g. "resets 3pm (UTC)")
2878
+ // Tier 3: 5-min hard fallback — only when both structured and regex fail
2879
+ // Tiers 1 & 2 are clamped to [now+60s, now+7d] (weekly limits reset ~2 days out).
2880
+ const clampResetTime = (isoString: string): string => {
2881
+ const nowMs = Date.now();
2882
+ const minMs = nowMs + 60_000;
2883
+ const maxMs = nowMs + MAX_RATE_LIMIT_RESET_MS;
2884
+ const candidateMs = new Date(isoString).getTime();
2885
+ return new Date(Math.min(Math.max(candidateMs, minMs), maxMs)).toISOString();
2886
+ };
2836
2887
 
2837
- let rateLimitedUntil: string;
2838
- if (result.rateLimitResetAt) {
2839
- rateLimitedUntil = clampResetTime(result.rateLimitResetAt);
2888
+ let rateLimitedUntil: string;
2889
+ if (result.rateLimitResetAt) {
2890
+ rateLimitedUntil = clampResetTime(result.rateLimitResetAt);
2891
+ console.log(`[credentials] Rate limit reset from rate_limit_event: ${rateLimitedUntil}`);
2892
+ } else if (failureReason != null) {
2893
+ const parsedResetTime = parseRateLimitResetTime(failureReason);
2894
+ if (parsedResetTime) {
2895
+ rateLimitedUntil = clampResetTime(parsedResetTime);
2840
2896
  console.log(
2841
- `[credentials] Rate limit reset from rate_limit_event: ${rateLimitedUntil}`,
2897
+ `[credentials] Parsed rate limit reset time from error: ${rateLimitedUntil}`,
2842
2898
  );
2843
2899
  } else {
2844
- const parsedResetTime = parseRateLimitResetTime(failureReason);
2845
- if (parsedResetTime) {
2846
- rateLimitedUntil = clampResetTime(parsedResetTime);
2847
- console.log(
2848
- `[credentials] Parsed rate limit reset time from error: ${rateLimitedUntil}`,
2849
- );
2850
- } else {
2851
- rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
2852
- }
2900
+ rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
2853
2901
  }
2854
- reportKeyRateLimit(
2855
- apiConfig.apiUrl,
2856
- apiConfig.apiKey,
2857
- credentialInfo.keyType,
2858
- credentialInfo.keySuffix,
2859
- credentialInfo.keyIndex,
2860
- rateLimitedUntil,
2861
- ).catch(() => {});
2902
+ } else {
2903
+ rateLimitedUntil = new Date(Date.now() + 5 * 60 * 1000).toISOString();
2862
2904
  }
2905
+ reportKeyRateLimit(
2906
+ apiConfig.apiUrl,
2907
+ apiConfig.apiKey,
2908
+ credentialInfo.keyType,
2909
+ credentialInfo.keySuffix,
2910
+ credentialInfo.keyIndex,
2911
+ rateLimitedUntil,
2912
+ ).catch(() => {});
2863
2913
  }
2864
2914
  await ensureTaskFinished(
2865
2915
  apiConfig,
@@ -3511,34 +3561,6 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3511
3561
  }
3512
3562
  }
3513
3563
 
3514
- // Fetch installed skills for system prompt
3515
- try {
3516
- const skillsResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills`, {
3517
- headers: {
3518
- Authorization: `Bearer ${apiKey}`,
3519
- "X-Agent-ID": agentId,
3520
- },
3521
- });
3522
- if (skillsResp.ok) {
3523
- const skillsData = (await skillsResp.json()) as {
3524
- skills: {
3525
- name: string;
3526
- description: string;
3527
- isActive: boolean;
3528
- isEnabled: boolean;
3529
- }[];
3530
- };
3531
- agentSkillsSummary = skillsData.skills
3532
- .filter((s) => s.isActive && s.isEnabled)
3533
- .map((s) => ({ name: s.name, description: s.description }));
3534
- if (agentSkillsSummary.length > 0) {
3535
- console.log(`[${role}] Loaded ${agentSkillsSummary.length} skills for system prompt`);
3536
- }
3537
- }
3538
- } catch {
3539
- // Non-fatal — skills are optional
3540
- }
3541
-
3542
3564
  // Fetch installed MCP servers for system prompt
3543
3565
  try {
3544
3566
  const mcpServersResp = await fetch(`${apiUrl}/api/agents/${agentId}/mcp-servers`, {
@@ -3649,35 +3671,32 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3649
3671
  }
3650
3672
  }
3651
3673
 
3652
- // ========== Sync skills to filesystem ==========
3653
- try {
3674
+ // ========== Boot-time skill load (signature-gated, replaces the standalone
3675
+ // skill-fetch + FS sync blocks). The polling loop below calls the same
3676
+ // helper per task to hot-reload skills mid-flight. Skipped for
3677
+ // `claude-managed` (cloud sandbox owns skill delivery).
3678
+ const lastSkillHash: { current: string | null } = { current: null };
3679
+ if (state.harnessProvider !== "claude-managed") {
3654
3680
  console.log(`[${role}] Syncing skills to filesystem...`);
3655
- const syncHeaders: Record<string, string> = {
3656
- "Content-Type": "application/json",
3657
- "X-Agent-ID": agentId,
3658
- };
3659
- if (apiKey) syncHeaders.Authorization = `Bearer ${apiKey}`;
3660
- const syncRes = await fetch(`${swarmUrl}/api/skills/sync-filesystem`, {
3661
- method: "POST",
3662
- headers: syncHeaders,
3663
- });
3664
- if (syncRes.ok) {
3665
- const syncResult = (await syncRes.json()) as {
3666
- synced: number;
3667
- removed: number;
3668
- errors: string[];
3669
- };
3670
- console.log(
3671
- `[${role}] Skills synced: ${syncResult.synced} written, ${syncResult.removed} removed`,
3672
- );
3673
- if (syncResult.errors.length > 0) {
3674
- console.warn(`[${role}] Skill sync errors: ${syncResult.errors.join(", ")}`);
3681
+ const skillResult = await refreshSkillsIfChanged(
3682
+ { apiUrl, swarmUrl, apiKey, agentId, role },
3683
+ lastSkillHash,
3684
+ );
3685
+ if (skillResult.changed && skillResult.summary) {
3686
+ agentSkillsSummary = skillResult.summary;
3687
+ if (agentSkillsSummary.length > 0) {
3688
+ console.log(`[${role}] Loaded ${agentSkillsSummary.length} skills for system prompt`);
3675
3689
  }
3676
- } else {
3677
- console.warn(`[${role}] Skill sync failed: HTTP ${syncRes.status}`);
3690
+ // Rebuild base prompt now that we have skills.
3691
+ basePrompt = await buildSystemPrompt();
3692
+ resolvedSystemPrompt = additionalSystemPrompt
3693
+ ? `${basePrompt}\n\n${additionalSystemPrompt}`
3694
+ : basePrompt;
3678
3695
  }
3679
- } catch (err) {
3680
- console.warn(`[${role}] Skill sync failed: ${(err as Error).message}`);
3696
+ } else {
3697
+ console.log(
3698
+ `[${role}] Skipping skill sync (claude-managed reads skills from agent definition)`,
3699
+ );
3681
3700
  }
3682
3701
 
3683
3702
  // ========== Resume paused tasks with PRIORITY ==========
@@ -3733,18 +3752,30 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3733
3752
  console.log(`[${role}] Injected relevant memories into resumed task prompt`);
3734
3753
  }
3735
3754
 
3736
- // Resolve --resume: prefer own session ID, then parent's
3737
- let resumeAdditionalArgs = opts.additionalArgs || [];
3738
- if (task.claudeSessionId) {
3739
- resumeAdditionalArgs = [...resumeAdditionalArgs, "--resume", task.claudeSessionId];
3740
- console.log(`[${role}] Resuming task's own session ${task.claudeSessionId.slice(0, 8)}`);
3741
- } else if (task.parentTaskId) {
3742
- const parentSessionId = await fetchProviderSessionId(apiUrl, apiKey, task.parentTaskId);
3743
- if (parentSessionId) {
3744
- resumeAdditionalArgs = [...resumeAdditionalArgs, "--resume", parentSessionId];
3745
- console.log(`[${role}] Resuming parent session ${parentSessionId.slice(0, 8)}`);
3755
+ // Resolve provider-aware resume: prefer own session, then parent.
3756
+ const resumeCandidates: ResumeSessionCandidate[] = [
3757
+ {
3758
+ source: "task",
3759
+ taskId: task.id,
3760
+ sessionId: task.claudeSessionId,
3761
+ provider: task.provider,
3762
+ providerMeta: task.providerMeta,
3763
+ },
3764
+ ];
3765
+ if (task.parentTaskId) {
3766
+ const parentSession = await fetchProviderSessionInfo(apiUrl, apiKey, task.parentTaskId);
3767
+ if (parentSession?.sessionId) {
3768
+ resumeCandidates.push({
3769
+ source: "parent",
3770
+ taskId: task.parentTaskId,
3771
+ sessionId: parentSession.sessionId,
3772
+ provider: parentSession.provider,
3773
+ providerMeta: parentSession.providerMeta,
3774
+ });
3746
3775
  }
3747
3776
  }
3777
+ const resumeResolution = resolveResumeSession(state.harnessProvider, resumeCandidates);
3778
+ logResumeResolution(role, resumeResolution);
3748
3779
 
3749
3780
  // Spawn Claude process for resumed task
3750
3781
  iteration++;
@@ -3810,7 +3841,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3810
3841
  prompt: resumePrompt,
3811
3842
  logFile,
3812
3843
  systemPrompt: resolvedSystemPrompt,
3813
- additionalArgs: resumeAdditionalArgs,
3844
+ additionalArgs: opts.additionalArgs,
3845
+ resumeSessionId: resumeResolution.resumeSessionId,
3814
3846
  role,
3815
3847
  apiUrl,
3816
3848
  apiKey,
@@ -4075,20 +4107,27 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4075
4107
  }
4076
4108
  }
4077
4109
 
4078
- // Resolve --resume for child tasks with parentTaskId
4079
- let effectiveAdditionalArgs = opts.additionalArgs || [];
4110
+ // Resolve provider-aware resume for child tasks with parentTaskId.
4111
+ let resumeSessionId: string | undefined;
4080
4112
  const taskObj = trigger.task as { parentTaskId?: string } | undefined;
4081
4113
  if (taskObj?.parentTaskId) {
4082
- const parentSessionId = await fetchProviderSessionId(
4114
+ const parentSession = await fetchProviderSessionInfo(
4083
4115
  apiUrl,
4084
4116
  apiKey,
4085
4117
  taskObj.parentTaskId,
4086
4118
  );
4087
- if (parentSessionId) {
4088
- effectiveAdditionalArgs = [...effectiveAdditionalArgs, "--resume", parentSessionId];
4089
- console.log(
4090
- `[${role}] Child task — resuming parent session ${parentSessionId.slice(0, 8)}`,
4091
- );
4119
+ if (parentSession?.sessionId) {
4120
+ const resumeResolution = resolveResumeSession(state.harnessProvider, [
4121
+ {
4122
+ source: "parent",
4123
+ taskId: taskObj.parentTaskId,
4124
+ sessionId: parentSession.sessionId,
4125
+ provider: parentSession.provider,
4126
+ providerMeta: parentSession.providerMeta,
4127
+ },
4128
+ ]);
4129
+ logResumeResolution(role, resumeResolution);
4130
+ resumeSessionId = resumeResolution.resumeSessionId;
4092
4131
  } else {
4093
4132
  console.log(`[${role}] Child task — parent session ID not found, starting fresh`);
4094
4133
  }
@@ -4168,6 +4207,23 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4168
4207
  cwdWarning = `\n\nNote: The task requested working directory "${taskDir}" but it does not exist. Falling back to default directory.`;
4169
4208
  }
4170
4209
 
4210
+ // Per-task skill hot-reload. Reuses the boot-time helper; signature
4211
+ // probe short-circuits when nothing changed. Skipped for
4212
+ // `claude-managed`. Read state.harnessProvider live so an adapter
4213
+ // swap mid-loop honors the new provider.
4214
+ if (state.harnessProvider !== "claude-managed") {
4215
+ const skillResult = await refreshSkillsIfChanged(
4216
+ { apiUrl, swarmUrl, apiKey, agentId, role },
4217
+ lastSkillHash,
4218
+ );
4219
+ if (skillResult.changed && skillResult.summary) {
4220
+ agentSkillsSummary = skillResult.summary;
4221
+ console.log(
4222
+ `[${role}] Skills changed — refreshing system prompt (${agentSkillsSummary.length} skills)`,
4223
+ );
4224
+ }
4225
+ }
4226
+
4171
4227
  // Rebuild system prompt with per-task repo context
4172
4228
  const taskBasePrompt = await buildSystemPrompt();
4173
4229
  const taskSystemPrompt =
@@ -4210,7 +4266,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4210
4266
  prompt: triggerPrompt,
4211
4267
  logFile,
4212
4268
  systemPrompt: taskSystemPrompt,
4213
- additionalArgs: effectiveAdditionalArgs,
4269
+ additionalArgs: opts.additionalArgs,
4270
+ resumeSessionId,
4214
4271
  role,
4215
4272
  apiUrl,
4216
4273
  apiKey,
package/src/http/core.ts CHANGED
@@ -240,7 +240,10 @@ export async function handleCore(
240
240
  // fall through to the bearer check (fail-closed).
241
241
  if (apiKey) {
242
242
  const pathSegments = getPathSegments(req.url || "");
243
- if (!isPublicRoute(req.method, pathSegments)) {
243
+ const isUserMcpRoute = req.url === "/mcp-user";
244
+ // `/mcp-user` runs its own `aswt_`-token auth in `handleMcpUser`; the swarm
245
+ // API key must not gate it.
246
+ if (!isUserMcpRoute && !isPublicRoute(req.method, pathSegments)) {
244
247
  const authHeader = req.headers.authorization;
245
248
  const providedKey = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
246
249
 
package/src/http/index.ts CHANGED
@@ -43,6 +43,7 @@ import { handleKv } from "./kv";
43
43
  import { handleMcp } from "./mcp";
44
44
  import { handleMcpOAuth, startMcpOAuthPendingGc, stopMcpOAuthPendingGc } from "./mcp-oauth";
45
45
  import { handleMcpServers } from "./mcp-servers";
46
+ import { handleMcpUser } from "./mcp-user";
46
47
  import { handleMemory } from "./memory";
47
48
  import { handlePageProxy } from "./page-proxy";
48
49
  import { handlePages } from "./pages";
@@ -89,6 +90,8 @@ const apiKey = getApiKey();
89
90
  const globalState = globalThis as typeof globalThis & {
90
91
  __httpServer?: Server<typeof IncomingMessage, typeof ServerResponse>;
91
92
  __transports?: Record<string, StreamableHTTPServerTransport>;
93
+ __transportsUser?: Record<string, StreamableHTTPServerTransport>;
94
+ __sessionUsers?: Record<string, string>;
92
95
  __sigintRegistered?: boolean;
93
96
  __runId?: string;
94
97
  };
@@ -100,6 +103,9 @@ if (globalState.__httpServer) {
100
103
  }
101
104
 
102
105
  const transports: Record<string, StreamableHTTPServerTransport> = globalState.__transports ?? {};
106
+ const transportsUser: Record<string, StreamableHTTPServerTransport> =
107
+ globalState.__transportsUser ?? {};
108
+ const sessionUsers: Record<string, string> = globalState.__sessionUsers ?? {};
103
109
 
104
110
  const httpServer = createHttpServer(async (req, res) => {
105
111
  const startTime = performance.now();
@@ -234,6 +240,7 @@ const httpServer = createHttpServer(async (req, res) => {
234
240
  () => handleInboxState(req, res, pathSegments, queryParams),
235
241
  () => handleTaskTemplates(req, res, pathSegments, queryParams),
236
242
  () => handleMcp(req, res, transports),
243
+ () => handleMcpUser(req, res, transportsUser, sessionUsers),
237
244
  ];
238
245
 
239
246
  try {
@@ -271,6 +278,8 @@ const httpServer = createHttpServer(async (req, res) => {
271
278
  // Store references in globalThis for hot reload persistence
272
279
  globalState.__httpServer = httpServer;
273
280
  globalState.__transports = transports;
281
+ globalState.__transportsUser = transportsUser;
282
+ globalState.__sessionUsers = sessionUsers;
274
283
 
275
284
  async function shutdown() {
276
285
  console.log("Shutting down HTTP server...");
@@ -303,6 +312,13 @@ async function shutdown() {
303
312
  delete transports[id];
304
313
  }
305
314
 
315
+ for (const [id, transport] of Object.entries(transportsUser)) {
316
+ console.log(`[HTTP] Closing user transport ${id}`);
317
+ transport.close();
318
+ delete transportsUser[id];
319
+ delete sessionUsers[id];
320
+ }
321
+
306
322
  // Close all active connections forcefully
307
323
  httpServer.closeAllConnections();
308
324
  httpServer.close(() => {
@@ -45,6 +45,20 @@ const claudeManagedTestRoute = route({
45
45
  },
46
46
  });
47
47
 
48
+ const mcpUserConfigRoute = route({
49
+ method: "get",
50
+ path: "/api/integrations/mcp-user/config",
51
+ pattern: ["api", "integrations", "mcp-user", "config"],
52
+ summary: "Get server-derived config for end-user MCP clients.",
53
+ tags: ["Integrations"],
54
+ responses: {
55
+ 200: {
56
+ description:
57
+ "Server-derived MCP user config. `mcpBaseUrl` is the API server base URL and `mcpUserUrl` appends `/mcp-user`.",
58
+ },
59
+ },
60
+ });
61
+
48
62
  // ─── Helpers ─────────────────────────────────────────────────────────────────
49
63
 
50
64
  /**
@@ -73,6 +87,12 @@ function resolveConfigValue(key: string): string | null {
73
87
  return null;
74
88
  }
75
89
 
90
+ function resolveMcpBaseUrl(): string {
91
+ const configured = resolveConfigValue("MCP_BASE_URL");
92
+ const fallback = `http://localhost:${process.env.PORT || "3013"}`;
93
+ return (configured || fallback).replace(/\/+$/, "");
94
+ }
95
+
76
96
  // ─── Public handler factory ──────────────────────────────────────────────────
77
97
 
78
98
  /**
@@ -89,6 +109,12 @@ export function createIntegrationsHandler(deps: TestConnectionDeps = {}) {
89
109
  res: ServerResponse,
90
110
  pathSegments: string[],
91
111
  ): Promise<boolean> {
112
+ if (mcpUserConfigRoute.match(req.method, pathSegments)) {
113
+ const mcpBaseUrl = resolveMcpBaseUrl();
114
+ json(res, { mcpBaseUrl, mcpUserUrl: `${mcpBaseUrl}/mcp-user` });
115
+ return true;
116
+ }
117
+
92
118
  if (claudeManagedTestRoute.match(req.method, pathSegments)) {
93
119
  const apiKey = resolveConfigValue("ANTHROPIC_API_KEY");
94
120
  const agentId = resolveConfigValue("MANAGED_AGENT_ID");
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
+ import { resolveUserByToken } from "@/be/users";
6
+ import { createUserServer } from "@/server-user";
7
+ import type { User } from "@/types";
8
+
9
+ function unauthorized(res: ServerResponse): true {
10
+ res.writeHead(401, { "Content-Type": "application/json" });
11
+ res.end(JSON.stringify({ error: "Unauthorized" }));
12
+ return true;
13
+ }
14
+
15
+ function extractBearer(req: IncomingMessage): string | null {
16
+ const header = req.headers.authorization;
17
+ if (!header?.startsWith("Bearer ")) return null;
18
+ const token = header.slice("Bearer ".length).trim();
19
+ return token.startsWith("aswt_") ? token : null;
20
+ }
21
+
22
+ function resolveActiveUser(req: IncomingMessage): User | null {
23
+ const token = extractBearer(req);
24
+ if (!token) return null;
25
+ const user = resolveUserByToken(token);
26
+ if (!user || user.status !== "active") return null;
27
+ return user;
28
+ }
29
+
30
+ export async function handleMcpUser(
31
+ req: IncomingMessage,
32
+ res: ServerResponse,
33
+ transports: Record<string, StreamableHTTPServerTransport>,
34
+ sessionUsers: Record<string, string>,
35
+ ): Promise<boolean> {
36
+ const sessionId = req.headers["mcp-session-id"] as string | undefined;
37
+
38
+ if (req.url !== "/mcp-user") {
39
+ return false;
40
+ }
41
+
42
+ const user = resolveActiveUser(req);
43
+ if (!user) return unauthorized(res);
44
+
45
+ if (sessionId && transports[sessionId] && sessionUsers[sessionId] !== user.id) {
46
+ return unauthorized(res);
47
+ }
48
+
49
+ if (req.method === "POST") {
50
+ const chunks: Buffer[] = [];
51
+ for await (const chunk of req) {
52
+ chunks.push(chunk);
53
+ }
54
+ const body = JSON.parse(Buffer.concat(chunks).toString());
55
+
56
+ let transport: StreamableHTTPServerTransport;
57
+
58
+ if (sessionId && transports[sessionId]) {
59
+ transport = transports[sessionId];
60
+ } else if (!sessionId && isInitializeRequest(body)) {
61
+ transport = new StreamableHTTPServerTransport({
62
+ sessionIdGenerator: () => randomUUID(),
63
+ onsessioninitialized: (id) => {
64
+ transports[id] = transport;
65
+ sessionUsers[id] = user.id;
66
+ },
67
+ onsessionclosed: (id) => {
68
+ delete transports[id];
69
+ delete sessionUsers[id];
70
+ },
71
+ });
72
+
73
+ transport.onclose = () => {
74
+ if (transport.sessionId) {
75
+ delete transports[transport.sessionId];
76
+ delete sessionUsers[transport.sessionId];
77
+ }
78
+ };
79
+
80
+ const server = createUserServer(user);
81
+ await server.connect(transport);
82
+ } else {
83
+ res.writeHead(400, { "Content-Type": "application/json" });
84
+ res.end(
85
+ JSON.stringify({
86
+ jsonrpc: "2.0",
87
+ error: { code: -32000, message: "Invalid session" },
88
+ id: null,
89
+ }),
90
+ );
91
+ return true;
92
+ }
93
+
94
+ await transport.handleRequest(req, res, body);
95
+ return true;
96
+ }
97
+
98
+ if (req.method === "GET" || req.method === "DELETE") {
99
+ if (sessionId && transports[sessionId]) {
100
+ await transports[sessionId].handleRequest(req, res);
101
+ return true;
102
+ }
103
+ res.writeHead(400);
104
+ res.end("Invalid session");
105
+ return true;
106
+ }
107
+
108
+ res.writeHead(405);
109
+ res.end("Method not allowed");
110
+ return true;
111
+ }