@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
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Unit tests for the pure, DB-free writeSkillsToFilesystem() helper.
3
+ *
4
+ * These tests drive the writer directly with crafted SkillFsEntry arrays,
5
+ * exercising: simple write, complex skill with bundled files, binary-file
6
+ * skip, rename → stale dir removed, marker-gated cleanup leaves unmanaged
7
+ * dirs intact, path-traversal name sanitization.
8
+ */
9
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ type SkillFsEntry,
15
+ SWARM_MARKER_FILE,
16
+ writeSkillsToFilesystem,
17
+ } from "../utils/skill-fs-writer";
18
+
19
+ const FAKE_HOME = join(tmpdir(), `skill-fs-writer-test-${process.pid}`);
20
+
21
+ function skillEntry(
22
+ overrides: Partial<SkillFsEntry> & { name: string; content: string },
23
+ ): SkillFsEntry {
24
+ return {
25
+ id: `id-${overrides.name}`,
26
+ isComplex: false,
27
+ isEnabled: true,
28
+ isActive: true,
29
+ files: [],
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe("writeSkillsToFilesystem", () => {
35
+ beforeAll(() => {
36
+ mkdirSync(FAKE_HOME, { recursive: true });
37
+ });
38
+
39
+ afterEach(() => {
40
+ // Clean up between tests
41
+ rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
42
+ rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
43
+ rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
44
+ });
45
+
46
+ afterAll(() => {
47
+ rmSync(FAKE_HOME, { recursive: true, force: true });
48
+ });
49
+
50
+ test("writes simple skill SKILL.md to claude dir", () => {
51
+ const entries = [skillEntry({ name: "my-skill", content: "# My Skill\n\nDoes stuff." })];
52
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
53
+
54
+ expect(result.errors).toHaveLength(0);
55
+ expect(result.synced).toBe(1);
56
+
57
+ const skillFile = join(FAKE_HOME, ".claude", "skills", "my-skill", "SKILL.md");
58
+ expect(existsSync(skillFile)).toBe(true);
59
+ expect(readFileSync(skillFile, "utf-8")).toContain("Does stuff.");
60
+ });
61
+
62
+ test("writes swarm-managed marker alongside SKILL.md", () => {
63
+ const entries = [skillEntry({ name: "my-skill", content: "# My Skill" })];
64
+ writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
65
+
66
+ const marker = join(FAKE_HOME, ".claude", "skills", "my-skill", SWARM_MARKER_FILE);
67
+ expect(existsSync(marker)).toBe(true);
68
+ });
69
+
70
+ test("writes to all harness dirs when harnessType is 'all'", () => {
71
+ const entries = [skillEntry({ name: "multi-skill", content: "# Multi" })];
72
+ const result = writeSkillsToFilesystem(entries, "all", FAKE_HOME);
73
+
74
+ expect(result.synced).toBe(3); // claude + pi + codex
75
+ expect(existsSync(join(FAKE_HOME, ".claude", "skills", "multi-skill", "SKILL.md"))).toBe(true);
76
+ expect(existsSync(join(FAKE_HOME, ".pi", "agent", "skills", "multi-skill", "SKILL.md"))).toBe(
77
+ true,
78
+ );
79
+ expect(existsSync(join(FAKE_HOME, ".codex", "skills", "multi-skill", "SKILL.md"))).toBe(true);
80
+ });
81
+
82
+ test("writes complex skill SKILL.md plus non-binary bundled files", () => {
83
+ const entries = [
84
+ skillEntry({
85
+ name: "complex-skill",
86
+ content: "# Complex Skill",
87
+ isComplex: true,
88
+ files: [
89
+ {
90
+ path: "references/guide.md",
91
+ content: "# Guide\n\nBundled reference.",
92
+ isBinary: false,
93
+ },
94
+ ],
95
+ }),
96
+ ];
97
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
98
+
99
+ expect(result.errors).toHaveLength(0);
100
+ expect(result.synced).toBe(1);
101
+
102
+ const skillFile = join(FAKE_HOME, ".claude", "skills", "complex-skill", "SKILL.md");
103
+ const bundledFile = join(
104
+ FAKE_HOME,
105
+ ".claude",
106
+ "skills",
107
+ "complex-skill",
108
+ "references",
109
+ "guide.md",
110
+ );
111
+ expect(existsSync(skillFile)).toBe(true);
112
+ expect(existsSync(bundledFile)).toBe(true);
113
+ expect(readFileSync(bundledFile, "utf-8")).toContain("Bundled reference.");
114
+ });
115
+
116
+ test("skips binary bundled files", () => {
117
+ const entries = [
118
+ skillEntry({
119
+ name: "complex-skill",
120
+ content: "# Complex Skill",
121
+ isComplex: true,
122
+ files: [
123
+ { path: "references/guide.md", content: "# Guide", isBinary: false },
124
+ { path: "assets/logo.png", content: "[binary]", isBinary: true },
125
+ ],
126
+ }),
127
+ ];
128
+ writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
129
+
130
+ const binaryFile = join(FAKE_HOME, ".claude", "skills", "complex-skill", "assets", "logo.png");
131
+ const textFile = join(
132
+ FAKE_HOME,
133
+ ".claude",
134
+ "skills",
135
+ "complex-skill",
136
+ "references",
137
+ "guide.md",
138
+ );
139
+ expect(existsSync(binaryFile)).toBe(false);
140
+ expect(existsSync(textFile)).toBe(true);
141
+ });
142
+
143
+ test("skips legacy complex skills with no files", () => {
144
+ const entries = [
145
+ skillEntry({
146
+ name: "legacy-complex",
147
+ content: "# Legacy",
148
+ isComplex: true,
149
+ files: [], // no files → skip
150
+ }),
151
+ ];
152
+ writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
153
+
154
+ const skillDir = join(FAKE_HOME, ".claude", "skills", "legacy-complex");
155
+ expect(existsSync(skillDir)).toBe(false);
156
+ });
157
+
158
+ test("skips inactive skills", () => {
159
+ const entries = [
160
+ skillEntry({ name: "inactive-skill", content: "# Inactive", isActive: false }),
161
+ ];
162
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
163
+
164
+ expect(result.synced).toBe(0);
165
+ expect(existsSync(join(FAKE_HOME, ".claude", "skills", "inactive-skill"))).toBe(false);
166
+ });
167
+
168
+ test("skips disabled skills", () => {
169
+ const entries = [
170
+ skillEntry({ name: "disabled-skill", content: "# Disabled", isEnabled: false }),
171
+ ];
172
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
173
+
174
+ expect(result.synced).toBe(0);
175
+ expect(existsSync(join(FAKE_HOME, ".claude", "skills", "disabled-skill"))).toBe(false);
176
+ });
177
+
178
+ test("removes stale swarm-managed skill directory on rename", () => {
179
+ // First sync: write old-name
180
+ const first = [skillEntry({ name: "old-name", content: "# Old" })];
181
+ writeSkillsToFilesystem(first, "claude", FAKE_HOME);
182
+
183
+ const oldDir = join(FAKE_HOME, ".claude", "skills", "old-name");
184
+ expect(existsSync(oldDir)).toBe(true);
185
+
186
+ // Second sync: write new-name, old-name disappears
187
+ const second = [skillEntry({ name: "new-name", content: "# New" })];
188
+ const result = writeSkillsToFilesystem(second, "claude", FAKE_HOME);
189
+
190
+ expect(result.removed).toBeGreaterThanOrEqual(1);
191
+ expect(existsSync(oldDir)).toBe(false);
192
+ expect(existsSync(join(FAKE_HOME, ".claude", "skills", "new-name", "SKILL.md"))).toBe(true);
193
+ });
194
+
195
+ test("marker-gated cleanup leaves unmanaged dirs intact", () => {
196
+ // User-installed skill dir with no .swarm-managed marker
197
+ const foreignDir = join(FAKE_HOME, ".claude", "skills", "user-personal-skill");
198
+ mkdirSync(foreignDir, { recursive: true });
199
+ writeFileSync(join(foreignDir, "SKILL.md"), "user's own skill — keep me");
200
+
201
+ // Sync with a different skill set — should NOT touch the unmanaged dir
202
+ const entries = [skillEntry({ name: "swarm-skill", content: "# Swarm" })];
203
+ writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
204
+
205
+ expect(existsSync(foreignDir)).toBe(true);
206
+ expect(readFileSync(join(foreignDir, "SKILL.md"), "utf-8")).toBe("user's own skill — keep me");
207
+ });
208
+
209
+ test("sanitizes path-traversal characters in skill names", () => {
210
+ const entries = [skillEntry({ name: "my/dangerous/../skill", content: "# Safe" })];
211
+ const result = writeSkillsToFilesystem(entries, "claude", FAKE_HOME);
212
+
213
+ expect(result.errors).toHaveLength(0);
214
+ const sanitizedDir = join(FAKE_HOME, ".claude", "skills", "my_dangerous____skill");
215
+ expect(existsSync(sanitizedDir)).toBe(true);
216
+ });
217
+
218
+ test("removes stale bundled files on re-sync", () => {
219
+ // First sync: write complex skill with one file
220
+ const first = [
221
+ skillEntry({
222
+ name: "complex-skill",
223
+ content: "# Complex",
224
+ isComplex: true,
225
+ files: [{ path: "references/guide.md", content: "# Guide", isBinary: false }],
226
+ }),
227
+ ];
228
+ writeSkillsToFilesystem(first, "claude", FAKE_HOME);
229
+
230
+ const skillDir = join(FAKE_HOME, ".claude", "skills", "complex-skill");
231
+ // Manually add a stale file that should be removed
232
+ writeFileSync(join(skillDir, "references", "stale.md"), "stale content");
233
+ expect(existsSync(join(skillDir, "references", "stale.md"))).toBe(true);
234
+
235
+ // Second sync: same skill, file not in new set → should be removed
236
+ const result = writeSkillsToFilesystem(first, "claude", FAKE_HOME);
237
+
238
+ expect(result.removed).toBeGreaterThanOrEqual(1);
239
+ expect(existsSync(join(skillDir, "references", "stale.md"))).toBe(false);
240
+ expect(existsSync(join(skillDir, "references", "guide.md"))).toBe(true);
241
+ });
242
+
243
+ test("returns empty result for empty entries", () => {
244
+ const result = writeSkillsToFilesystem([], "claude", FAKE_HOME);
245
+
246
+ expect(result.synced).toBe(0);
247
+ expect(result.removed).toBe(0);
248
+ expect(result.errors).toHaveLength(0);
249
+ });
250
+ });
@@ -4,7 +4,6 @@ import type { TaskAttachment } from "../types";
4
4
 
5
5
  // Slack block types are open unions — the builder returns `any`; we read it
6
6
  // as `any` in the test to inspect the runtime shape.
7
- // biome-ignore lint/suspicious/noExplicitAny: see comment above
8
7
  type SlackBlock = any;
9
8
 
10
9
  function mkAttachment(overrides: Partial<TaskAttachment>): TaskAttachment {
@@ -941,7 +941,6 @@ function mkAttachment(overrides: Partial<TaskAttachment>): TaskAttachment {
941
941
  };
942
942
  }
943
943
 
944
- // biome-ignore lint/suspicious/noExplicitAny: section blocks are loose plain objects
945
944
  type AnyBlock = any;
946
945
 
947
946
  function sectionTexts(blocks: AnyBlock[]): string[] {
@@ -0,0 +1,80 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import type { WebClient } from "@slack/web-api";
3
+ import { withAutoJoin } from "../slack/channel-join";
4
+
5
+ // Mirrors the shape @slack/web-api's platformErrorFromResult produces:
6
+ // message = "An API error occurred: <code>", data.error = "<code>"
7
+ function makePlatformError(code: string): Error {
8
+ const err = new Error(`An API error occurred: ${code}`);
9
+ (err as unknown as { data: { error: string } }).data = { error: code };
10
+ return err;
11
+ }
12
+
13
+ describe("withAutoJoin", () => {
14
+ test("success: fn called once, join not called", async () => {
15
+ const joinFn = mock(() => Promise.resolve({}));
16
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
17
+ const fn = mock(() => Promise.resolve("ok"));
18
+
19
+ const result = await withAutoJoin(client, "C123", fn);
20
+ expect(result).toBe("ok");
21
+ expect(fn).toHaveBeenCalledTimes(1);
22
+ expect(joinFn).not.toHaveBeenCalled();
23
+ });
24
+
25
+ test("not_in_channel: calls join then retries fn exactly once", async () => {
26
+ const joinFn = mock(() => Promise.resolve({}));
27
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
28
+ let callCount = 0;
29
+ const fn = mock(async () => {
30
+ callCount++;
31
+ if (callCount === 1) throw makePlatformError("not_in_channel");
32
+ return "retried-ok";
33
+ });
34
+
35
+ const result = await withAutoJoin(client, "CPUB", fn);
36
+ expect(result).toBe("retried-ok");
37
+ expect(fn).toHaveBeenCalledTimes(2);
38
+ expect(joinFn).toHaveBeenCalledTimes(1);
39
+ expect(joinFn).toHaveBeenCalledWith({ channel: "CPUB" });
40
+ });
41
+
42
+ test("private channel: join fails with method_not_supported_for_channel_type → descriptive error", async () => {
43
+ const joinFn = mock(() => {
44
+ throw makePlatformError("method_not_supported_for_channel_type");
45
+ });
46
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
47
+ const fn = mock(() => {
48
+ throw makePlatformError("not_in_channel");
49
+ });
50
+
51
+ await expect(withAutoJoin(client, "CPRIV", fn)).rejects.toThrow("invite the bot");
52
+ expect(joinFn).toHaveBeenCalledTimes(1);
53
+ expect(fn).toHaveBeenCalledTimes(1);
54
+ });
55
+
56
+ test("non-not_in_channel error: rethrown without join", async () => {
57
+ const joinFn = mock(() => Promise.resolve({}));
58
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
59
+ const fn = mock(() => {
60
+ throw makePlatformError("channel_not_found");
61
+ });
62
+
63
+ await expect(withAutoJoin(client, "C123", fn)).rejects.toThrow("channel_not_found");
64
+ expect(joinFn).not.toHaveBeenCalled();
65
+ expect(fn).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ test("retry is bounded: second fn error propagates without another join", async () => {
69
+ const joinFn = mock(() => Promise.resolve({}));
70
+ const client = { conversations: { join: joinFn } } as unknown as WebClient;
71
+ // Every call throws not_in_channel, but we only join once and retry once
72
+ const fn = mock(() => {
73
+ throw makePlatformError("not_in_channel");
74
+ });
75
+
76
+ await expect(withAutoJoin(client, "C123", fn)).rejects.toThrow("not_in_channel");
77
+ expect(fn).toHaveBeenCalledTimes(2);
78
+ expect(joinFn).toHaveBeenCalledTimes(1); // no infinite loop
79
+ });
80
+ });
@@ -62,7 +62,6 @@ function makeMockClient(byUserId: Record<string, MockUsersInfoResponse | "throw"
62
62
  },
63
63
  };
64
64
  return {
65
- // biome-ignore lint/suspicious/noExplicitAny: shape-matched to WebClient's users.info call site.
66
65
  client: client as any,
67
66
  callCounts,
68
67
  };
@@ -233,7 +233,6 @@ describe("AgentTaskConfigSchema — outputSchema", () => {
233
233
  test("accepts outputSchema in config", async () => {
234
234
  const { AgentTaskExecutor } = await import("../workflows/executors/agent-task");
235
235
  const executor = new AgentTaskExecutor({
236
- // biome-ignore lint/suspicious/noExplicitAny: mock DB for test
237
236
  db: {} as any,
238
237
  eventBus: { emit: () => {}, on: () => {}, off: () => {} },
239
238
  interpolate: (t: string) => t,
@@ -257,7 +256,6 @@ describe("AgentTaskConfigSchema — outputSchema", () => {
257
256
  test("accepts followUpConfig in config", async () => {
258
257
  const { AgentTaskExecutor } = await import("../workflows/executors/agent-task");
259
258
  const executor = new AgentTaskExecutor({
260
- // biome-ignore lint/suspicious/noExplicitAny: mock DB for test
261
259
  db: {} as any,
262
260
  eventBus: { emit: () => {}, on: () => {}, off: () => {} },
263
261
  interpolate: (t: string) => t,
@@ -49,7 +49,6 @@ class MemoryStorage {
49
49
 
50
50
  afterEach(() => {
51
51
  // Clean up the global between tests so leakage can't mask bugs.
52
- // biome-ignore lint/suspicious/noExplicitAny: test-only shim
53
52
  delete (globalThis as any).localStorage;
54
53
  });
55
54
 
@@ -82,7 +81,6 @@ describe("deriveStorageKey", () => {
82
81
  describe("dismiss / restore round-trip via localStorage shape", () => {
83
82
  test("dismiss writes '1' under the namespaced key; restore removes it", () => {
84
83
  const storage = new MemoryStorage();
85
- // biome-ignore lint/suspicious/noExplicitAny: test-only shim
86
84
  (globalThis as any).localStorage = storage;
87
85
 
88
86
  const key = deriveStorageKey("http://localhost:3013", "home-welcome");
@@ -101,7 +99,6 @@ describe("dismiss / restore round-trip via localStorage shape", () => {
101
99
 
102
100
  test("namespace isolation: dismissing on apiUrl A does not affect apiUrl B", () => {
103
101
  const storage = new MemoryStorage();
104
- // biome-ignore lint/suspicious/noExplicitAny: test-only shim
105
102
  (globalThis as any).localStorage = storage;
106
103
 
107
104
  const keyA = deriveStorageKey("http://a.local:3013", "home-welcome");
@@ -118,7 +115,6 @@ describe("graceful failure when localStorage throws", () => {
118
115
  test("setItem throw is swallowed by the hook's try/catch contract", () => {
119
116
  const storage = new MemoryStorage();
120
117
  storage.setThrowOnSet(true);
121
- // biome-ignore lint/suspicious/noExplicitAny: test-only shim
122
118
  (globalThis as any).localStorage = storage;
123
119
 
124
120
  const key = deriveStorageKey("http://localhost:3013", "home-welcome");
@@ -76,10 +76,10 @@ export const registerCreateScheduleTool = (server: McpServer) => {
76
76
  .optional()
77
77
  .describe("Whether the schedule is enabled (default: true)"),
78
78
  model: z
79
- .enum(["haiku", "sonnet", "opus"])
79
+ .enum(["haiku", "sonnet", "opus", "fable"])
80
80
  .optional()
81
81
  .describe(
82
- "Model to use for tasks created by this schedule ('haiku', 'sonnet', or 'opus'). If not set, uses agent/global config or defaults to 'opus'.",
82
+ "Model to use for tasks created by this schedule ('haiku', 'sonnet', 'opus', or 'fable'). If not set, uses agent/global config or defaults to 'opus'.",
83
83
  ),
84
84
  }),
85
85
  outputSchema: z.object({
@@ -44,7 +44,7 @@ export const registerUpdateScheduleTool = (server: McpServer) => {
44
44
  timezone: z.string().optional().describe("New timezone"),
45
45
  enabled: z.boolean().optional().describe("Enable or disable the schedule"),
46
46
  model: z
47
- .enum(["haiku", "sonnet", "opus"])
47
+ .enum(["haiku", "sonnet", "opus", "fable"])
48
48
  .nullable()
49
49
  .optional()
50
50
  .describe("Model to use for tasks created by this schedule. Set to null to clear."),
@@ -54,10 +54,10 @@ export const sendTaskInputSchema = z.object({
54
54
  "VCS repo identifier (e.g., 'desplega-ai/agent-swarm' for GitHub or 'group/project' for GitLab). Links the task to a registered repo for workspace context.",
55
55
  ),
56
56
  model: z
57
- .enum(["haiku", "sonnet", "opus"])
57
+ .enum(["haiku", "sonnet", "opus", "fable"])
58
58
  .optional()
59
59
  .describe(
60
- "Model to use for this task ('haiku', 'sonnet', or 'opus'). If not set, uses agent/global config MODEL_OVERRIDE or defaults to 'opus'.",
60
+ "Model to use for this task ('haiku', 'sonnet', 'opus', or 'fable'). If not set, uses agent/global config MODEL_OVERRIDE or defaults to 'opus'.",
61
61
  ),
62
62
  allowDuplicate: z
63
63
  .boolean()
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import { getAgentById } from "@/be/db";
4
4
  import { getSlackApp } from "@/slack/app";
5
+ import { withAutoJoin } from "@/slack/channel-join";
5
6
  import { markdownToSlack } from "@/slack/responses";
6
7
  import { createToolRegistrar } from "@/tools/utils";
7
8
 
@@ -68,22 +69,24 @@ export const registerSlackPostTool = (server: McpServer) => {
68
69
  try {
69
70
  const slackMessage = markdownToSlack(message);
70
71
 
71
- const result = await app.client.chat.postMessage({
72
- channel: channelId,
73
- text: slackMessage, // Fallback for notifications
74
- username: agent.name,
75
- icon_emoji: ":crown:",
76
- ...(threadTs ? { thread_ts: threadTs } : {}),
77
- blocks: [
78
- {
79
- type: "section",
80
- text: {
81
- type: "mrkdwn",
82
- text: slackMessage,
72
+ const result = await withAutoJoin(app.client, channelId, () =>
73
+ app.client.chat.postMessage({
74
+ channel: channelId,
75
+ text: slackMessage, // Fallback for notifications
76
+ username: agent.name,
77
+ icon_emoji: ":crown:",
78
+ ...(threadTs ? { thread_ts: threadTs } : {}),
79
+ blocks: [
80
+ {
81
+ type: "section",
82
+ text: {
83
+ type: "mrkdwn",
84
+ text: slackMessage,
85
+ },
83
86
  },
84
- },
85
- ],
86
- });
87
+ ],
88
+ }),
89
+ );
87
90
 
88
91
  const messageTs = result.ts;
89
92
 
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import { getAgentById, getInboxMessageById, getTaskById } from "@/be/db";
4
4
  import { getSlackApp } from "@/slack/app";
5
+ import { withAutoJoin } from "@/slack/channel-join";
5
6
  import { downloadFile } from "@/slack/files";
6
7
  import { extractSlackMessageText } from "@/slack/message-text";
7
8
  import { createToolRegistrar } from "@/tools/utils";
@@ -216,19 +217,16 @@ export const registerSlackReadTool = (server: McpServer) => {
216
217
  let rawMessages: RawMessage[] = [];
217
218
 
218
219
  if (slackThreadTs) {
219
- // Fetch thread replies
220
- const result = await client.conversations.replies({
221
- channel: slackChannelId,
222
- ts: slackThreadTs,
223
- limit,
224
- });
220
+ // Fetch thread replies — auto-join public channels on not_in_channel
221
+ const result = await withAutoJoin(client, slackChannelId, () =>
222
+ client.conversations.replies({ channel: slackChannelId, ts: slackThreadTs!, limit }),
223
+ );
225
224
  rawMessages = (result.messages || []) as RawMessage[];
226
225
  } else {
227
- // Fetch channel history
228
- const result = await client.conversations.history({
229
- channel: slackChannelId,
230
- limit,
231
- });
226
+ // Fetch channel history — auto-join public channels on not_in_channel
227
+ const result = await withAutoJoin(client, slackChannelId, () =>
228
+ client.conversations.history({ channel: slackChannelId, limit }),
229
+ );
232
230
  rawMessages = (result.messages || []) as RawMessage[];
233
231
  }
234
232
 
@@ -8,6 +8,7 @@ import {
8
8
  markTaskSlackReplySent,
9
9
  } from "@/be/db";
10
10
  import { getSlackApp } from "@/slack/app";
11
+ import { withAutoJoin } from "@/slack/channel-join";
11
12
  import { markdownToSlack } from "@/slack/responses";
12
13
  import { createToolRegistrar } from "@/tools/utils";
13
14
 
@@ -118,22 +119,24 @@ export const registerSlackReplyTool = (server: McpServer) => {
118
119
  try {
119
120
  const slackMessage = markdownToSlack(message);
120
121
 
121
- await app.client.chat.postMessage({
122
- channel: slackChannelId,
123
- thread_ts: slackThreadTs,
124
- text: slackMessage, // Fallback for notifications
125
- username: agent.name,
126
- icon_emoji: agent.isLead ? ":crown:" : ":robot_face:",
127
- blocks: [
128
- {
129
- type: "section",
130
- text: {
131
- type: "mrkdwn",
132
- text: slackMessage,
122
+ await withAutoJoin(app.client, slackChannelId, () =>
123
+ app.client.chat.postMessage({
124
+ channel: slackChannelId,
125
+ thread_ts: slackThreadTs,
126
+ text: slackMessage, // Fallback for notifications
127
+ username: agent.name,
128
+ icon_emoji: agent.isLead ? ":crown:" : ":robot_face:",
129
+ blocks: [
130
+ {
131
+ type: "section",
132
+ text: {
133
+ type: "mrkdwn",
134
+ text: slackMessage,
135
+ },
133
136
  },
134
- },
135
- ],
136
- });
137
+ ],
138
+ }),
139
+ );
137
140
 
138
141
  // After successful postMessage, mark task as having a Slack reply
139
142
  if (taskId) {
@@ -2,6 +2,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import * as z from "zod";
3
3
  import { getAgentById } from "@/be/db";
4
4
  import { getSlackApp } from "@/slack/app";
5
+ import { withAutoJoin } from "@/slack/channel-join";
5
6
  import { markdownToSlack } from "@/slack/responses";
6
7
  import { createToolRegistrar } from "@/tools/utils";
7
8
 
@@ -62,21 +63,23 @@ export const registerSlackStartThreadTool = (server: McpServer) => {
62
63
  try {
63
64
  const slackMessage = markdownToSlack(message);
64
65
 
65
- const result = await app.client.chat.postMessage({
66
- channel: channelId,
67
- text: slackMessage, // Fallback for notifications
68
- username: agent.name,
69
- icon_emoji: ":crown:",
70
- blocks: [
71
- {
72
- type: "section",
73
- text: {
74
- type: "mrkdwn",
75
- text: slackMessage,
66
+ const result = await withAutoJoin(app.client, channelId, () =>
67
+ app.client.chat.postMessage({
68
+ channel: channelId,
69
+ text: slackMessage, // Fallback for notifications
70
+ username: agent.name,
71
+ icon_emoji: ":crown:",
72
+ blocks: [
73
+ {
74
+ type: "section",
75
+ text: {
76
+ type: "mrkdwn",
77
+ text: slackMessage,
78
+ },
76
79
  },
77
- },
78
- ],
79
- });
80
+ ],
81
+ }),
82
+ );
80
83
 
81
84
  const ts = result.ts;
82
85
  const resolvedChannelId = result.channel ?? channelId;
@@ -66,10 +66,10 @@ export const taskActionInputSchema = z.object({
66
66
  "Working directory (absolute path) for the agent to start in. Only used with 'create' action.",
67
67
  ),
68
68
  model: z
69
- .enum(["haiku", "sonnet", "opus"])
69
+ .enum(["haiku", "sonnet", "opus", "fable"])
70
70
  .optional()
71
71
  .describe(
72
- "Model to use for the created task ('haiku', 'sonnet', or 'opus'). Only used with 'create' action.",
72
+ "Model to use for the created task ('haiku', 'sonnet', 'opus', or 'fable'). Only used with 'create' action.",
73
73
  ),
74
74
  });
75
75
 
package/src/types.ts CHANGED
@@ -247,6 +247,10 @@ export const AgentTaskSchema = z.object({
247
247
  provider: ProviderNameSchema.optional(),
248
248
  providerMeta: z.record(z.string(), z.unknown()).optional(),
249
249
 
250
+ // Harness variant — sub-variant within a provider (e.g. "bridge" vs "stock" for claude)
251
+ harnessVariant: z.string().optional(),
252
+ harnessVariantMeta: z.record(z.string(), z.unknown()).optional(),
253
+
250
254
  // Aggregated session cost for task list/read models. Undefined means no
251
255
  // session cost rows have been recorded for this task.
252
256
  totalCostUsd: z.number().min(0).optional(),
@@ -1579,6 +1583,13 @@ export const ScriptRunSchema = z.object({
1579
1583
  });
1580
1584
  export type ScriptRun = z.infer<typeof ScriptRunSchema>;
1581
1585
 
1586
+ export const ScriptRunListItemSchema = ScriptRunSchema.omit({
1587
+ source: true,
1588
+ args: true,
1589
+ output: true,
1590
+ });
1591
+ export type ScriptRunListItem = z.infer<typeof ScriptRunListItemSchema>;
1592
+
1582
1593
  export const ScriptRunJournalEntrySchema = z.object({
1583
1594
  id: z.string().uuid(),
1584
1595
  runId: z.string().uuid(),