@desplega.ai/agent-swarm 1.92.2 → 1.94.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 (122) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +242 -3
  3. package/package.json +5 -5
  4. package/src/be/db.ts +152 -11
  5. package/src/be/memory/boot-reembed.ts +0 -1
  6. package/src/be/memory/providers/sqlite-store.ts +42 -25
  7. package/src/be/memory/raters/llm-client.ts +12 -5
  8. package/src/be/memory/types.ts +3 -0
  9. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  10. package/src/be/migrations/089_harness_variant.sql +2 -0
  11. package/src/be/migrations/090_model_tiers.sql +2 -0
  12. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  13. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  14. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  15. package/src/be/migrations/runner.ts +52 -0
  16. package/src/be/modelsdev-cache.json +3264 -1166
  17. package/src/be/scripts/boot-reembed.ts +74 -0
  18. package/src/be/scripts/db.ts +19 -3
  19. package/src/be/seed/index.ts +1 -1
  20. package/src/be/seed/registry.ts +2 -2
  21. package/src/be/seed/runner.ts +5 -5
  22. package/src/be/seed/types.ts +6 -1
  23. package/src/be/seed-pricing.ts +2 -0
  24. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  25. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  26. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  27. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  28. package/src/be/seed-scripts/index.ts +8 -7
  29. package/src/be/skill-sync.ts +28 -179
  30. package/src/commands/runner.ts +197 -10
  31. package/src/http/api-keys.ts +42 -0
  32. package/src/http/index.ts +13 -2
  33. package/src/http/mcp-bridge.ts +1 -1
  34. package/src/http/memory.ts +23 -24
  35. package/src/http/metrics.ts +55 -6
  36. package/src/http/schedules.ts +16 -15
  37. package/src/http/script-runs.ts +7 -1
  38. package/src/http/scripts.ts +147 -1
  39. package/src/http/tasks.ts +17 -6
  40. package/src/model-tiers.ts +140 -0
  41. package/src/providers/claude-adapter.ts +33 -1
  42. package/src/providers/claude-managed-adapter.ts +3 -0
  43. package/src/providers/claude-managed-models.ts +16 -0
  44. package/src/providers/codex-adapter.ts +8 -1
  45. package/src/providers/codex-models.ts +1 -0
  46. package/src/providers/codex-oauth/auth-json.ts +1 -0
  47. package/src/providers/harness-version.ts +7 -0
  48. package/src/providers/opencode-adapter.ts +12 -4
  49. package/src/providers/pi-mono-adapter.ts +90 -8
  50. package/src/providers/types.ts +2 -0
  51. package/src/scheduler/scheduler.ts +22 -34
  52. package/src/scripts-runtime/egress-secrets.ts +83 -0
  53. package/src/scripts-runtime/eval-harness.ts +4 -0
  54. package/src/scripts-runtime/executors/types.ts +7 -0
  55. package/src/scripts-runtime/loader.ts +2 -0
  56. package/src/server-user.ts +8 -2
  57. package/src/slack/channel-join.ts +41 -0
  58. package/src/slack/responses.ts +39 -11
  59. package/src/slack/watcher.ts +121 -8
  60. package/src/tests/additive-buffer.test.ts +0 -1
  61. package/src/tests/agents-list-model-display.test.ts +13 -0
  62. package/src/tests/api-key-tracking.test.ts +113 -0
  63. package/src/tests/approval-requests.test.ts +0 -6
  64. package/src/tests/aws-error-classifier.test.ts +148 -0
  65. package/src/tests/claude-managed-adapter.test.ts +12 -0
  66. package/src/tests/claude-managed-setup.test.ts +0 -4
  67. package/src/tests/codex-pool.test.ts +2 -6
  68. package/src/tests/context-window.test.ts +7 -0
  69. package/src/tests/http-api-integration.test.ts +23 -6
  70. package/src/tests/memory-edges.test.ts +0 -2
  71. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  72. package/src/tests/memory-rater-e2e.test.ts +0 -2
  73. package/src/tests/memory-store.test.ts +19 -1
  74. package/src/tests/memory.test.ts +51 -0
  75. package/src/tests/metrics-http.test.ts +137 -3
  76. package/src/tests/migration-046-budgets.test.ts +33 -0
  77. package/src/tests/migration-runner-regressions.test.ts +69 -0
  78. package/src/tests/model-control.test.ts +162 -46
  79. package/src/tests/opencode-adapter.test.ts +9 -0
  80. package/src/tests/pi-mono-adapter.test.ts +319 -0
  81. package/src/tests/providers/pi-cost.test.ts +9 -0
  82. package/src/tests/reload-config.test.ts +33 -17
  83. package/src/tests/runner-fallback-output.test.ts +50 -0
  84. package/src/tests/runner-skills-refresh.test.ts +216 -46
  85. package/src/tests/script-runs-http.test.ts +7 -1
  86. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  87. package/src/tests/scripts-embeddings.test.ts +90 -0
  88. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  89. package/src/tests/seed-scripts.test.ts +13 -1
  90. package/src/tests/seed.test.ts +26 -1
  91. package/src/tests/session-attach.test.ts +6 -6
  92. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  93. package/src/tests/skill-fs-writer.test.ts +250 -0
  94. package/src/tests/slack-attachments-block.test.ts +0 -1
  95. package/src/tests/slack-blocks.test.ts +0 -1
  96. package/src/tests/slack-channel-join.test.ts +80 -0
  97. package/src/tests/slack-identity-resolution.test.ts +0 -1
  98. package/src/tests/slack-watcher.test.ts +66 -0
  99. package/src/tests/structured-output.test.ts +0 -2
  100. package/src/tests/use-dismissible-card.test.ts +0 -4
  101. package/src/tests/workflow-agent-task.test.ts +5 -2
  102. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  103. package/src/tools/memory-get.ts +11 -0
  104. package/src/tools/memory-search.ts +18 -0
  105. package/src/tools/schedules/create-schedule.ts +71 -70
  106. package/src/tools/schedules/update-schedule.ts +43 -31
  107. package/src/tools/send-task.ts +16 -5
  108. package/src/tools/slack-post.ts +18 -15
  109. package/src/tools/slack-read.ts +9 -11
  110. package/src/tools/slack-reply.ts +18 -15
  111. package/src/tools/slack-start-thread.ts +17 -14
  112. package/src/tools/task-action.ts +11 -3
  113. package/src/types.ts +40 -0
  114. package/src/utils/aws-error-classifier.ts +97 -0
  115. package/src/utils/context-window.ts +5 -0
  116. package/src/utils/credentials.test.ts +68 -0
  117. package/src/utils/credentials.ts +66 -5
  118. package/src/utils/pretty-print.ts +25 -10
  119. package/src/utils/skill-fs-writer.ts +220 -0
  120. package/src/utils/skills-refresh.ts +123 -40
  121. package/src/workflows/engine.ts +3 -2
  122. package/src/workflows/executors/agent-task.ts +3 -1
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
3
  CREDENTIAL_POOL_VARS,
