@desplega.ai/agent-swarm 1.94.0 → 1.96.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 (38) hide show
  1. package/README.md +3 -3
  2. package/openapi.json +46 -1
  3. package/package.json +4 -3
  4. package/src/be/boot-scrub-logs.ts +76 -0
  5. package/src/be/db.ts +22 -10
  6. package/src/be/migrations/094_mcp_extra_authorize_params.sql +4 -0
  7. package/src/be/modelsdev-cache.json +89422 -85636
  8. package/src/be/skill-sync.ts +4 -4
  9. package/src/be/swarm-config-guard.ts +8 -0
  10. package/src/commands/provider-credentials.ts +37 -9
  11. package/src/commands/runner.ts +28 -0
  12. package/src/http/agents.ts +1 -0
  13. package/src/http/config.ts +24 -4
  14. package/src/http/index.ts +9 -0
  15. package/src/http/mcp-oauth.ts +14 -0
  16. package/src/oauth/mcp-wrapper.ts +14 -0
  17. package/src/prompts/session-templates.ts +21 -0
  18. package/src/providers/codex-skill-resolver.ts +22 -8
  19. package/src/providers/opencode-adapter.ts +20 -2
  20. package/src/providers/pi-mono-adapter.ts +160 -21
  21. package/src/providers/types.ts +33 -0
  22. package/src/tests/bedrock-model-groups.test.ts +135 -0
  23. package/src/tests/credential-check.test.ts +538 -50
  24. package/src/tests/harness-provider-resolution.test.ts +23 -0
  25. package/src/tests/mcp-oauth-queries.test.ts +71 -1
  26. package/src/tests/mcp-oauth-wrapper.test.ts +109 -0
  27. package/src/tests/opencode-adapter.test.ts +29 -1
  28. package/src/tests/provider-command-format.test.ts +12 -0
  29. package/src/tests/secret-scrubber.test.ts +73 -1
  30. package/src/tests/skill-fs-writer.test.ts +7 -1
  31. package/src/tests/skill-sync.test.ts +15 -3
  32. package/src/tools/mcp-servers/mcp-server-create.ts +7 -0
  33. package/src/tools/mcp-servers/mcp-server-update.ts +8 -0
  34. package/src/tools/swarm-config/get-config.ts +9 -1
  35. package/src/tools/swarm-config/list-config.ts +8 -0
  36. package/src/types.ts +22 -0
  37. package/src/utils/secret-scrubber.ts +33 -12
  38. package/src/utils/skill-fs-writer.ts +11 -3
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Filesystem sync for skills.
3
3
  *
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.
4
+ * Writes installed skills to every local harness skill tree so Claude Code,
5
+ * Pi, Codex, OpenCode, and AGENTS.md-compatible adapters can discover them.
7
6
  *
8
7
  * This runs on the API side — workers call it via POST /api/skills/sync-filesystem.
9
8
  * The actual FS write logic lives in the worker-safe src/utils/skill-fs-writer.ts
@@ -13,6 +12,7 @@
13
12
  import { homedir } from "node:os";
14
13
  import {
15
14
  type SkillFsEntry,
15
+ type SkillHarnessTarget,
16
16
  type SkillSyncResult,
17
17
  writeSkillsToFilesystem,
18
18
  } from "../utils/skill-fs-writer";
@@ -32,7 +32,7 @@ export type { SkillSyncResult };
32
32
  */
33
33
  export function syncSkillsToFilesystem(
34
34
  agentId: string,
35
- harnessType: "claude" | "pi" | "codex" | "all" = "all",
35
+ harnessType: SkillHarnessTarget = "all",
36
36
  homeOverride?: string,
37
37
  ): SkillSyncResult {
38
38
  const skills = getAgentSkills(agentId);
@@ -58,6 +58,14 @@ const VALIDATED_KEYS: Record<string, (value: unknown) => string | null> = {
58
58
  if (["true", "false", "1", "0"].includes(normalized)) return null;
59
59
  return "Invalid SWARM_USE_CLAUDE_BRIDGE value (must be one of: true, false, 1, 0)";
60
60
  },
61
+ // AWS credential mode for the Bedrock path on the pi harness.
62
+ // sdk — AWS SDK default credential chain (env, ~/.aws/*, SSO, IMDS, …)
63
+ // bearer — explicit bearer token via AWS_BEARER_TOKEN_BEDROCK (future/Mantle)
64
+ // When absent the worker infers the mode from MODEL_OVERRIDE (sdk semantics).
65
+ BEDROCK_AUTH_MODE: (value) => {
66
+ if (value === "sdk" || value === "bearer") return null;
67
+ return "Invalid BEDROCK_AUTH_MODE value (must be one of: sdk, bearer)";
68
+ },
61
69
  };
