@desplega.ai/agent-swarm 1.82.0 → 1.83.1

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 (35) hide show
  1. package/openapi.json +43 -5
  2. package/package.json +6 -6
  3. package/src/be/db.ts +16 -3
  4. package/src/be/migrations/073_task_attachments_agent_fs_ids.sql +15 -0
  5. package/src/be/schedules/validate.ts +21 -0
  6. package/src/be/skill-sync.ts +65 -15
  7. package/src/commands/provider-credentials.ts +11 -0
  8. package/src/commands/runner.ts +41 -54
  9. package/src/http/schedules.ts +34 -9
  10. package/src/http/skills.ts +27 -2
  11. package/src/http/tasks.ts +7 -3
  12. package/src/providers/pi-mono-adapter.ts +20 -3
  13. package/src/providers/types.ts +5 -1
  14. package/src/slack/blocks.ts +132 -1
  15. package/src/slack/responses.ts +15 -5
  16. package/src/slack/watcher.ts +12 -0
  17. package/src/tests/credential-check.test.ts +47 -0
  18. package/src/tests/http-api-integration.test.ts +36 -0
  19. package/src/tests/rest-api.test.ts +51 -1
  20. package/src/tests/runner-skills-refresh.test.ts +200 -0
  21. package/src/tests/schedule-validation-helper.test.ts +51 -0
  22. package/src/tests/skill-sync.test.ts +73 -9
  23. package/src/tests/skills-signature.test.ts +141 -0
  24. package/src/tests/slack-attachments-block.test.ts +240 -0
  25. package/src/tests/slack-blocks.test.ts +162 -0
  26. package/src/tests/slack-watcher.test.ts +83 -0
  27. package/src/tests/store-progress-attachments-handler.test.ts +480 -0
  28. package/src/tests/store-progress-attachments.test.ts +41 -0
  29. package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
  30. package/src/tools/schedules/update-schedule.ts +48 -8
  31. package/src/tools/slack-upload-file.ts +17 -5
  32. package/src/tools/store-progress.ts +55 -19
  33. package/src/types.ts +21 -1
  34. package/src/utils/constants.ts +58 -0
  35. package/src/utils/skills-refresh.ts +123 -0
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.82.0",
5
+ "version": "1.83.1",
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": [
@@ -836,6 +836,7 @@
836
836
  "env",
837
837
  "file",
838
838
  "side-effect-pending",
839
+ "sdk-delegated",
839
840
  null
840
841
  ],
841
842
  "default": null
@@ -6928,10 +6929,17 @@
6928
6929
  "type": "string"
6929
6930
  },
6930
6931
  "cronExpression": {
6931
- "type": "string"
6932
+ "type": [
6933
+ "string",
6934
+ "null"
6935
+ ]
6932
6936
  },
6933
6937
  "intervalMs": {
6934
- "type": "integer"
6938
+ "type": [
6939
+ "integer",
6940
+ "null"
6941
+ ],
6942
+ "exclusiveMinimum": 0
6935
6943
  },
6936
6944
  "taskTemplate": {
6937
6945
  "type": "string"
@@ -7895,6 +7903,35 @@
7895
7903
  }
7896
7904
  }
7897
7905
  },