4
+ getModelAwareCredentialVars,
4
5
  resolveCredentialPools,
5
6
  selectRandomCredential,
6
7
  validateClaudeCredentials,
@@ -154,3 +155,70 @@ describe("CREDENTIAL_POOL_VARS", () => {
154
155
  expect(CREDENTIAL_POOL_VARS).toContain("ANTHROPIC_API_KEY");
155
156
  });
156
157
  });
158
+
159
+ describe("getModelAwareCredentialVars", () => {
160
+ it("excludes OPENAI_API_KEY for opencode with OpenRouter model (slash in name)", () => {
161
+ const vars = getModelAwareCredentialVars("opencode", "google/gemini-3-flash-preview");
162
+ expect(vars).toContain("OPENROUTER_API_KEY");
163
+ expect(vars).toContain("ANTHROPIC_API_KEY");
164
+ expect(vars).not.toContain("OPENAI_API_KEY");
165
+ });
166
+
167
+ it("keeps OPENAI_API_KEY for opencode with direct OpenAI model (no slash)", () => {
168
+ const vars = getModelAwareCredentialVars("opencode", "gpt-4o");
169
+ expect(vars).toContain("OPENROUTER_API_KEY");
170
+ expect(vars).toContain("ANTHROPIC_API_KEY");
171
+ expect(vars).toContain("OPENAI_API_KEY");
172
+ });
173
+
174
+ it("keeps OPENAI_API_KEY for opencode when model is empty", () => {
175
+ const vars = getModelAwareCredentialVars("opencode", "");
176
+ expect(vars).toContain("OPENAI_API_KEY");
177
+ });
178
+
179
+ it("keeps OPENAI_API_KEY for opencode when model is undefined", () => {
180
+ const vars = getModelAwareCredentialVars("opencode");
181
+ expect(vars).toContain("OPENAI_API_KEY");
182
+ });
183
+
184
+ it("excludes OPENAI_API_KEY for pi with OpenRouter model (slash in name)", () => {
185
+ const vars = getModelAwareCredentialVars("pi", "anthropic/claude-sonnet-4-20250514");
186
+ expect(vars).toContain("OPENROUTER_API_KEY");
187
+ expect(vars).toContain("ANTHROPIC_API_KEY");
188
+ expect(vars).not.toContain("OPENAI_API_KEY");
189
+ });
190
+
191
+ it("keeps OPENAI_API_KEY for pi with direct model (no slash)", () => {
192
+ const vars = getModelAwareCredentialVars("pi", "gpt-4o");
193
+ expect(vars).toContain("OPENROUTER_API_KEY");
194
+ expect(vars).toContain("ANTHROPIC_API_KEY");
195
+ expect(vars).toContain("OPENAI_API_KEY");
196
+ });
197
+
198
+ it("keeps OPENAI_API_KEY for pi when model is undefined", () => {
199
+ const vars = getModelAwareCredentialVars("pi");
200
+ expect(vars).toContain("OPENAI_API_KEY");
201
+ });
202
+
203
+ it("returns static list for non-slash providers regardless of model", () => {
204
+ const vars = getModelAwareCredentialVars("claude", "google/gemini-3-flash-preview");
205
+ expect(vars).toContain("CLAUDE_CODE_OAUTH_TOKEN");
206
+ expect(vars).toContain("ANTHROPIC_API_KEY");
207
+ expect(vars).not.toContain("OPENAI_API_KEY");
208
+ });
209
+
210
+ it("falls back to all pool vars for unknown provider", () => {
211
+ const vars = getModelAwareCredentialVars("unknown-provider", "some-model");
212
+ expect(vars).toEqual(CREDENTIAL_POOL_VARS);
213
+ });
214
+
215
+ it("handles OpenRouter-prefixed models", () => {
216
+ const vars = getModelAwareCredentialVars("opencode", "openrouter/openai/gpt-4o");
217
+ expect(vars).not.toContain("OPENAI_API_KEY");
218
+ });
219
+
220
+ it("handles Anthropic-prefixed models via OpenRouter", () => {
221
+ const vars = getModelAwareCredentialVars("opencode", "anthropic/claude-sonnet-4-20250514");
222
+ expect(vars).not.toContain("OPENAI_API_KEY");
223
+ });
224
+ });
@@ -23,13 +23,47 @@ export const CREDENTIAL_POOL_VARS = [
23
23
  */
24
24
  export const PROVIDER_CREDENTIAL_VARS: Record<string, readonly string[]> = {
25
25
  claude: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
26
- // pi-mono accepts either router or anthropic keys
27
- pi: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY"],
26
+ pi: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
28
27
  codex: ["OPENAI_API_KEY", "CODEX_OAUTH"],
29
28
  devin: ["DEVIN_API_KEY"],
30
29
  opencode: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
31
30
  };
32
31
 
32
+ /**
33
+ * Providers where models use `provider_id/model_id` format — a slash in the
34
+ * model string means the model is routed through an upstream provider (e.g.
35
+ * OpenRouter) and OPENAI_API_KEY must not be selected (it would auth against
36
+ * the wrong endpoint). Without a slash the model targets a direct API (e.g.
37
+ * OpenAI) and OPENAI_API_KEY is valid.
38
+ *
39
+ * Both opencode and pi follow this convention:
40
+ * - opencode: https://opencode.ai/docs/models/
41
+ * - pi: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/models.md
42
+ */
43
+ const SLASH_MODEL_PROVIDERS = new Set(["opencode", "pi"]);
44
+
45
+ /**
46
+ * Given a provider and model string, return the credential vars that are
47
+ * actually relevant. This implements the harness × model matrix constraint:
48
+ *
49
+ * - (opencode | pi) + model with "/" (e.g. "google/gemini-3-flash-preview",
50
+ * "openrouter/openai/gpt-4o"): the model is routed through OpenRouter →
51
+ * OPENAI_API_KEY must not be selected.
52
+ * - (opencode | pi) + model without "/" (e.g. "gpt-4o", "o3-mini") or empty:
53
+ * the model targets a direct API → keep all creds including OPENAI_API_KEY.
54
+ *
55
+ * All other providers return their static list unchanged.
56
+ */
57
+ export function getModelAwareCredentialVars(provider: string, model?: string): readonly string[] {
58
+ const base = PROVIDER_CREDENTIAL_VARS[provider];
59
+ if (!base) return CREDENTIAL_POOL_VARS;
60
+ if (!SLASH_MODEL_PROVIDERS.has(provider) || !model) return base;
61
+ if (model.includes("/")) {
62
+ return base.filter((v) => v !== "OPENAI_API_KEY");
63
+ }
64
+ return base;
65
+ }
66
+
33
67
  /**
34
68
  * Derive a canonical harness provider from a credential env var name. Used
35
69
  * by the api_key_status table's `provider` column so the dashboard can
@@ -64,6 +98,8 @@ export interface CredentialSelection {
64
98
  keySuffix: string;
65
99
  /** Which credential pool env var this selection came from */
66
100
  keyType: string;
101
+ /** True when all indices for this keyType were rate-limited (best-effort pick) */
102
+ isRateLimitFallback: boolean;
67
103
  }
