@desplega.ai/agent-swarm 1.83.0 → 1.83.2

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 (67) hide show
  1. package/openapi.json +177 -10
  2. package/package.json +6 -6
  3. package/src/artifact-sdk/server.ts +23 -1
  4. package/src/be/budget-admission.ts +28 -4
  5. package/src/be/budget-refusal-notify.ts +19 -3
  6. package/src/be/db-queries/oauth.ts +43 -0
  7. package/src/be/db.ts +37 -4
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/be/schedules/validate.ts +21 -0
  10. package/src/be/skill-sync.ts +65 -15
  11. package/src/commands/resume-session.ts +118 -0
  12. package/src/commands/runner.ts +178 -121
  13. package/src/http/core.ts +4 -1
  14. package/src/http/index.ts +16 -0
  15. package/src/http/integrations.ts +26 -0
  16. package/src/http/mcp-user.ts +111 -0
  17. package/src/http/poll.ts +19 -5
  18. package/src/http/schedules.ts +35 -10
  19. package/src/http/skills.ts +27 -2
  20. package/src/http/users.ts +107 -2
  21. package/src/jira/client.ts +3 -5
  22. package/src/jira/oauth.ts +1 -0
  23. package/src/jira/sync.ts +2 -2
  24. package/src/oauth/ensure-token.ts +1 -0
  25. package/src/oauth/wrapper.ts +38 -7
  26. package/src/providers/claude-adapter.ts +7 -2
  27. package/src/providers/claude-managed-adapter.ts +1 -1
  28. package/src/providers/codex-adapter.ts +30 -0
  29. package/src/providers/opencode-adapter.ts +149 -14
  30. package/src/providers/pi-mono-adapter.ts +41 -1
  31. package/src/providers/types.ts +1 -1
  32. package/src/server-user.ts +117 -0
  33. package/src/tests/artifact-sdk.test.ts +23 -19
  34. package/src/tests/budget-user-scope.test.ts +376 -0
  35. package/src/tests/claude-managed-adapter.test.ts +6 -0
  36. package/src/tests/codex-adapter.test.ts +192 -0
  37. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  38. package/src/tests/db-queries-oauth.test.ts +43 -0
  39. package/src/tests/ensure-token.test.ts +93 -0
  40. package/src/tests/error-tracker.test.ts +52 -0
  41. package/src/tests/fetch-resolved-env.test.ts +33 -20
  42. package/src/tests/http-api-integration.test.ts +36 -0
  43. package/src/tests/http-users.test.ts +29 -1
  44. package/src/tests/mcp-user-route.test.ts +325 -0
  45. package/src/tests/opencode-adapter.test.ts +75 -0
  46. package/src/tests/pi-mono-adapter.test.ts +21 -1
  47. package/src/tests/rate-limit-event.test.ts +69 -6
  48. package/src/tests/resume-session.test.ts +93 -0
  49. package/src/tests/runner-skills-refresh.test.ts +200 -0
  50. package/src/tests/schedule-validation-helper.test.ts +51 -0
  51. package/src/tests/skill-sync.test.ts +73 -9
  52. package/src/tests/skills-signature.test.ts +141 -0
  53. package/src/tests/task-tools-ctx.test.ts +100 -0
  54. package/src/tests/task-tools-ownership.test.ts +167 -0
  55. package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
  56. package/src/tests/user-token-routes.test.ts +221 -0
  57. package/src/tools/cancel-task.ts +137 -83
  58. package/src/tools/get-task-details.ts +73 -59
  59. package/src/tools/get-tasks.ts +134 -126
  60. package/src/tools/schedules/update-schedule.ts +48 -8
  61. package/src/tools/send-task.ts +312 -312
  62. package/src/tools/slack-upload-file.ts +17 -5
  63. package/src/tools/task-action.ts +464 -367
  64. package/src/tools/task-tool-ctx.ts +43 -0
  65. package/src/types.ts +6 -2
  66. package/src/utils/error-tracker.ts +122 -9
  67. package/src/utils/skills-refresh.ts +123 -0