7906
+ "/api/agents/{id}/skills/signature": {
7907
+ "get": {
7908
+ "summary": "Compute a stable signature over an agent's installed skills",
7909
+ "description": "Returns a sha256 hash over per-row mutation fields of the agent's active+enabled skill set. Workers poll this to detect skill changes cheaply without fetching the full list.",
7910
+ "tags": [
7911
+ "Skills"
7912
+ ],
7913
+ "security": [
7914
+ {
7915
+ "bearerAuth": []
7916
+ }
7917
+ ],
7918
+ "parameters": [
7919
+ {
7920
+ "schema": {
7921
+ "type": "string"
7922
+ },
7923
+ "required": true,
7924
+ "name": "id",
7925
+ "in": "path"
7926
+ }
7927
+ ],
7928
+ "responses": {
7929
+ "200": {
7930
+ "description": "Skills signature"
7931
+ }
7932
+ }
7933
+ }
7934
+ },
7898
7935
  "/api/scripts/upsert": {
7899
7936
  "post": {
7900
7937
  "operationId": "scripts_upsert",
@@ -9795,7 +9832,8 @@
9795
9832
  },
9796
9833
  "/api/tasks/{id}": {
9797
9834
  "get": {
9798
- "summary": "Get task details with logs",
9835
+ "summary": "Get task details with logs and attachments",
9836
+ "description": "Returns the full `AgentTask` row decorated with `logs` (capped by `logsLimit`) and `attachments` (pointer-based artifacts stored on the task, ordered by `created_at`).",
9799
9837
  "tags": [
9800
9838
  "Tasks"
9801
9839
  ],
@@ -9826,7 +9864,7 @@
9826
9864
  ],
9827
9865
  "responses": {
9828
9866
  "200": {
9829
- "description": "Task with logs"
9867
+ "description": "Task with logs and attachments"
9830
9868
  },
9831
9869
  "404": {
9832
9870
  "description": "Task not found"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.82.0",
3
+ "version": "1.83.1",
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>",
@@ -108,12 +108,12 @@
108
108
  "@desplega.ai/localtunnel": "^2.2.0",
109
109
  "@inkjs/ui": "^2.0.0",
110
110
  "@linear/sdk": "^77.0.0",
111
- "@earendil-works/pi-agent-core": "^0.75.3",
112
- "@earendil-works/pi-ai": "^0.75.3",
113
- "@earendil-works/pi-coding-agent": "^0.75.3",
111
+ "@earendil-works/pi-agent-core": "^0.75.5",
112
+ "@earendil-works/pi-ai": "^0.75.5",
113
+ "@earendil-works/pi-coding-agent": "^0.75.5",
114
114
  "@modelcontextprotocol/sdk": "^1.25.1",
115
- "@openai/codex-sdk": "^0.130.0",
116
- "@opencode-ai/sdk": "^1.15.4",
115
+ "@openai/codex-sdk": "^0.133.0",
116
+ "@opencode-ai/sdk": "^1.15.10",
117
117
  "@openfort/openfort-node": "^0.9.1",
118
118
  "@opentelemetry/api": "^1.9.1",
119
119
  "@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
package/src/be/db.ts CHANGED
@@ -2146,6 +2146,8 @@ type TaskAttachmentRow = {
2146
2146
  url: string | null;
2147
2147
  path: string | null;
2148
2148
  page_id: string | null;
2149
+ agent_fs_org_id: string | null;
2150
+ agent_fs_drive_id: string | null;
2149
2151
  mime_type: string | null;
2150
2152
  size_bytes: number | null;
2151
2153
  sha256: string | null;
@@ -2165,6 +2167,8 @@ function rowToTaskAttachment(row: TaskAttachmentRow): TaskAttachment {
2165
2167
  url: row.url ?? undefined,
2166
2168
  path: row.path ?? undefined,
2167
2169
  pageId: row.page_id ?? undefined,
2170
+ orgId: row.agent_fs_org_id ?? undefined,
2171
+ driveId: row.agent_fs_drive_id ?? undefined,
2168
2172
  mimeType: row.mime_type ?? undefined,
2169
2173
  sizeBytes: row.size_bytes ?? undefined,
2170
2174
  sha256: row.sha256 ?? undefined,
@@ -2183,6 +2187,10 @@ export interface InsertTaskAttachmentInput {
2183
2187
  url?: string;
2184
2188
  path?: string;
2185
2189
  pageId?: string;
2190
+ /** agent-fs only — paired with `driveId` to build a public live-host URL. */
2191
+ orgId?: string;
2192
+ /** agent-fs only — paired with `orgId` to build a public live-host URL. */
2193
+ driveId?: string;
2186
2194
  mimeType?: string;
2187
2195
  sizeBytes?: number;
2188
2196
  sha256?: string;
@@ -2247,6 +2255,8 @@ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAtta
2247
2255
  string | null,
2248
2256
  string | null,
2249
2257
  string | null,
2258
+ string | null,
2259
+ string | null,
2250
2260
  number | null,
2251
2261
  string | null,
2252
2262
  string | null,
@@ -2256,8 +2266,9 @@ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAtta
2256
2266
  >(
2257
2267
  `INSERT INTO task_attachments
2258
2268
  (id, task_id, agent_id, name, kind, url, path, page_id,
2269
+ agent_fs_org_id, agent_fs_drive_id,
2259
2270
  mime_type, size_bytes, sha256, intent, description, is_primary)
2260
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2271
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2261
2272
  RETURNING *`,
2262
2273
  )
2263
2274
  .get(
@@ -2269,6 +2280,8 @@ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAtta
2269
2280
  input.url ?? null,
2270
2281
  input.path ?? null,
2271
2282
  input.pageId ?? null,
2283
+ input.orgId ?? null,
2284
+ input.driveId ?? null,
2272
2285
  input.mimeType ?? null,
2273
2286
  input.sizeBytes ?? null,
2274
2287
  input.sha256 ?? null,
@@ -4893,8 +4906,8 @@ export function createScheduledTask(data: CreateScheduledTaskData): ScheduledTas
4893
4906
  export interface UpdateScheduledTaskData {
4894
4907
  name?: string;
4895
4908
  description?: string;
4896
- cronExpression?: string;
4897
- intervalMs?: number;
4909
+ cronExpression?: string | null;
4910
+ intervalMs?: number | null;
4898
4911
  taskTemplate?: string;
4899
4912
  taskType?: string;
4900
4913
  tags?: string[];
@@ -0,0 +1,15 @@
1
+ -- 073_task_attachments_agent_fs_ids.sql
2
+ -- Phase 2a follow-up: agent-fs `<org_id>/<drive_id>` columns on task_attachments.
3
+ --
4
+ -- The Phase 1 schema stores only the agent-fs `path`, so the Slack/UI renderers
5
+ -- have no way to build a public live-host URL like
6
+ -- ${AGENT_FS_LIVE_URL}/file/~/<org_id>/<drive_id>/<path>
7
+ -- and fall back to the opaque `agent-fs:<path>` string instead. This adds the
8
+ -- two missing columns so the renderers can emit clickable links.
9
+ --
10
+ -- Both columns are NULLABLE so existing rows stay valid and so non-agent-fs
11
+ -- kinds (`url`, `shared-fs`, `page`) are unaffected. No index — these columns
12
+ -- are always paired with a `task_id` filter (the resolver runs after the row
13
+ -- has already been located).
14
+ ALTER TABLE task_attachments ADD COLUMN agent_fs_org_id TEXT;
15
+ ALTER TABLE task_attachments ADD COLUMN agent_fs_drive_id TEXT;
@@ -0,0 +1,21 @@
1
+ export type MergedScheduleTiming = { mergedCron: string | null; mergedInterval: number | null };
2
+ export type ScheduleTimingPatch = { cronExpression?: string | null; intervalMs?: number | null };
3
+
4
+ export function mergeScheduleTiming(
5
+ existing: { cronExpression: string | null; intervalMs: number | null },
6
+ patch: ScheduleTimingPatch,
7
+ ): MergedScheduleTiming {
8
+ return {
9
+ mergedCron: patch.cronExpression !== undefined ? patch.cronExpression : existing.cronExpression,
10
+ mergedInterval: patch.intervalMs !== undefined ? patch.intervalMs : existing.intervalMs,
11
+ };
12
+ }
13
+
14
+ export type RecurringTimingError = { kind: "both-null" } | null;
15
+
16
+ export function validateRecurringTiming(merged: MergedScheduleTiming): RecurringTimingError {
17
+ if (merged.mergedCron === null && merged.mergedInterval === null) {
18
+ return { kind: "both-null" };
19
+ }
20
+ return null;
21
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Filesystem sync for skills.
3
3
  *
4
- * Writes installed skills to ~/.claude/skills/<name>/SKILL.md (and optionally
5
- * ~/.pi/agent/skills/<name>/SKILL.md) so Claude Code and Pi discover them
6
- * natively.
4
+ * Writes installed skills to ~/.claude/skills/<name>/SKILL.md,
5
+ * ~/.pi/agent/skills/<name>/SKILL.md, and ~/.codex/skills/<name>/SKILL.md
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
9
  */
@@ -19,6 +19,16 @@ export interface SkillSyncResult {
19
19
  errors: string[];
20
20
  }
21
21
 
22
+ /**
23
+ * Marker file written into every swarm-managed skill directory. Cleanup
24
+ * only ever removes directories that contain this marker, so unrelated
25
+ * personal skills the user installed via the harness's own tooling (e.g.
26
+ * `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
27
+ * untouched even when the API server shares a HOME with the worker (local
28
+ * dev). See `~/.codex/skills` blast-radius note in PR #555.
29
+ */
30
+ const SWARM_MARKER_FILE = ".swarm-managed";
31
+
22
32
  /**
23
33
  * Sync agent's installed skills to the filesystem.
24
34
  *
@@ -27,7 +37,7 @@ export interface SkillSyncResult {
27
37
  */
28
38
  export function syncSkillsToFilesystem(
29
39
  agentId: string,
30
- harnessType: "claude" | "pi" | "both" = "both",
40
+ harnessType: "claude" | "pi" | "codex" | "all" = "all",
31
41
  homeOverride?: string,
32
42
  ): SkillSyncResult {
33
43
  const skills = getAgentSkills(agentId);
@@ -37,12 +47,15 @@ export function syncSkillsToFilesystem(
37
47
 
38
48
  // Directories to write to
39
49
  const skillDirs: string[] = [];
40
- if (harnessType === "claude" || harnessType === "both") {
50
+ if (harnessType === "claude" || harnessType === "all") {
41
51
  skillDirs.push(join(home, ".claude", "skills"));
42
52
  }
43
- if (harnessType === "pi" || harnessType === "both") {
53
+ if (harnessType === "pi" || harnessType === "all") {
44
54
  skillDirs.push(join(home, ".pi", "agent", "skills"));
45
55
  }
56
+ if (harnessType === "codex" || harnessType === "all") {
57
+ skillDirs.push(join(home, ".codex", "skills"));
58
+ }
46
59
 
47
60
  // Ensure base dirs exist
48
61
  for (const dir of skillDirs) {
@@ -66,10 +79,12 @@ export function syncSkillsToFilesystem(
66
79
  for (const baseDir of skillDirs) {
67
80
  const skillDir = join(baseDir, safeName);
68
81
  const skillFile = join(skillDir, "SKILL.md");
82
+ const markerFile = join(skillDir, SWARM_MARKER_FILE);
69
83
 
70
84
  try {
71
85
  mkdirSync(skillDir, { recursive: true });
72
86
  writeFileSync(skillFile, skill.content, "utf-8");
87
+ writeFileSync(markerFile, "", "utf-8");
73
88
  synced++;
74
89
  } catch (err) {
75
90
  errors.push(
@@ -79,7 +94,10 @@ export function syncSkillsToFilesystem(
79
94
  }
80
95
  }
81
96
 
82
- // Cleanup: remove skill directories that are no longer installed
97
+ // Cleanup: only remove directories WE previously created (marker file
98
+ // present). Leaves user-installed personal skills alone — important on
99
+ // local dev where ~/.codex/skills holds skills the user installed
100
+ // outside the swarm.
83
101
  let removed = 0;
84
102
  for (const baseDir of skillDirs) {
85
103
  if (!existsSync(baseDir)) continue;
@@ -87,14 +105,15 @@ export function syncSkillsToFilesystem(
87
105
  try {
88
106
  const existing = readdirSync(baseDir, { withFileTypes: true });
89
107
  for (const entry of existing) {
90
- if (entry.isDirectory() && !writtenNames.has(entry.name)) {
91
- const skillDir = join(baseDir, entry.name);
92
- try {
93
- rmSync(skillDir, { recursive: true, force: true });
94
- removed++;
95
- } catch {
96
- // Non-fatal — skip cleanup errors
97
- }
108
+ if (!entry.isDirectory()) continue;
109
+ if (writtenNames.has(entry.name)) continue;
110
+ const skillDir = join(baseDir, entry.name);
111
+ if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
112
+ try {
113
+ rmSync(skillDir, { recursive: true, force: true });
114
+ removed++;
115
+ } catch {
116
+ // Non-fatal — skip cleanup errors
98
117
  }
99
118
  }
100
119
  } catch {
@@ -104,3 +123,34 @@ export function syncSkillsToFilesystem(
104
123
 
105
124
  return { synced, removed, errors };
106
125
  }
126
+
127
+ export interface SkillsSignature {
128
+ hash: string;
129
+ count: number;
130
+ }
131
+
132
+ /**
133
+ * Compute a stable signature over an agent's installed-and-enabled skill set.
134
+ *
135
+ * Hash inputs are the per-row mutation-tracking fields — any install,
136
+ * uninstall, toggle, or skill-update mutates at least one of them. Output is
137
+ * deterministic and contains no timestamps beyond per-row mutation fields.
138
+ */
139
+ export function computeAgentSkillsSignature(agentId: string): SkillsSignature {
140
+ const skills = getAgentSkills(agentId);
141
+ const sorted = [...skills].sort((a, b) => a.id.localeCompare(b.id));
142
+ const canonical = JSON.stringify(
143
+ sorted.map((s) => [
144
+ s.id,
145
+ s.name,
146
+ s.version,
147
+ s.isEnabled,
148
+ s.isActive,
149
+ s.lastUpdatedAt,
150
+ s.sourceHash ?? "",
151
+ s.installedAt,
152
+ ]),
153
+ );
154
+ const hash = new Bun.CryptoHasher("sha256").update(canonical).digest("hex");
155
+ return { hash, count: sorted.length };
156
+ }
@@ -238,6 +238,7 @@ function parseCodexOAuthAccess(blob: string | undefined): string | null {
238
238
  * | `codex` | `~/.codex/auth.json` (file) → `CODEX_OAUTH` (env OAuth) → `OPENAI_API_KEY` | OpenAI `/v1/models` (api-key path only) |
239
239
  * | `opencode` | `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` (pi-style) | matching provider's `/v1/models` |
240
240
  * | `pi` | `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` | matching provider's `/v1/models` |
241
+ * | `pi` (bedrock) | `MODEL_OVERRIDE=amazon-bedrock/*` → AWS SDK default credential chain | presence-only (validated at first inference call) |
241
242
  * | `devin` | `DEVIN_API_KEY` (+ `DEVIN_API_BASE_URL` override) | `${baseUrl}/v1/sessions?limit=1` |
242
243
  *
243
244
  * Returns `{ok: true, latency_ms}` on 2xx, `{ok: false, error, latency_ms}`
@@ -296,6 +297,16 @@ export async function validateProviderCredentials(provider: string): Promise<Liv
296
297
  }
297
298
  case "pi":
298
299
  case "opencode": {
300
+ // pi-mono with MODEL_OVERRIDE=amazon-bedrock/* delegates credential
301
+ // resolution to the AWS SDK default chain (env, ~/.aws/*, SSO, IMDS,
302
+ // assume-role, …). pi-ai exposes no Bedrock-specific check we could
303
+ // call here, and the SDK chain may issue slow IMDS network calls on
304
+ // non-EC2 hosts — so the live test is a presence check, mirroring the
305
+ // codex-OAuth pattern above. Real validation happens at the first
306
+ // Bedrock inference call.
307
+ if (provider === "pi" && env.MODEL_OVERRIDE?.toLowerCase().startsWith("amazon-bedrock/")) {
308
+ return presenceCheckOk();
309
+ }
299
310
  // Both pi-mono and opencode resolve credentials in the same order:
300
311
  // OPENROUTER → ANTHROPIC → OPENAI. Live-test against the matching
301
312
  // provider's models endpoint.
@@ -42,6 +42,7 @@ import { parseRateLimitResetTime } from "../utils/error-tracker.ts";
42
42
  import { resolveHarnessProvider } from "../utils/harness-provider.ts";
43
43
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
44
44
  import { scrubSecrets } from "../utils/secret-scrubber.ts";
45
+ import { refreshSkillsIfChanged } from "../utils/skills-refresh.ts";
45
46
  import { detectVcsProvider } from "../vcs/index.ts";
46
47
  import { interpolate } from "../workflows/template.ts";
47
48
  import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
@@ -3511,34 +3512,6 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3511
3512
  }
3512
3513
  }
3513
3514
 
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
3515
  // Fetch installed MCP servers for system prompt
3543
3516
  try {
3544
3517
  const mcpServersResp = await fetch(`${apiUrl}/api/agents/${agentId}/mcp-servers`, {
@@ -3649,35 +3622,32 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3649
3622
  }
3650
3623
  }
3651
3624
 
3652
- // ========== Sync skills to filesystem ==========
3653
- try {
3625
+ // ========== Boot-time skill load (signature-gated, replaces the standalone
3626
+ // skill-fetch + FS sync blocks). The polling loop below calls the same
3627
+ // helper per task to hot-reload skills mid-flight. Skipped for
3628
+ // `claude-managed` (cloud sandbox owns skill delivery).
3629
+ const lastSkillHash: { current: string | null } = { current: null };
3630
+ if (state.harnessProvider !== "claude-managed") {
3654
3631
  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(", ")}`);
3632
+ const skillResult = await refreshSkillsIfChanged(
3633
+ { apiUrl, swarmUrl, apiKey, agentId, role },
3634
+ lastSkillHash,
3635
+ );
3636
+ if (skillResult.changed && skillResult.summary) {
3637
+ agentSkillsSummary = skillResult.summary;
3638
+ if (agentSkillsSummary.length > 0) {
3639
+ console.log(`[${role}] Loaded ${agentSkillsSummary.length} skills for system prompt`);
3675
3640
  }
3676
- } else {
3677
- console.warn(`[${role}] Skill sync failed: HTTP ${syncRes.status}`);
3641
+ // Rebuild base prompt now that we have skills.
3642
+ basePrompt = await buildSystemPrompt();
3643
+ resolvedSystemPrompt = additionalSystemPrompt
3644
+ ? `${basePrompt}\n\n${additionalSystemPrompt}`
3645
+ : basePrompt;
3678
3646
  }
3679
- } catch (err) {
3680
- console.warn(`[${role}] Skill sync failed: ${(err as Error).message}`);
3647
+ } else {
3648
+ console.log(
3649
+ `[${role}] Skipping skill sync (claude-managed reads skills from agent definition)`,
3650
+ );
3681
3651
  }
3682
3652
 
3683
3653
  // ========== Resume paused tasks with PRIORITY ==========
@@ -4168,6 +4138,23 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4168
4138
  cwdWarning = `\n\nNote: The task requested working directory "${taskDir}" but it does not exist. Falling back to default directory.`;
4169
4139
  }
4170
4140
 
4141
+ // Per-task skill hot-reload. Reuses the boot-time helper; signature
4142
+ // probe short-circuits when nothing changed. Skipped for
4143
+ // `claude-managed`. Read state.harnessProvider live so an adapter
4144
+ // swap mid-loop honors the new provider.
4145
+ if (state.harnessProvider !== "claude-managed") {
4146
+ const skillResult = await refreshSkillsIfChanged(
4147
+ { apiUrl, swarmUrl, apiKey, agentId, role },
4148
+ lastSkillHash,
4149
+ );
4150
+ if (skillResult.changed && skillResult.summary) {
4151
+ agentSkillsSummary = skillResult.summary;
4152
+ console.log(
4153
+ `[${role}] Skills changed — refreshing system prompt (${agentSkillsSummary.length} skills)`,
4154
+ );
4155
+ }
4156
+ }
4157
+
4171
4158
  // Rebuild system prompt with per-task repo context
4172
4159
  const taskBasePrompt = await buildSystemPrompt();
4173
4160
  const taskSystemPrompt =
@@ -11,6 +11,7 @@ import {
11
11
  getScheduledTasks,
12
12
  updateScheduledTask,
13
13
  } from "../be/db";
14
+ import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
14
15
  import { calculateNextRun } from "../scheduler/scheduler";
15
16
  import { scheduleContextKey } from "../tasks/context-key";
16
17
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
@@ -115,8 +116,8 @@ const updateSchedule = route({
115
116
  body: z.object({
116
117
  name: z.string().optional(),
117
118
  description: z.string().optional(),
118
- cronExpression: z.string().optional(),
119
- intervalMs: z.number().int().optional(),
119
+ cronExpression: z.string().nullable().optional(),
120
+ intervalMs: z.number().int().positive().nullable().optional(),
120
121
  taskTemplate: z.string().optional(),
121
122
  taskType: z.string().optional(),
122
123
  tags: z.array(z.string()).optional(),
@@ -400,6 +401,22 @@ export async function handleSchedules(
400
401
  return true;
401
402
  }
402
403
 
404
+ // Validate merged timing state — catches cases where one side is null in the DB
405
+ // and the patch nulls the other, which the schema-level check cannot see.
406
+ if (existing.scheduleType !== "one_time") {
407
+ const timing = mergeScheduleTiming(
408
+ {
409
+ cronExpression: existing.cronExpression ?? null,
410
+ intervalMs: existing.intervalMs ?? null,
411
+ },
412
+ { cronExpression: parsed.body.cronExpression, intervalMs: parsed.body.intervalMs },
413
+ );
414
+ if (validateRecurringTiming(timing)) {
415
+ jsonError(res, "At least one of intervalMs or cronExpression must be set", 400);
416
+ return true;
417
+ }
418
+ }
419
+
403
420
  if (parsed.body.cronExpression) {
404
421
  try {
405
422
  CronExpressionParser.parse(parsed.body.cronExpression);
@@ -439,14 +456,22 @@ export async function handleSchedules(
439
456
  parsed.body.intervalMs !== undefined ||
440
457
  (parsed.body.enabled === true && !existing.enabled)
441
458
  ) {
442
- const merged = {
443
- cronExpression: parsed.body.cronExpression ?? existing.cronExpression,
444
- intervalMs: parsed.body.intervalMs ?? existing.intervalMs,
445
- timezone: parsed.body.timezone ?? existing.timezone,
446
- };
447
- if (merged.cronExpression || merged.intervalMs) {
459
+ const timing = mergeScheduleTiming(
460
+ {
461
+ cronExpression: existing.cronExpression ?? null,
462
+ intervalMs: existing.intervalMs ?? null,
463
+ },
464
+ { cronExpression: parsed.body.cronExpression, intervalMs: parsed.body.intervalMs },
465
+ );
466
+ const mergedTimezone =
467
+ parsed.body.timezone !== undefined ? parsed.body.timezone : existing.timezone;
468
+ if (timing.mergedCron || timing.mergedInterval) {
448
469
  // biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
449
- body.nextRunAt = calculateNextRun(merged as any);
470
+ body.nextRunAt = calculateNextRun({
471
+ cronExpression: timing.mergedCron,
472
+ intervalMs: timing.mergedInterval,
473
+ timezone: mergedTimezone,
474
+ } as any);
450
475
  }
451
476
  }
452
477
  }
@@ -12,7 +12,7 @@ import {
12
12
  updateSkill,
13
13
  } from "../be/db";
14
14
  import { parseSkillContent } from "../be/skill-parser";
15
- import { syncSkillsToFilesystem } from "../be/skill-sync";
15
+ import { computeAgentSkillsSignature, syncSkillsToFilesystem } from "../be/skill-sync";
16
16
  import { route } from "./route-def";
17
17
  import { json, jsonError } from "./utils";
18
18
 
@@ -193,6 +193,21 @@ const getAgentSkillsRoute = route({
193
193
  },
194
194
  });
195
195
 
196
+ const getAgentSkillsSignatureRoute = route({
197
+ method: "get",
198
+ path: "/api/agents/{id}/skills/signature",
199
+ pattern: ["api", "agents", null, "skills", "signature"],
200
+ summary: "Compute a stable signature over an agent's installed skills",
201
+ description:
202
+ "Returns a sha256 hash over per-row mutation fields of the agent's active+enabled skill set. Workers poll this to detect skill changes cheaply without fetching the full list.",
203
+ tags: ["Skills"],
204
+ auth: { apiKey: true },
205
+ params: z.object({ id: z.string() }),
206
+ responses: {
207
+ 200: { description: "Skills signature" },
208
+ },
209
+ });
210
+
196
211
  // ─── Handler ─────────────────────────────────────────────────────────────────
197
212
 
198
213
  export async function handleSkills(
@@ -202,12 +217,22 @@ export async function handleSkills(
202
217
  queryParams: URLSearchParams,
203
218
  myAgentId: string | undefined,
204
219
  ): Promise<boolean> {
220
+ // GET /api/agents/:id/skills/signature (must come before the shorter pattern)
221
+ if (getAgentSkillsSignatureRoute.match(req.method, pathSegments)) {
222
+ const parsed = await getAgentSkillsSignatureRoute.parse(req, res, pathSegments, queryParams);
223
+ if (!parsed) return true;
224
+ const sig = computeAgentSkillsSignature(parsed.params.id);
225
+ json(res, { hash: sig.hash, count: sig.count, generatedAt: new Date().toISOString() });
226
+ return true;
227
+ }
228
+
205
229
  // GET /api/agents/:id/skills (must be before /api/skills routes)
206
230
  if (getAgentSkillsRoute.match(req.method, pathSegments)) {
207
231
  const parsed = await getAgentSkillsRoute.parse(req, res, pathSegments, queryParams);
208
232
  if (!parsed) return true;
209
233
  const skills = getAgentSkills(parsed.params.id);
210
- json(res, { skills, total: skills.length });
234
+ const signature = computeAgentSkillsSignature(parsed.params.id).hash;
235
+ json(res, { skills, total: skills.length, signature });
211
236
  return true;
212
237
  }
213
238