68
104
 
69
105
  /**
@@ -82,10 +118,19 @@ export function selectCredential(
82
118
  .filter(Boolean);
83
119
  if (credentials.length <= 1) {
84
120
  const selected = value.trim();
85
- return { selected, index: 0, total: 1, keySuffix: selected.slice(-5), keyType };
121
+ const isRateLimitFallback = availableIndices !== undefined && availableIndices.length === 0;
122
+ return {
123
+ selected,
124
+ index: 0,
125
+ total: 1,
126
+ keySuffix: selected.slice(-5),
127
+ keyType,
128
+ isRateLimitFallback,
129
+ };
86
130
  }
87
131
 
88
132
  let index: number;
133
+ let isRateLimitFallback = false;
89
134
  if (availableIndices && availableIndices.length > 0) {
90
135
  // Pick randomly from available (non-rate-limited) indices
91
136
  const validIndices = availableIndices.filter((i) => i >= 0 && i < credentials.length);
@@ -94,17 +139,26 @@ export function selectCredential(
94
139
  } else {
95
140
  // All available indices out of range — fall back to random from all
96
141
  index = Math.floor(Math.random() * credentials.length);
142
+ isRateLimitFallback = true;
97
143
  }
98
144
  } else if (availableIndices && availableIndices.length === 0) {
99
145
  // All keys are rate-limited — pick randomly anyway (best effort)
100
146
  index = Math.floor(Math.random() * credentials.length);
147
+ isRateLimitFallback = true;
101
148
  } else {
102
149
  // No availability info — pure random (backward compatible)
103
150
  index = Math.floor(Math.random() * credentials.length);
104
151
  }
105
152
 
106
153
  const selected = credentials[index]!;
107
- return { selected, index, total: credentials.length, keySuffix: selected.slice(-5), keyType };
154
+ return {
155
+ selected,
156
+ index,
157
+ total: credentials.length,
158
+ keySuffix: selected.slice(-5),
159
+ keyType,
160
+ isRateLimitFallback,
161
+ };
108
162
  }
109
163
 
110
164
  /**
@@ -208,10 +262,17 @@ export async function resolveCredentialPools(
208
262
  * container env. Defaults to ALL pool vars for backwards compatibility.
209
263
  */
