@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.
- package/README.md +2 -2
- package/openapi.json +242 -3
- package/package.json +5 -5
- package/src/be/db.ts +152 -11
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/migrations/090_model_tiers.sql +2 -0
- package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
- package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
- package/src/be/migrations/093_slack_message_tracking.sql +6 -0
- package/src/be/migrations/runner.ts +52 -0
- package/src/be/modelsdev-cache.json +3264 -1166
- package/src/be/scripts/boot-reembed.ts +74 -0
- package/src/be/scripts/db.ts +19 -3
- package/src/be/seed/index.ts +1 -1
- package/src/be/seed/registry.ts +2 -2
- package/src/be/seed/runner.ts +5 -5
- package/src/be/seed/types.ts +6 -1
- package/src/be/seed-pricing.ts +2 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +8 -7
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +197 -10
- package/src/http/api-keys.ts +42 -0
- package/src/http/index.ts +13 -2
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/metrics.ts +55 -6
- package/src/http/schedules.ts +16 -15
- package/src/http/script-runs.ts +7 -1
- package/src/http/scripts.ts +147 -1
- package/src/http/tasks.ts +17 -6
- package/src/model-tiers.ts +140 -0
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +16 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +12 -4
- package/src/providers/pi-mono-adapter.ts +90 -8
- package/src/providers/types.ts +2 -0
- package/src/scheduler/scheduler.ts +22 -34
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +8 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/slack/responses.ts +39 -11
- package/src/slack/watcher.ts +121 -8
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/agents-list-model-display.test.ts +13 -0
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/aws-error-classifier.test.ts +148 -0
- package/src/tests/claude-managed-adapter.test.ts +12 -0
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/context-window.test.ts +7 -0
- package/src/tests/http-api-integration.test.ts +23 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/metrics-http.test.ts +137 -3
- package/src/tests/migration-046-budgets.test.ts +33 -0
- package/src/tests/migration-runner-regressions.test.ts +69 -0
- package/src/tests/model-control.test.ts +162 -46
- package/src/tests/opencode-adapter.test.ts +9 -0
- package/src/tests/pi-mono-adapter.test.ts +319 -0
- package/src/tests/providers/pi-cost.test.ts +9 -0
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-fallback-output.test.ts +50 -0
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-boot-reembed.test.ts +163 -0
- package/src/tests/scripts-embeddings.test.ts +90 -0
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/seed.test.ts +26 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/slack-watcher.test.ts +66 -0
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tests/workflow-agent-task.test.ts +5 -2
- package/src/tests/workflow-validation-port-routing.test.ts +181 -0
- package/src/tools/memory-get.ts +11 -0
- package/src/tools/memory-search.ts +18 -0
- package/src/tools/schedules/create-schedule.ts +71 -70
- package/src/tools/schedules/update-schedule.ts +43 -31
- package/src/tools/send-task.ts +16 -5
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +11 -3
- package/src/types.ts +40 -0
- package/src/utils/aws-error-classifier.ts +97 -0
- package/src/utils/context-window.ts +5 -0
- package/src/utils/credentials.test.ts +68 -0
- package/src/utils/credentials.ts +66 -5
- package/src/utils/pretty-print.ts +25 -10
- package/src/utils/skill-fs-writer.ts +220 -0
- package/src/utils/skills-refresh.ts +123 -40
- package/src/workflows/engine.ts +3 -2
- 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
|
+
});
|
package/src/utils/credentials.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
? (
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const
|
|
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 =
|
|
217
|
+
const costStr = costUsd ? `$${costUsd.toFixed(4)}` : "";
|
|
218
|
+
const turnsStr = numTurns != null ? `${numTurns} turns` : "";
|
|
203
219
|
|
|
204
|
-
|
|
205
|
-
|
|
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
|
+
}
|