62
70
 
63
71
  export function validateConfigValue(key: string, value: unknown): string | null {
@@ -29,6 +29,21 @@ import { scrubSecrets } from "../utils/secret-scrubber";
29
29
 
30
30
  export type SupportedProvider = "claude" | "claude-managed" | "codex" | "devin" | "opencode" | "pi";
31
31
 
32
+ /**
33
+ * True when the pi harness should use the AWS SDK Bedrock path: either an
34
+ * explicit `BEDROCK_AUTH_MODE=sdk`, or — preserving prefix-inference semantics —
35
+ * `BEDROCK_AUTH_MODE` absent with a `MODEL_OVERRIDE=amazon-bedrock/*` selection.
36
+ * Single source of truth for the gate so the live-test arm and the worker
37
+ * reconcile loop agree with `checkPiMonoCredentials`.
38
+ */
39
+ export function isBedrockSdkMode(env: Record<string, string | undefined>): boolean {
40
+ const mode = env.BEDROCK_AUTH_MODE?.toLowerCase();
41
+ return (
42
+ mode === "sdk" ||
43
+ (mode === undefined && Boolean(env.MODEL_OVERRIDE?.toLowerCase().startsWith("amazon-bedrock/")))
44
+ );
45
+ }
46
+
32
47
  /**
33
48
  * Static documentation of which env vars each provider considers when running
34
49
  * `checkCredentials`. Used by the dashboard to render hints before any worker
@@ -243,7 +258,7 @@ function parseCodexOAuthAccess(blob: string | undefined): string | null {
243
258
  * | `codex` | `~/.codex/auth.json` (file) → `CODEX_OAUTH` (env OAuth) → `OPENAI_API_KEY` | OpenAI `/v1/models` (api-key path only) |
244
259
  * | `opencode` | `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` (pi-style) | matching provider's `/v1/models` |
245
260
  * | `pi` | `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` | matching provider's `/v1/models` |
246
- * | `pi` (bedrock) | `MODEL_OVERRIDE=amazon-bedrock/*` → AWS SDK default credential chain | presence-only (validated at first inference call) |
261
+ * | `pi` (bedrock) | `MODEL_OVERRIDE=amazon-bedrock/*` → AWS SDK default credential chain | presence-only (real check is the worker-side Bedrock enumeration) |
247
262
  * | `devin` | `DEVIN_API_KEY` (+ `DEVIN_API_BASE_URL` override) | `${baseUrl}/v1/sessions?limit=1` |
248
263
  *
249
264
  * Returns `{ok: true, latency_ms}` on 2xx, `{ok: false, error, latency_ms}`
@@ -302,14 +317,14 @@ export async function validateProviderCredentials(provider: string): Promise<Liv
302
317
  }
303
318
  case "pi":
304
319
  case "opencode": {
305
- // pi-mono with MODEL_OVERRIDE=amazon-bedrock/* delegates credential
306
- // resolution to the AWS SDK default chain (env, ~/.aws/*, SSO, IMDS,
307
- // assume-role, …). pi-ai exposes no Bedrock-specific check we could
308
- // call here, and the SDK chain may issue slow IMDS network calls on
309
- // non-EC2 hosts so the live test is a presence check, mirroring the
310
- // codex-OAuth pattern above. Real validation happens at the first
311
- // Bedrock inference call.
312
- if (provider === "pi" && env.MODEL_OVERRIDE?.toLowerCase().startsWith("amazon-bedrock/")) {
320
+ // For the pi Bedrock path, the real credential check is the AWS SDK
321
+ // enumeration (`ListFoundationModels` + `ListInferenceProfiles`) that
322
+ // `checkProviderCredentials` (the `pi` dynamic-import arm) already ran.
323
+ // That result is already in `buildCredStatusReport` the live-test is a
324
+ // pass-through / no-op so we never issue a second AWS SDK call here
325
+ // (which would drag the SDK into the wrong binary or make slow IMDS
326
+ // calls on non-EC2 hosts).
327
+ if (provider === "pi" && isBedrockSdkMode(env)) {
313
328
  return presenceCheckOk();
314
329
  }
315
330
  // Both pi-mono and opencode resolve credentials in the same order:
@@ -402,6 +417,18 @@ export async function buildCredStatusReport(
402
417
  testedAt: Date.now(),
403
418
  };
404
419
  }
420
+ // Include the Bedrock enumeration block when the pi probe ran in Bedrock SDK
421
+ // mode (bedrockRegion is only set by checkPiMonoCredentials in that branch).
422
+ const bedrock: AgentCredStatus["bedrock"] =
423
+ presence.bedrockRegion !== undefined
424
+ ? {
425
+ region: presence.bedrockRegion,
426
+ probedAt: Date.now(),
427
+ ready: presence.ready,
428
+ models: presence.bedrockModels ?? [],
429
+ error: presence.ready ? undefined : (presence.hint ?? undefined),
430
+ }
431
+ : null;
405
432
  return {
406
433
  ready: presence.ready,
407
434
  missing: presence.missing ?? [],
@@ -411,6 +438,7 @@ export async function buildCredStatusReport(
411
438
  latestModel: null,
412
439
  reportedAt: Date.now(),
413
440
  reportKind: kind,
441
+ bedrock,
414
442
  };
415
443
  }
416
444
 
@@ -60,6 +60,7 @@ import { resolveClaudeMdPath, syncProfileFilesToServer } from "./profile-sync.ts
60
60
  import {
61
61
  buildCredStatusReport,
62
62
  buildLatestModelReport,
63
+ isBedrockSdkMode,
63
64
  isCredCheckDisabled,
64
65
  reportCredStatus,
65
66
  reportLatestModel,
@@ -436,6 +437,7 @@ const RELOADABLE_ENV_KEYS: ReadonlySet<string> = new Set([
436
437
  "MODEL_OVERRIDE",
437
438
  "AGENT_FS_SHARED_ORG_ID",
438
439
  "SWARM_USE_CLAUDE_BRIDGE",
440
+ "BEDROCK_AUTH_MODE",
439
441
  ]);
440
442
 
441
443
  /**
@@ -3847,6 +3849,16 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
3847
3849
  let lastHarnessReconcileAt = 0;
3848
3850
  const HARNESS_RECONCILE_INTERVAL_MS = 10_000;
3849
3851
 
3852
+ // Throttle for the periodic Bedrock model-enumeration refresh. The credential
3853
+ // report below only re-runs on a harness_provider change (boot + provider
3854
+ // swap), so enabling Bedrock access after boot would otherwise never reach the
3855
+ // picker. This timer re-runs the enumeration on a fixed interval, decoupled
3856
+ // from the harness-change gate, so the UI stays accurate. 5 minutes keeps it
3857
+ // cheap (one bounded AWS enumeration per tick) while still surfacing newly
3858
+ // granted access within a few minutes.
3859
+ let lastBedrockRefreshAt = 0;
3860
+ const BEDROCK_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
3861
+
3850
3862
  // Create API config for ping/close
3851
3863
  const apiConfig: ApiConfig = { apiUrl, apiKey, agentId };
3852
3864
 
@@ -4571,6 +4583,22 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4571
4583
  .catch((err) =>
4572
4584
  console.warn(`[${role}] cred_status post_task report failed (non-fatal): ${err}`),
4573
4585
  );
4586
+ } else if (
4587
+ currentHarness === "pi" &&
4588
+ isBedrockSdkMode(process.env) &&
4589
+ Date.now() - lastBedrockRefreshAt > BEDROCK_REFRESH_INTERVAL_MS
4590
+ ) {
4591
+ // Bedrock enumeration drifts independently of the harness_provider:
4592
+ // access granted (or revoked) in the AWS console after boot won't flip
4593
+ // the provider, so the harness-change gate above never fires. Re-run the
4594
+ // enumeration on the throttled interval so the picker reflects the live
4595
+ // account state. One bounded AWS round-trip per tick.
4596
+ lastBedrockRefreshAt = Date.now();
4597
+ buildCredStatusReport(currentHarness, process.env, {}, "post_task")
4598
+ .then((snap) => reportCredStatus(apiUrl, apiKey, agentId, snap))
4599
+ .catch((err) =>
4600
+ console.warn(`[${role}] bedrock enumeration refresh failed (non-fatal): ${err}`),
4601
+ );
4574
4602
  }
4575
4603
  }
4576
4604
 
@@ -615,6 +615,7 @@ export async function handleAgentsRest(
615
615
  latestModel: null,
616
616
  reportedAt: parsed.body.latest_model.reportedAt,
617
617
  reportKind: "post_task" as const,
618
+ bedrock: null,
618
619
  };
619
620
  finalAgent =
620
621
  updateAgentCredStatus(parsed.params.id, {
@@ -14,6 +14,7 @@ import {
14
14
  reservedKeyError,
15
15
  validateConfigValue,
16
16
  } from "../be/swarm-config-guard";
17
+ import { registerVolatileSecret } from "../utils/secret-scrubber";
17
18
  import { reloadGlobalConfigsAndIntegrations, scheduleIntegrationsReload } from "./core";
18
19
  import { route } from "./route-def";
19
20
  import { json, jsonError } from "./utils";
@@ -152,7 +153,15 @@ export async function handleConfig(
152
153
  parsed.query.agentId || undefined,
153
154
  parsed.query.repoId || undefined,
154
155
  );
155
- json(res, { configs: includeSecrets ? configs : maskSecrets(configs) });
156
+ const result = includeSecrets ? configs : maskSecrets(configs);
157
+ if (includeSecrets) {
158
+ for (const c of result) {
159
+ if (c.isSecret && c.value) {
160
+ registerVolatileSecret(c.value, `config:${c.key}`);
161
+ }
162
+ }
163
+ }
164
+ json(res, { configs: result });
156
165
  return true;
157
166
  }
158
167
 
@@ -199,8 +208,11 @@ export async function handleConfig(
199
208
  jsonError(res, "Config not found", 404);
200
209
  return true;
201
210
  }
202
- const result = includeSecrets ? config : maskSecrets([config])[0];
203
- json(res, result);
211
+ const singleResult = includeSecrets ? config : maskSecrets([config])[0]!;
212
+ if (includeSecrets && singleResult.isSecret && singleResult.value) {
213
+ registerVolatileSecret(singleResult.value, `config:${singleResult.key}`);
214
+ }
215
+ json(res, singleResult);
204
216
  return true;
205
217
  }
206
218
 
@@ -212,7 +224,15 @@ export async function handleConfig(
212
224
  scope: parsed.query.scope || undefined,
213
225
  scopeId: parsed.query.scopeId || undefined,
214
226
  });
215
- json(res, { configs: includeSecrets ? configs : maskSecrets(configs) });
227
+ const listResult = includeSecrets ? configs : maskSecrets(configs);
228
+ if (includeSecrets) {
229
+ for (const c of listResult) {
230
+ if (c.isSecret && c.value) {
231
+ registerVolatileSecret(c.value, `config:${c.key}`);
232
+ }
233
+ }
234
+ }
235
+ json(res, { configs: listResult });
216
236
  return true;
217
237
  }
218
238
 
package/src/http/index.ts CHANGED
@@ -576,6 +576,15 @@ httpServer
576
576
  .catch((err) => {
577
577
  console.error("[boot-reembed-scripts] startup backfill failed (non-fatal):", err);
578
578
  });
579
+
580
+ // One-time scrub: retroactively redact any session_logs rows containing
581
+ // sensitive patterns that pre-date the defense-in-depth scrub layer.
582
+ // Idempotent, tracked via seed_state.
583
+ import("../be/boot-scrub-logs")
584
+ .then(({ runBootScrubLogs }) => runBootScrubLogs())
585
+ .catch((err) => {
586
+ console.error("[boot-scrub-logs] startup scrub failed (non-fatal):", err);
587
+ });
579
588
  })
580
589
  .on("error", (err) => {
581
590
  console.error("HTTP Server Error:", err);
@@ -362,6 +362,19 @@ async function prepareAuthorizeFlow(
362
362
 
363
363
  const scopes = q.scopes ? splitScopes(q.scopes) : client.scopes;
364
364
 
365
+ let extraParams: Record<string, string> | undefined;
366
+ if (server.extraAuthorizeParams) {
367
+ try {
368
+ const parsed = JSON.parse(server.extraAuthorizeParams);
369
+ if (parsed && typeof parsed === "object") {
370
+ extraParams = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
371
+ }
372
+ } catch {
373
+ // Malformed config must never break the authorize flow — log + ignore.
374
+ console.warn(`[mcp-oauth] Ignoring malformed extraAuthorizeParams for server ${mcpServerId}`);
375
+ }
376
+ }
377
+
365
378
  const built = await buildAuthorizeUrl({
366
379
  authorizeUrl: client.authorizeUrl,
367
380
  tokenUrl: client.tokenUrl,
@@ -369,6 +382,7 @@ async function prepareAuthorizeFlow(
369
382
  redirectUri: callbackRedirectUri(),
370
383
  scopes,
371
384
  resource: client.resourceUrl,
385
+ extraParams,
372
386
  });
373
387
 
374
388
  insertMcpOAuthPending({
@@ -287,7 +287,21 @@ export async function buildAuthorizeUrl(input: BuildAuthorizeInput): Promise<Bui
287
287
  url.searchParams.set("resource", input.resource);
288
288
 
289
289
  if (input.extraParams) {
290
+ const RESERVED = new Set([
291
+ "response_type",
292
+ "client_id",
293
+ "redirect_uri",
294
+ "scope",
295
+ "state",
296
+ "code_challenge",
297
+ "code_challenge_method",
298
+ "resource",
299
+ ]);
290
300
  for (const [k, v] of Object.entries(input.extraParams)) {
301
+ if (RESERVED.has(k.toLowerCase())) {
302
+ console.warn(`[mcp-oauth] extraParams key "${k}" is reserved and skipped`);
303
+ continue;
304
+ }
291
305
  url.searchParams.set(k, v);
292
306
  }
293
307
  }
@@ -158,6 +158,27 @@ When you finish a task:
158
158
  - **Failure**: Use \`store-progress\` with status: "failed" and failureReason: "<what went wrong>"
159
159
 
160
160
  Always include meaningful output - the lead agent reviews your work.
161
+
162
+ #### Credential Hygiene
163
+
164
+ When you retrieve secrets via \`get-config\` (with \`includeSecrets: true\`), **never pass secret values directly on a command line or embed them in tool output**. Command arguments are logged.
165
+
166
+ **Safe pattern:** Write the secret to a temporary \`.env\` file, then source it:
167
+ \`\`\`bash
168
+ # Write to temp file (not logged)
169
+ echo "MY_TOKEN=<value>" > /tmp/.task-env && source /tmp/.task-env
170
+ # Use the variable (value stays out of logs)
171
+ curl -H "Authorization: Bearer $MY_TOKEN" https://api.example.com
172
+ rm /tmp/.task-env
173
+ \`\`\`
174
+
175
+ **Unsafe pattern (NEVER do this):**
176
+ \`\`\`bash
177
+ # The literal secret appears in the logged command
178
+ curl -H "Authorization: Bearer lin_oauth_abc123..." https://api.example.com
179
+ \`\`\`
180
+
181
+ The same applies to \`store-progress\` output — never include raw secret values in progress text, output, or failure reasons.
161
182
  `,
162
183
  variables: [],
163
184
  category: "system",
@@ -63,6 +63,21 @@ export async function resolveCodexPrompt(
63
63
  prompt: string,
64
64
  skillsDir?: string,
65
65
  emit?: (event: ProviderEvent) => void,
66
+ ): Promise<string> {
67
+ return resolveSlashSkillPrompt(prompt, {
68
+ providerLabel: "codex",
69
+ skillsDir: skillsDir ?? defaultSkillsDir(),
70
+ emit,
71
+ });
72
+ }
73
+
74
+ export async function resolveSlashSkillPrompt(
75
+ prompt: string,
76
+ opts: {
77
+ providerLabel: string;
78
+ skillsDir: string;
79
+ emit?: (event: ProviderEvent) => void;
80
+ },
66
81
  ): Promise<string> {
67
82
  if (!prompt) {
68
83
  return prompt;
@@ -81,15 +96,14 @@ export async function resolveCodexPrompt(
81
96
 
82
97
  const commandName: string = match[1];
83
98
  const trailingArgs: string = match[2] ?? "";
84
- const dir = skillsDir ?? defaultSkillsDir();
85
- const skillPath = join(dir, commandName, "SKILL.md");
99
+ const skillPath = join(opts.skillsDir, commandName, "SKILL.md");
86
100
 
87
101
  const file = Bun.file(skillPath);
88
102
  const exists = await file.exists();
89
103
  if (!exists) {
90
- emit?.({
104
+ opts.emit?.({
91
105
  type: "raw_stderr",
92
- content: `[codex] skill resolver: SKILL.md not found for /${commandName} (looked in ${skillPath})\n`,
106
+ content: `[${opts.providerLabel}] skill resolver: SKILL.md not found for /${commandName} (looked in ${skillPath})\n`,
93
107
  });
94
108
  return prompt;
95
109
  }
@@ -99,17 +113,17 @@ export async function resolveCodexPrompt(
99
113
  skillContent = await file.text();
100
114
  } catch (err) {
101
115
  const message = err instanceof Error ? err.message : String(err);
102
- emit?.({
116
+ opts.emit?.({
103
117
  type: "raw_stderr",
104
- content: `[codex] skill resolver: failed to read SKILL.md for /${commandName}: ${message}\n`,
118
+ content: `[${opts.providerLabel}] skill resolver: failed to read SKILL.md for /${commandName}: ${message}\n`,
105
119
  });
106
120
  return prompt;
107
121
  }
108
122
 
109
123
  if (skillContent.length > MAX_SKILL_CHARS) {
110
- emit?.({
124
+ opts.emit?.({
111
125
  type: "raw_stderr",
112
- content: `[codex] skill resolver: SKILL.md for /${commandName} exceeds ${MAX_SKILL_CHARS} chars (${skillContent.length}), truncating\n`,
126
+ content: `[${opts.providerLabel}] skill resolver: SKILL.md for /${commandName} exceeds ${MAX_SKILL_CHARS} chars (${skillContent.length}), truncating\n`,
113
127
  });
114
128
  skillContent = skillContent.slice(0, MAX_SKILL_CHARS);
115
129
  }
@@ -20,6 +20,7 @@ import {
20
20
  import { validateOpencodeCredentials } from "../utils/credentials";
21
21
  import { fetchInstalledMcpServers } from "../utils/mcp-server-fetcher";
22
22
  import { scrubSecrets } from "../utils/secret-scrubber";
23
+ import { resolveSlashSkillPrompt } from "./codex-skill-resolver";
23
24
  import { CTX_MODE_NUDGE_EVERY } from "./ctx-mode-env";
24
25
  import { readPkgVersion } from "./harness-version";
25
26
  import type {
@@ -102,6 +103,13 @@ function isAssistantMessage(msg: unknown): msg is AssistantMessage {
102
103
  }
103
104
 
104
105
  const DOCKER_PLUGIN_PATH = "/home/worker/.config/opencode/plugins/agent-swarm.ts";
106
+
107
+ function defaultOpencodeSkillsDir(): string {
108
+ if (process.env.OPENCODE_SKILLS_DIR) {
109
+ return process.env.OPENCODE_SKILLS_DIR;
110
+ }
111
+ return join(process.env.HOME ?? "/home/worker", ".opencode", "skills");
112
+ }
105
113
  const MODEL_CACHE_REFRESH_TIMEOUT_MS = 15_000;
106
114
 
107
115
  function isOpenRouterModel(model: string | undefined): boolean {
@@ -291,6 +299,10 @@ export class OpencodeSession implements ProviderSession {
291
299
  });
292
300
  }
293
301
 
302
+ emitProviderEvent(event: ProviderEvent): void {
303
+ this.emit(event);
304
+ }
305
+
294
306
  onEvent(listener: (event: ProviderEvent) => void): void {
295
307
  const wasEmpty = this.listeners.length === 0;
296
308
  this.listeners.push(listener);
@@ -738,13 +750,19 @@ export class OpencodeAdapter implements ProviderAdapter {
738
750
 
739
751
  let promptRefreshAttempted = false;
740
752
  let promptRefreshPromise: Promise<boolean> | undefined;
753
+ let session: OpencodeSession | undefined;
741
754
  const sendPrompt = async () => {
755
+ const resolvedPrompt = await resolveSlashSkillPrompt(config.prompt, {
756
+ providerLabel: "opencode",
757
+ skillsDir: defaultOpencodeSkillsDir(),
758
+ emit: (event) => session?.emitProviderEvent(event),
759
+ });
742
760
  await client.session.prompt({
743
761
  path: { id: sessionId },
744
762
  query: { directory: config.cwd },
745
763
  body: {
746
764
  agent: agentName,
747
- parts: [{ type: "text", text: config.prompt }],
765
+ parts: [{ type: "text", text: resolvedPrompt }],
748
766
  },
749
767
  });
750
768
  };
@@ -760,7 +778,7 @@ export class OpencodeAdapter implements ProviderAdapter {
760
778
  return await promptRefreshPromise;
761
779
  };
762
780
 
763
- const session = new OpencodeSession(
781
+ session = new OpencodeSession(
764
782
  sessionId,
765
783
  server,
766
784
  config.model,