@@ -0,0 +1,85 @@
1
+ -- 074_user_budget_scope.sql
2
+ -- Add per-user budget enforcement for client-side MCP users.
3
+ --
4
+ -- SQLite cannot widen a CHECK constraint in place, so recreate the affected
5
+ -- tables and preserve their data. The `budgets` table gains scope='user'.
6
+ -- `budget_refusal_notifications` gains cause='user' and optional user
7
+ -- spend/budget audit columns so claim-time user-budget refusals can share the
8
+ -- existing dedup + lead-notification rail.
9
+
10
+ CREATE TABLE budgets_new (
11
+ scope TEXT NOT NULL,
12
+ scope_id TEXT NOT NULL,
13
+ daily_budget_usd REAL NOT NULL,
14
+ createdAt INTEGER NOT NULL,
15
+ lastUpdatedAt INTEGER NOT NULL,
16
+ PRIMARY KEY (scope, scope_id),
17
+ CHECK (scope IN ('global', 'agent', 'user')),
18
+ CHECK (daily_budget_usd >= 0)
19
+ );
20
+
21
+ INSERT INTO budgets_new (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt)
22
+ SELECT scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt
23
+ FROM budgets;
24
+
25
+ DROP TABLE budgets;
26
+ ALTER TABLE budgets_new RENAME TO budgets;
27
+
28
+ INSERT OR IGNORE INTO budgets (scope, scope_id, daily_budget_usd, createdAt, lastUpdatedAt)
29
+ SELECT
30
+ 'user',
31
+ id,
32
+ dailyBudgetUsd,
33
+ CAST(strftime('%s', 'now') AS INTEGER) * 1000,
34
+ CAST(strftime('%s', 'now') AS INTEGER) * 1000
35
+ FROM users
36
+ WHERE dailyBudgetUsd IS NOT NULL;
37
+
38
+ CREATE TABLE budget_refusal_notifications_new (
39
+ task_id TEXT NOT NULL,
40
+ date TEXT NOT NULL,
41
+ agent_id TEXT NOT NULL,
42
+ cause TEXT NOT NULL,
43
+ agent_spend_usd REAL,
44
+ agent_budget_usd REAL,
45
+ global_spend_usd REAL,
46
+ global_budget_usd REAL,
47
+ user_spend_usd REAL,
48
+ user_budget_usd REAL,
49
+ follow_up_task_id TEXT,
50
+ createdAt INTEGER NOT NULL,
51
+ PRIMARY KEY (task_id, date),
52
+ CHECK (cause IN ('agent', 'global', 'user'))
53
+ );
54
+
55
+ INSERT INTO budget_refusal_notifications_new (
56
+ task_id,
57
+ date,
58
+ agent_id,
59
+ cause,
60
+ agent_spend_usd,
61
+ agent_budget_usd,
62
+ global_spend_usd,
63
+ global_budget_usd,
64
+ user_spend_usd,
65
+ user_budget_usd,
66
+ follow_up_task_id,
67
+ createdAt
68
+ )
69
+ SELECT
70
+ task_id,
71
+ date,
72
+ agent_id,
73
+ cause,
74
+ agent_spend_usd,
75
+ agent_budget_usd,
76
+ global_spend_usd,
77
+ global_budget_usd,
78
+ NULL,
79
+ NULL,
80
+ follow_up_task_id,
81
+ createdAt
82
+ FROM budget_refusal_notifications;
83
+
84
+ DROP TABLE budget_refusal_notifications;
85
+ ALTER TABLE budget_refusal_notifications_new RENAME TO budget_refusal_notifications;
@@ -0,0 +1,21 @@
1
+ export type MergedScheduleTiming = { mergedCron: string | null; mergedInterval: number | null };
2
+ export type ScheduleTimingPatch = { cronExpression?: string | null; intervalMs?: number | null };
3
+
4
+ export function mergeScheduleTiming(
5
+ existing: { cronExpression: string | null; intervalMs: number | null },
6
+ patch: ScheduleTimingPatch,
7
+ ): MergedScheduleTiming {
8
+ return {
9
+ mergedCron: patch.cronExpression !== undefined ? patch.cronExpression : existing.cronExpression,
10
+ mergedInterval: patch.intervalMs !== undefined ? patch.intervalMs : existing.intervalMs,
11
+ };
12
+ }
13
+
14
+ export type RecurringTimingError = { kind: "both-null" } | null;
15
+
16
+ export function validateRecurringTiming(merged: MergedScheduleTiming): RecurringTimingError {
17
+ if (merged.mergedCron === null && merged.mergedInterval === null) {
18
+ return { kind: "both-null" };
19
+ }
20
+ return null;
21
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Filesystem sync for skills.
3
3
  *
4
- * Writes installed skills to ~/.claude/skills/<name>/SKILL.md (and optionally
5
- * ~/.pi/agent/skills/<name>/SKILL.md) so Claude Code and Pi discover them
6
- * natively.
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.
7
7
  *
8
8
  * This runs on the API side — workers call it via POST /api/skills/sync-filesystem.
9
9
  */
@@ -19,6 +19,16 @@ export interface SkillSyncResult {
19
19
  errors: string[];
20
20
  }
21
21
 
22
+ /**
23
+ * Marker file written into every swarm-managed skill directory. Cleanup
24
+ * only ever removes directories that contain this marker, so unrelated
25
+ * personal skills the user installed via the harness's own tooling (e.g.
26
+ * `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
27
+ * untouched even when the API server shares a HOME with the worker (local
28
+ * dev). See `~/.codex/skills` blast-radius note in PR #555.
29
+ */
30
+ const SWARM_MARKER_FILE = ".swarm-managed";
31
+
22
32
  /**
23
33
  * Sync agent's installed skills to the filesystem.
24
34
  *
@@ -27,7 +37,7 @@ export interface SkillSyncResult {
27
37
  */
28
38
  export function syncSkillsToFilesystem(
29
39
  agentId: string,
30
- harnessType: "claude" | "pi" | "both" = "both",
40
+ harnessType: "claude" | "pi" | "codex" | "all" = "all",
31
41
  homeOverride?: string,
32
42
  ): SkillSyncResult {
33
43
  const skills = getAgentSkills(agentId);
@@ -37,12 +47,15 @@ export function syncSkillsToFilesystem(
37
47
 
38
48
  // Directories to write to
39
49
  const skillDirs: string[] = [];
40
- if (harnessType === "claude" || harnessType === "both") {
50
+ if (harnessType === "claude" || harnessType === "all") {
41
51
  skillDirs.push(join(home, ".claude", "skills"));
42
52
  }
43
- if (harnessType === "pi" || harnessType === "both") {
53
+ if (harnessType === "pi" || harnessType === "all") {
44
54
  skillDirs.push(join(home, ".pi", "agent", "skills"));
45
55
  }
56
+ if (harnessType === "codex" || harnessType === "all") {
57
+ skillDirs.push(join(home, ".codex", "skills"));
58
+ }
46
59
 
47
60
  // Ensure base dirs exist
48
61
  for (const dir of skillDirs) {
@@ -66,10 +79,12 @@ export function syncSkillsToFilesystem(
66
79
  for (const baseDir of skillDirs) {
67
80
  const skillDir = join(baseDir, safeName);
68
81
  const skillFile = join(skillDir, "SKILL.md");
82
+ const markerFile = join(skillDir, SWARM_MARKER_FILE);
69
83
 
70
84
  try {
71
85
  mkdirSync(skillDir, { recursive: true });
72
86
  writeFileSync(skillFile, skill.content, "utf-8");
87
+ writeFileSync(markerFile, "", "utf-8");
73
88
  synced++;
74
89
  } catch (err) {
75
90
  errors.push(
@@ -79,7 +94,10 @@ export function syncSkillsToFilesystem(
79
94
  }
80
95
  }
81
96
 
82
- // Cleanup: remove skill directories that are no longer installed
97
+ // Cleanup: only remove directories WE previously created (marker file
98
+ // present). Leaves user-installed personal skills alone — important on
99
+ // local dev where ~/.codex/skills holds skills the user installed
100
+ // outside the swarm.
83
101
  let removed = 0;
84
102
  for (const baseDir of skillDirs) {
85
103
  if (!existsSync(baseDir)) continue;
@@ -87,14 +105,15 @@ export function syncSkillsToFilesystem(
87
105
  try {
88
106
  const existing = readdirSync(baseDir, { withFileTypes: true });
89
107
  for (const entry of existing) {
90
- if (entry.isDirectory() && !writtenNames.has(entry.name)) {
91
- const skillDir = join(baseDir, entry.name);
92
- try {
93
- rmSync(skillDir, { recursive: true, force: true });
94
- removed++;
95
- } catch {
96
- // Non-fatal — skip cleanup errors
97
- }
108
+ if (!entry.isDirectory()) continue;
109
+ if (writtenNames.has(entry.name)) continue;
110
+ const skillDir = join(baseDir, entry.name);
111
+ if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
112
+ try {
113
+ rmSync(skillDir, { recursive: true, force: true });
114
+ removed++;
115
+ } catch {
116
+ // Non-fatal — skip cleanup errors
98
117
  }
99
118
  }
100
119
  } catch {
@@ -104,3 +123,34 @@ export function syncSkillsToFilesystem(
104
123
 
105
124
  return { synced, removed, errors };
106
125
  }
126
+
127
+ export interface SkillsSignature {
128
+ hash: string;
129
+ count: number;
130
+ }
131
+
132
+ /**
133
+ * Compute a stable signature over an agent's installed-and-enabled skill set.
134
+ *
135
+ * Hash inputs are the per-row mutation-tracking fields — any install,
136
+ * uninstall, toggle, or skill-update mutates at least one of them. Output is
137
+ * deterministic and contains no timestamps beyond per-row mutation fields.
138
+ */
139
+ export function computeAgentSkillsSignature(agentId: string): SkillsSignature {
140
+ const skills = getAgentSkills(agentId);
141
+ const sorted = [...skills].sort((a, b) => a.id.localeCompare(b.id));
142
+ const canonical = JSON.stringify(
143
+ sorted.map((s) => [
144
+ s.id,
145
+ s.name,
146
+ s.version,
147
+ s.isEnabled,
148
+ s.isActive,
149
+ s.lastUpdatedAt,
150
+ s.sourceHash ?? "",
151
+ s.installedAt,
152
+ ]),
153
+ );
154
+ const hash = new Bun.CryptoHasher("sha256").update(canonical).digest("hex");
155
+ return { hash, count: sorted.length };
156
+ }
@@ -0,0 +1,118 @@
1
+ import type { ProviderName } from "../types";
2
+
3
+ export type ResumeSessionSource = "task" | "parent";
4
+
5
+ export interface ResumeSessionCandidate {
6
+ source: ResumeSessionSource;
7
+ sessionId?: string | null;
8
+ taskId?: string;
9
+ provider?: ProviderName;
10
+ providerMeta?: Record<string, unknown>;
11
+ }
12
+
13
+ export interface ResumeSessionSkip {
14
+ source: ResumeSessionSource;
15
+ sessionId: string;
16
+ provider?: ProviderName;
17
+ reason: string;
18
+ }
19
+
20
+ export interface ResumeSessionResolution {
21
+ resumeSessionId?: string;
22
+ source?: ResumeSessionSource;
23
+ provider?: ProviderName;
24
+ skipped: ResumeSessionSkip[];
25
+ }
26
+
27
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
28
+
29
+ const RESUMABLE_PROVIDERS = new Set<ProviderName>(["claude", "claude-managed", "codex"]);
30
+
31
+ export function isClaudeCliSessionId(sessionId: string): boolean {
32
+ return UUID_RE.test(sessionId);
33
+ }
34
+
35
+ function normalizeStoredProvider(candidate: ResumeSessionCandidate): ProviderName | undefined {
36
+ if (candidate.provider === "claude" && candidate.providerMeta?.managed === true) {
37
+ return "claude-managed";
38
+ }
39
+ return candidate.provider;
40
+ }
41
+
42
+ function providerSupportsResume(provider: ProviderName): boolean {
43
+ return RESUMABLE_PROVIDERS.has(provider);
44
+ }
45
+
46
+ export function resolveResumeSession(
47
+ currentProvider: ProviderName,
48
+ candidates: ResumeSessionCandidate[],
49
+ ): ResumeSessionResolution {
50
+ const skipped: ResumeSessionSkip[] = [];
51
+
52
+ for (const candidate of candidates) {
53
+ const sessionId = candidate.sessionId?.trim();
54
+ if (!sessionId) continue;
55
+
56
+ const storedProvider = normalizeStoredProvider(candidate);
57
+
58
+ if (!storedProvider) {
59
+ if (currentProvider === "claude" && isClaudeCliSessionId(sessionId)) {
60
+ return {
61
+ resumeSessionId: sessionId,
62
+ source: candidate.source,
63
+ provider: "claude",
64
+ skipped,
65
+ };
66
+ }
67
+
68
+ skipped.push({
69
+ source: candidate.source,
70
+ sessionId,
71
+ reason:
72
+ currentProvider === "claude"
73
+ ? "legacy Claude resume requires a UUID session id"
74
+ : "stored session provider is unknown",
75
+ });
76
+ continue;
77
+ }
78
+
79
+ if (storedProvider !== currentProvider) {
80
+ skipped.push({
81
+ source: candidate.source,
82
+ sessionId,
83
+ provider: storedProvider,
84
+ reason: `stored session provider ${storedProvider} does not match current provider ${currentProvider}`,
85
+ });
86
+ continue;
87
+ }
88
+
89
+ if (!providerSupportsResume(currentProvider)) {
90
+ skipped.push({
91
+ source: candidate.source,
92
+ sessionId,
93
+ provider: storedProvider,
94
+ reason: `provider ${currentProvider} does not support runner resume`,
95
+ });
96
+ continue;
97
+ }
98
+
99
+ if (currentProvider === "claude" && !isClaudeCliSessionId(sessionId)) {
100
+ skipped.push({
101
+ source: candidate.source,
102
+ sessionId,
103
+ provider: storedProvider,
104
+ reason: "Claude CLI --resume requires a UUID session id",
105
+ });
106
+ continue;
107
+ }
108
+
109
+ return {
110
+ resumeSessionId: sessionId,
111
+ source: candidate.source,
112
+ provider: storedProvider,
113
+ skipped,
114
+ };
115
+ }
116
+
117
+ return { skipped };
118
+ }