210
264
  provider?: string;
265
+ /**
266
+ * Optional model string (e.g. "google/gemini-3-flash-preview", "gpt-4o").
267
+ * Used together with `provider` to apply the harness × model matrix:
268
+ * an OpenRouter-routed model (contains "/") on the opencode harness must
269
+ * not select OPENAI_API_KEY, while a direct OpenAI model may.
270
+ */
271
+ model?: string;
211
272
  },
212
273
  ): Promise<CredentialSelection[]> {
213
274
  const providerVars = opts?.provider
214
- ? (PROVIDER_CREDENTIAL_VARS[opts.provider] ?? CREDENTIAL_POOL_VARS)
275
+ ? getModelAwareCredentialVars(opts.provider, opts.model)
215
276
  : CREDENTIAL_POOL_VARS;
216
277
 
217
278
  const availableIndicesMap =
@@ -87,6 +87,17 @@ export function prettyPrintLine(line: string, role: string): void {
87
87
  const prefix = `${c.dim}[${role}]${c.reset}`;
88
88
 
89
89
  switch (type) {
90
+ case "session_init": {
91
+ const provider = json.provider as string;
92
+ const variant = json.harnessVariant as string;
93
+ const meta = json.harnessVariantMeta as Record<string, unknown> | undefined;
94
+ const version = meta?.version ?? "";
95
+ console.log(
96
+ `${prefix} ${c.cyan}●${c.reset} ${c.bold}Session started${c.reset} ${c.dim}(${provider}${variant ? ` ${variant}` : ""}${version ? ` v${version}` : ""})${c.reset}`,
97
+ );
98
+ break;
99
+ }
100
+
90
101
  case "system": {
91
102
  const subtype = json.subtype as string;
92
103
  if (subtype === "init") {
@@ -190,20 +201,24 @@ export function prettyPrintLine(line: string, role: string): void {
190
201
  }
191
202
 
192
203
  case "result": {
193
- const subtype = json.subtype as string;
194
- const isError = json.is_error as boolean;
195
- const duration = json.duration_ms as number;
196
- const cost = json.total_cost_usd as number;
197
- const numTurns = json.num_turns as number;
198
- const result = json.result as string;
204
+ // Claude CLI emits flat fields (subtype, num_turns, duration_ms, total_cost_usd).
205
+ // Non-Claude adapters (opencode, pi) emit a nested `cost` object with
206
+ // CostData fields. Handle both formats gracefully.
207
+ const costObj = json.cost as Record<string, unknown> | undefined;
208
+ const subtype = (json.subtype as string) ?? (json.isError ? "error" : "success");
209
+ const isError = (json.is_error as boolean) ?? (json.isError as boolean) ?? false;
210
+ const duration = (json.duration_ms as number) ?? (costObj?.durationMs as number);
211
+ const costUsd = (json.total_cost_usd as number) ?? (costObj?.totalCostUsd as number);
212
+ const numTurns = (json.num_turns as number) ?? (costObj?.numTurns as number | null);
213
+ const result = (json.result as string) ?? (json.output as string);
199
214
 
200
215
  const icon = isError ? `${c.red}✗${c.reset}` : `${c.green}✓${c.reset}`;
201
216
  const durationStr = duration ? `${(duration / 1000).toFixed(1)}s` : "";
202
- const costStr = cost ? `$${cost.toFixed(4)}` : "";
217
+ const costStr = costUsd ? `$${costUsd.toFixed(4)}` : "";
218
+ const turnsStr = numTurns != null ? `${numTurns} turns` : "";
203
219
 
204
- console.log(
205
- `${prefix} ${icon} ${c.bold}Done${c.reset} ${c.dim}(${subtype}, ${numTurns} turns, ${durationStr}, ${costStr})${c.reset}`,
206
- );
220
+ const details = [subtype, turnsStr, durationStr, costStr].filter(Boolean).join(", ");
221
+ console.log(`${prefix} ${icon} ${c.bold}Done${c.reset} ${c.dim}(${details})${c.reset}`);
207
222
 
208
223
  if (result) {
209
224
  const lines = result.split("\n").filter((l) => l.trim());
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Pure, DB-free filesystem writer for agent skills.
3
+ *
4
+ * Worker-safe: imports only node:fs / node:os / node:path — no be/db, no bun:sqlite.
5
+ *
6
+ * Shared by:
7
+ * - API-side: syncSkillsToFilesystem (src/be/skill-sync.ts) which fetches
8
+ * SkillFsEntry data from the DB then delegates here.
9
+ * - Worker-side: refreshSkillsIfChanged (src/utils/skills-refresh.ts) which
10
+ * fetches SkillFsEntry data over HTTP then calls writeSkillsToFilesystem
11
+ * with the worker's own homedir(), writing SKILL.md files to the correct
12
+ * machine instead of the API box.
13
+ */
14
+
15
+ import type { Dirent } from "node:fs";
16
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+
19
+ export interface SkillSyncResult {
20
+ synced: number;
21
+ removed: number;
22
+ errors: string[];
23
+ }
24
+
25
+ export interface SkillFsEntry {
26
+ id: string;
27
+ name: string;
28
+ content: string | null;
29
+ isComplex: boolean;
30
+ isEnabled: boolean;
31
+ isActive: boolean;
32
+ files: { path: string; content: string; isBinary: boolean }[];
33
+ }
34
+
35
+ /**
36
+ * Marker file written into every swarm-managed skill directory. Cleanup
37
+ * only ever removes directories that contain this marker, so unrelated
38
+ * personal skills the user installed via the harness's own tooling (e.g.
39
+ * `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
40
+ * untouched even when the API server shares a HOME with the worker (local
41
+ * dev). See `~/.codex/skills` blast-radius note in PR #555.
42
+ */
43
+ export const SWARM_MARKER_FILE = ".swarm-managed";
44
+
45
+ function reconcileManagedSkillFiles(skillDir: string, currentRelativeFiles: Set<string>): number {
46
+ if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) return 0;
47
+
48
+ let removed = 0;
49
+
50
+ const walk = (dir: string, relativeDir = ""): boolean => {
51
+ let entries: Dirent[];
52
+ try {
53
+ entries = readdirSync(dir, { withFileTypes: true });
54
+ } catch {
55
+ return false;
56
+ }
57
+
58
+ let hasEntries = false;
59
+ for (const entry of entries) {
60
+ const relativePath = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
61
+ const fullPath = join(dir, entry.name);
62
+
63
+ if (entry.isDirectory()) {
64
+ const childHasEntries = walk(fullPath, relativePath);
65
+ if (!childHasEntries) {
66
+ try {
67
+ rmSync(fullPath, { recursive: true, force: true });
68
+ } catch {
69
+ hasEntries = true;
70
+ }
71
+ } else {
72
+ hasEntries = true;
73
+ }
74
+ continue;
75
+ }
76
+
77
+ if (
78
+ relativePath === "SKILL.md" ||
79
+ relativePath === SWARM_MARKER_FILE ||
80
+ currentRelativeFiles.has(relativePath)
81
+ ) {
82
+ hasEntries = true;
83
+ continue;
84
+ }
85
+
86
+ try {
87
+ rmSync(fullPath, { force: true });
88
+ removed++;
89
+ } catch {
90
+ hasEntries = true;
91
+ }
92
+ }
93
+
94
+ return hasEntries;
95
+ };
96
+
97
+ walk(skillDir);
98
+ return removed;
99
+ }
100
+
101
+ /**
102
+ * Write skill entries to the filesystem under the given home directory.
103
+ *
104
+ * For simple skills (non-complex): writes SKILL.md only.
105
+ * For DB-backed complex skills: writes SKILL.md plus bundled files.
106
+ * Skips legacy complex skills with no files (handled by npx in entrypoint).
107
+ * Binary files are skipped.
108
+ * Stale swarm-managed skill directories are cleaned up.
109
+ */
110
+ export function writeSkillsToFilesystem(
111
+ entries: SkillFsEntry[],
112
+ harnessType: "claude" | "pi" | "codex" | "all" = "all",
113
+ home: string,
114
+ ): SkillSyncResult {
115
+ const errors: string[] = [];
116
+ let synced = 0;
117
+ let removed = 0;
118
+
119
+ // Directories to write to
120
+ const skillDirs: string[] = [];
121
+ if (harnessType === "claude" || harnessType === "all") {
122
+ skillDirs.push(join(home, ".claude", "skills"));
123
+ }
124
+ if (harnessType === "pi" || harnessType === "all") {
125
+ skillDirs.push(join(home, ".pi", "agent", "skills"));
126
+ }
127
+ if (harnessType === "codex" || harnessType === "all") {
128
+ skillDirs.push(join(home, ".codex", "skills"));
129
+ }
130
+
131
+ // Ensure base dirs exist
132
+ for (const dir of skillDirs) {
133
+ mkdirSync(dir, { recursive: true });
134
+ }
135
+
136
+ // Track which skill names we write (for cleanup)
137
+ const writtenNames = new Set<string>();
138
+
139
+ for (const skill of entries) {
140
+ if (!skill.isActive || !skill.isEnabled) continue;
141
+ if (skill.isComplex && skill.files.length === 0) continue; // Legacy complex skills handled by npx
142
+ if (!skill.content) continue;
143
+
144
+ // Sanitize skill name to prevent path traversal (strip /, .., and non-safe chars)
145
+ const safeName = skill.name.replace(/[^a-zA-Z0-9_-]/g, "_");
146
+ if (!safeName) continue;
147
+
148
+ writtenNames.add(safeName);
149
+ const currentBundledFilePaths = new Set(
150
+ skill.files.filter((file) => !file.isBinary).map((file) => file.path),
151
+ );
152
+
153
+ for (const baseDir of skillDirs) {
154
+ const skillDir = join(baseDir, safeName);
155
+ const skillFile = join(skillDir, "SKILL.md");
156
+ const markerFile = join(skillDir, SWARM_MARKER_FILE);
157
+
158
+ try {
159
+ mkdirSync(skillDir, { recursive: true });
160
+ removed += reconcileManagedSkillFiles(skillDir, currentBundledFilePaths);
161
+ writeFileSync(skillFile, skill.content, "utf-8");
162
+ writeFileSync(markerFile, "", "utf-8");
163
+ synced++;
164
+ } catch (err) {
165
+ const msg = err instanceof Error ? err.message : "Unknown error";
166
+ errors.push(`${skill.name} -> ${skillDir}: ${msg}`);
167
+ console.error(
168
+ `[skill-fs-writer] Failed to write SKILL.md for ${skill.name} to ${skillDir}: ${msg}`,
169
+ );
170
+ }
171
+
172
+ for (const file of skill.files) {
173
+ if (file.isBinary) {
174
+ console.log(`[skill-fs-writer] Skipping binary skill file ${skill.name}/${file.path}`);
175
+ continue;
176
+ }
177
+
178
+ const targetPath = join(skillDir, file.path);
179
+ try {
180
+ mkdirSync(dirname(targetPath), { recursive: true });
181
+ writeFileSync(targetPath, file.content, "utf-8");
182
+ } catch (err) {
183
+ const msg = err instanceof Error ? err.message : "Unknown error";
184
+ errors.push(`${skill.name}/${file.path} -> ${targetPath}: ${msg}`);
185
+ console.error(
186
+ `[skill-fs-writer] Failed to write bundled file ${skill.name}/${file.path} to ${targetPath}: ${msg}`,
187
+ );
188
+ }
189
+ }
190
+ }
191
+ }
192
+
193
+ // Cleanup: only remove directories WE previously created (marker file
194
+ // present). Leaves user-installed personal skills alone — important on
195
+ // local dev where ~/.codex/skills holds skills the user installed
196
+ // outside the swarm.
197
+ for (const baseDir of skillDirs) {
198
+ if (!existsSync(baseDir)) continue;
199
+
200
+ try {
201
+ const existing = readdirSync(baseDir, { withFileTypes: true });
202
+ for (const entry of existing) {
203
+ if (!entry.isDirectory()) continue;
204
+ if (writtenNames.has(entry.name)) continue;
205
+ const skillDir = join(baseDir, entry.name);
206
+ if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
207
+ try {
208
+ rmSync(skillDir, { recursive: true, force: true });
209
+ removed++;
210
+ } catch {
211
+ // Non-fatal — skip cleanup errors
212
+ }
213
+ }
214
+ } catch {
215
+ // Non-fatal — skip if we can't read the directory
216
+ }
217
+ }
218
+
219
+ return { synced, removed, errors };
220
+ }