@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
@@ -26,6 +26,8 @@
26
26
  export const CONTEXT_FORMULA = "input-cache-output" as const;
27
27
 
28
28
  const CONTEXT_WINDOW_DEFAULTS: Record<string, number> = {
29
+ // Anthropic Fable / Mythos tier
30
+ "claude-fable-5": 1_000_000,
29
31
  // Anthropic 4.x family
30
32
  "claude-opus-4-8": 1_000_000,
31
33
  "claude-opus-4-7": 1_000_000,
@@ -45,6 +47,7 @@ const CONTEXT_WINDOW_DEFAULTS: Record<string, number> = {
45
47
  "claude-3-sonnet": 200_000,
46
48
  "claude-3-haiku": 200_000,
47
49
  // Shortnames used by the local-CLI adapter and pi-mono OpenRouter mirror.
50
+ fable: 1_000_000,
48
51
  opus: 1_000_000,
49
52
  sonnet: 1_000_000,
50
53
  haiku: 200_000,
@@ -64,6 +64,8 @@ export interface CredentialSelection {
64
64
  keySuffix: string;
65
65
  /** Which credential pool env var this selection came from */
66
66
  keyType: string;
67
+ /** True when all indices for this keyType were rate-limited (best-effort pick) */
68
+ isRateLimitFallback: boolean;
67
69
  }
68
70
 
69
71
  /**
@@ -82,10 +84,19 @@ export function selectCredential(
82
84
  .filter(Boolean);
83
85
  if (credentials.length <= 1) {
84
86
  const selected = value.trim();
85
- return { selected, index: 0, total: 1, keySuffix: selected.slice(-5), keyType };
87
+ const isRateLimitFallback = availableIndices !== undefined && availableIndices.length === 0;
88
+ return {
89
+ selected,
90
+ index: 0,
91
+ total: 1,
92
+ keySuffix: selected.slice(-5),
93
+ keyType,
94
+ isRateLimitFallback,
95
+ };
86
96
  }
87
97
 
88
98
  let index: number;
99
+ let isRateLimitFallback = false;
89
100
  if (availableIndices && availableIndices.length > 0) {
90
101
  // Pick randomly from available (non-rate-limited) indices
91
102
  const validIndices = availableIndices.filter((i) => i >= 0 && i < credentials.length);
@@ -94,17 +105,26 @@ export function selectCredential(
94
105
  } else {
95
106
  // All available indices out of range — fall back to random from all
96
107
  index = Math.floor(Math.random() * credentials.length);
108
+ isRateLimitFallback = true;
97
109
  }
98
110
  } else if (availableIndices && availableIndices.length === 0) {
99
111
  // All keys are rate-limited — pick randomly anyway (best effort)
100
112
  index = Math.floor(Math.random() * credentials.length);
113
+ isRateLimitFallback = true;
101
114
  } else {
102
115
  // No availability info — pure random (backward compatible)
103
116
  index = Math.floor(Math.random() * credentials.length);
104
117
  }
105
118
 
106
119
  const selected = credentials[index]!;
107
- return { selected, index, total: credentials.length, keySuffix: selected.slice(-5), keyType };
120
+ return {
121
+ selected,
122
+ index,
123
+ total: credentials.length,
124
+ keySuffix: selected.slice(-5),
125
+ keyType,
126
+ isRateLimitFallback,
127
+ };
108
128
  }
109
129
 
110
130
  /**
@@ -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
+ }
@@ -2,15 +2,27 @@
2
2
  * Worker-side per-task skill refresh.
3
3
  *
4
4
  * Polls the cheap signature endpoint; on a hash mismatch, refetches the
5
- * full skill list and re-runs filesystem sync (claude/pi/codex dirs). The
6
- * worker stores the signature returned in the list response so the cached
7
- * hash always corresponds exactly to the snapshot it acted on avoids a
8
- * stale-hash race between the signature and list endpoints.
5
+ * full skill list and writes SKILL.md files to the worker's local HOME via
6
+ * writeSkillsToFilesystem() from skill-fs-writer.ts. This ensures newly
7
+ * created/approved skills land on the worker disk mid-sessionno container
8
+ * restart required.
9
+ *
10
+ * Previously Step 3 POSTed to /api/skills/sync-filesystem, which wrote to
11
+ * the API server's HOME instead of the worker disk. Now Step 3 builds
12
+ * SkillFsEntry[] from the already-fetched skill data and writes locally.
13
+ * For complex skills the worker fetches bundled files via N+1 HTTP calls
14
+ * (acceptable for v1 — simple skills need zero extra fetches).
15
+ *
16
+ * The /api/skills/sync-filesystem endpoint is retained for single-box local
17
+ * dev (where API and worker share a HOME). Workers no longer call it.
9
18
  *
10
19
  * Transient errors are swallowed (returned as `changed: false`) so a flaky
11
20
  * API can't churn the system prompt.
12
21
  */
13
22
 
23
+ import { homedir } from "node:os";
24
+ import { type SkillFsEntry, writeSkillsToFilesystem } from "./skill-fs-writer";
25
+
14
26
  export type SkillsRefreshContext = {
15
27
  apiUrl: string;
16
28
  swarmUrl: string;
@@ -27,6 +39,7 @@ export type SkillsRefreshResult = {
27
39
  export async function refreshSkillsIfChanged(
28
40
  ctx: SkillsRefreshContext,
29
41
  lastHashRef: { current: string | null },
42
+ homeOverride?: string,
30
43
  ): Promise<SkillsRefreshResult> {
31
44
  const { apiUrl, apiKey, agentId, role } = ctx;
32
45
  const authHeaders: Record<string, string> = { "X-Agent-ID": agentId };
@@ -52,70 +65,140 @@ export async function refreshSkillsIfChanged(
52
65
  return { changed: false };
53
66
  }
54
67
 
55
- // Step 2: full fetch + sync (only reached when hash differs or first call)
56
- let summary: { name: string; description: string }[] | undefined;
68
+ // Step 2: full fetch (only reached when hash differs or first call)
69
+ // Keep the full skill rows including content, id, isComplex — data is
70
+ // already on the wire, was previously discarded.
71
+ type SkillRow = {
72
+ id: string;
73
+ name: string;
74
+ description: string;
75
+ content: string | null;
76
+ isComplex: boolean;
77
+ isEnabled: boolean;
78
+ isActive: boolean;
79
+ };
80
+ let skillRows: SkillRow[] = [];
57
81
  let newHash: string | null = null;
82
+ let listFetchOk = false;
58
83
  try {
59
84
  const skillsResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills`, {
60
85
  headers: authHeaders,
61
86
  });
62
87
  if (skillsResp.ok) {
63
88
  const skillsData = (await skillsResp.json()) as {
64
- skills: { name: string; description: string; isActive: boolean; isEnabled: boolean }[];
89
+ skills: SkillRow[];
65
90
  signature?: string;
66
91
  };
67
- summary = skillsData.skills
68
- .filter((s) => s.isActive && s.isEnabled)
69
- .map((s) => ({ name: s.name, description: s.description }));
92
+ skillRows = skillsData.skills;
70
93
  if (typeof skillsData.signature === "string") {
71
94
  newHash = skillsData.signature;
72
95
  }
96
+ listFetchOk = true;
73
97
  }
74
98
  } catch {
75
- // Non-fatalskills are optional
99
+ // Transient network / parse error bail out without touching the local FS
100
+ }
101
+
102
+ // Guard: a failed list fetch must not proceed to writeSkillsToFilesystem.
103
+ // An empty entries array would wipe every swarm-managed skill directory from
104
+ // the worker disk, which is worse than leaving the cache stale.
105
+ if (!listFetchOk) {
106
+ return { changed: false };
76
107
  }
77
108
 
78
- // Step 3: filesystem sync (claude/pi/codex dirs)
109
+ const summary = skillRows
110
+ .filter((s) => s.isActive && s.isEnabled)
111
+ .map((s) => ({ name: s.name, description: s.description }));
112
+
113
+ // Step 3: build SkillFsEntry[] and write to THIS worker's local HOME.
114
+ //
115
+ // For complex+enabled skills, fetch bundled files via N+1 HTTP calls
116
+ // (GET /api/skills/:id/files for manifest, then per non-binary file).
117
+ // Simple skills (the common case) need zero extra fetches.
79
118
  let syncOk = false;
80
119
  try {
81
- const syncHeaders: Record<string, string> = {
82
- "Content-Type": "application/json",
83
- "X-Agent-ID": agentId,
84
- };
85
- if (apiKey) syncHeaders.Authorization = `Bearer ${apiKey}`;
86
- const syncRes = await fetch(`${apiUrl}/api/skills/sync-filesystem`, {
87
- method: "POST",
88
- headers: syncHeaders,
89
- });
90
- if (syncRes.ok) {
91
- const syncResult = (await syncRes.json()) as {
92
- synced: number;
93
- removed: number;
94
- errors: string[];
95
- };
96
- console.log(
97
- `[${role}] Skills synced: ${syncResult.synced} written, ${syncResult.removed} removed`,
98
- );
99
- if (syncResult.errors.length > 0) {
100
- console.warn(`[${role}] Skill sync errors: ${syncResult.errors.join(", ")}`);
120
+ const entries: SkillFsEntry[] = [];
121
+
122
+ for (const skill of skillRows) {
123
+ if (!skill.isActive || !skill.isEnabled) continue;
124
+
125
+ const files: { path: string; content: string; isBinary: boolean }[] = [];
126
+
127
+ if (skill.isComplex) {
128
+ // Fetch manifest to know which files exist + which are binary
129
+ try {
130
+ const manifestResp = await fetch(`${apiUrl}/api/skills/${skill.id}/files`, {
131
+ headers: authHeaders,
132
+ });
133
+ if (manifestResp.ok) {
134
+ const manifestData = (await manifestResp.json()) as {
135
+ files: { path: string; isBinary: boolean }[];
136
+ };
137
+
138
+ // Fetch content for each non-binary file (N+1 acceptable for v1)
139
+ for (const manifestEntry of manifestData.files) {
140
+ if (manifestEntry.isBinary) {
141
+ files.push({ path: manifestEntry.path, content: "", isBinary: true });
142
+ continue;
143
+ }
144
+ try {
145
+ const encodedPath = manifestEntry.path.split("/").map(encodeURIComponent).join("/");
146
+ const fileResp = await fetch(
147
+ `${apiUrl}/api/skills/${skill.id}/files/${encodedPath}`,
148
+ { headers: authHeaders },
149
+ );
150
+ if (fileResp.ok) {
151
+ const fileData = (await fileResp.json()) as {
152
+ file: { path: string; content: string; isBinary: boolean };
153
+ };
154
+ files.push({
155
+ path: fileData.file.path,
156
+ content: fileData.file.content,
157
+ isBinary: fileData.file.isBinary,
158
+ });
159
+ }
160
+ } catch {
161
+ // Non-fatal — skip this file
162
+ }
163
+ }
164
+ }
165
+ } catch {
166
+ // Non-fatal — treat as no files (will skip complex skill per writer logic)
167
+ }
101
168
  }
102
- syncOk = true;
103
- } else {
104
- console.warn(`[${role}] Skill sync failed: HTTP ${syncRes.status}`);
169
+
170
+ entries.push({
171
+ id: skill.id,
172
+ name: skill.name,
173
+ content: skill.content ?? null,
174
+ isComplex: skill.isComplex,
175
+ isEnabled: skill.isEnabled,
176
+ isActive: skill.isActive,
177
+ files,
178
+ });
179
+ }
180
+
181
+ const writeResult = writeSkillsToFilesystem(entries, "all", homeOverride ?? homedir());
182
+ console.log(
183
+ `[${role}] Skills synced: ${writeResult.synced} written, ${writeResult.removed} removed`,
184
+ );
185
+ if (writeResult.errors.length > 0) {
186
+ console.warn(`[${role}] Skill sync errors: ${writeResult.errors.join(", ")}`);
105
187
  }
188
+ syncOk = true;
106
189
  } catch (err) {
107
190
  console.warn(`[${role}] Skill sync failed: ${(err as Error).message}`);
108
191
  }
109
192
 
110
- if (summary === undefined && newHash === null) {
193
+ if (skillRows.length === 0 && newHash === null) {
111
194
  return { changed: false };
112
195
  }
113
196
 
114
- // Only cache the new hash once the FS sync has actually succeeded —
115
- // otherwise a transient sync failure would leave the cached hash matching
197
+ // Only cache the new hash once the local FS write has actually succeeded —
198
+ // otherwise a transient write failure would leave the cached hash matching
116
199
  // the current signature, causing later polls to short-circuit and the
117
- // disk state to stay stale until an unrelated skill mutation. The next
118
- // poll re-enters this code path (lastHashRef unchanged) and retries.
200
+ // disk state to stay stale forever. The next poll re-enters this code path
201
+ // (lastHashRef unchanged) and retries.
119
202
  if (syncOk && newHash !== null) {
120
203
  lastHashRef.current = newHash;
121
204
  }