@desplega.ai/agent-swarm 1.83.1 → 1.84.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 (69) hide show
  1. package/openapi.json +158 -8
  2. package/package.json +1 -1
  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 +35 -2
  8. package/src/be/migrations/074_user_budget_scope.sql +85 -0
  9. package/src/commands/resume-session.ts +118 -0
  10. package/src/commands/runner.ts +137 -67
  11. package/src/http/core.ts +4 -1
  12. package/src/http/index.ts +16 -0
  13. package/src/http/integrations.ts +26 -0
  14. package/src/http/mcp-user.ts +111 -0
  15. package/src/http/poll.ts +19 -5
  16. package/src/http/schedules.ts +1 -1
  17. package/src/http/users.ts +107 -2
  18. package/src/http/webhooks.ts +101 -0
  19. package/src/integrations/kapso/client.ts +198 -0
  20. package/src/integrations/kapso/config.ts +104 -0
  21. package/src/integrations/kapso/inbound.ts +111 -0
  22. package/src/jira/client.ts +3 -5
  23. package/src/jira/oauth.ts +1 -0
  24. package/src/jira/sync.ts +2 -2
  25. package/src/oauth/ensure-token.ts +1 -0
  26. package/src/oauth/wrapper.ts +38 -7
  27. package/src/providers/claude-adapter.ts +7 -2
  28. package/src/providers/claude-managed-adapter.ts +1 -1
  29. package/src/providers/codex-adapter.ts +30 -0
  30. package/src/providers/opencode-adapter.ts +149 -14
  31. package/src/providers/pi-mono-adapter.ts +41 -1
  32. package/src/providers/types.ts +1 -1
  33. package/src/server-user.ts +117 -0
  34. package/src/server.ts +14 -0
  35. package/src/tests/artifact-sdk.test.ts +23 -19
  36. package/src/tests/budget-user-scope.test.ts +376 -0
  37. package/src/tests/claude-managed-adapter.test.ts +6 -0
  38. package/src/tests/codex-adapter.test.ts +192 -0
  39. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  40. package/src/tests/db-queries-oauth.test.ts +43 -0
  41. package/src/tests/ensure-token.test.ts +93 -0
  42. package/src/tests/error-tracker.test.ts +52 -0
  43. package/src/tests/fetch-resolved-env.test.ts +33 -20
  44. package/src/tests/http-users.test.ts +29 -1
  45. package/src/tests/kapso-client.test.ts +94 -0
  46. package/src/tests/kapso-inbound.test.ts +198 -0
  47. package/src/tests/mcp-user-route.test.ts +325 -0
  48. package/src/tests/opencode-adapter.test.ts +75 -0
  49. package/src/tests/pi-mono-adapter.test.ts +21 -1
  50. package/src/tests/rate-limit-event.test.ts +69 -6
  51. package/src/tests/resume-session.test.ts +93 -0
  52. package/src/tests/task-tools-ctx.test.ts +100 -0
  53. package/src/tests/task-tools-ownership.test.ts +167 -0
  54. package/src/tests/tool-annotations.test.ts +3 -2
  55. package/src/tests/user-token-routes.test.ts +221 -0
  56. package/src/tools/cancel-task.ts +137 -83
  57. package/src/tools/get-task-details.ts +73 -59
  58. package/src/tools/get-tasks.ts +134 -126
  59. package/src/tools/register-kapso-number.ts +210 -0
  60. package/src/tools/send-task.ts +312 -312
  61. package/src/tools/task-action.ts +464 -367
  62. package/src/tools/task-tool-ctx.ts +43 -0
  63. package/src/tools/templates.ts +35 -0
  64. package/src/tools/tool-config.ts +6 -0
  65. package/src/tools/whatsapp-message.ts +135 -0
  66. package/src/types.ts +6 -2
  67. package/src/utils/error-tracker.ts +122 -9
  68. package/templates/skills/agentmail-sending/SKILL.md +49 -0
  69. package/templates/skills/kapso-whatsapp/SKILL.md +383 -0
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.83.1",
5
+ "version": "1.84.0",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
@@ -1440,7 +1440,8 @@
1440
1440
  "type": "string",
1441
1441
  "enum": [
1442
1442
  "global",
1443
- "agent"
1443
+ "agent",
1444
+ "user"
1444
1445
  ]
1445
1446
  },
1446
1447
  "scopeId": {
@@ -1526,7 +1527,8 @@
1526
1527
  "type": "string",
1527
1528
  "enum": [
1528
1529
  "agent",
1529
- "global"
1530
+ "global",
1531
+ "user"
1530
1532
  ]
1531
1533
  },
1532
1534
  "agentSpendUsd": {
@@ -1553,6 +1555,18 @@
1553
1555
  "null"
1554
1556
  ]
1555
1557
  },
1558
+ "userSpendUsd": {
1559
+ "type": [
1560
+ "number",
1561
+ "null"
1562
+ ]
1563
+ },
1564
+ "userBudgetUsd": {
1565
+ "type": [
1566
+ "number",
1567
+ "null"
1568
+ ]
1569
+ },
1556
1570
  "followUpTaskId": {
1557
1571
  "type": [
1558
1572
  "string",
@@ -1600,7 +1614,8 @@
1600
1614
  "type": "string",
1601
1615
  "enum": [
1602
1616
  "global",
1603
- "agent"
1617
+ "agent",
1618
+ "user"
1604
1619
  ]
1605
1620
  },
1606
1621
  "required": true,
@@ -1631,7 +1646,8 @@
1631
1646
  "type": "string",
1632
1647
  "enum": [
1633
1648
  "global",
1634
- "agent"
1649
+ "agent",
1650
+ "user"
1635
1651
  ]
1636
1652
  },
1637
1653
  "scopeId": {
@@ -1680,7 +1696,8 @@
1680
1696
  "type": "string",
1681
1697
  "enum": [
1682
1698
  "global",
1683
- "agent"
1699
+ "agent",
1700
+ "user"
1684
1701
  ]
1685
1702
  },
1686
1703
  "required": true,
@@ -1729,7 +1746,8 @@
1729
1746
  "type": "string",
1730
1747
  "enum": [
1731
1748
  "global",
1732
- "agent"
1749
+ "agent",
1750
+ "user"
1733
1751
  ]
1734
1752
  },
1735
1753
  "scopeId": {
@@ -1778,7 +1796,8 @@
1778
1796
  "type": "string",
1779
1797
  "enum": [
1780
1798
  "global",
1781
- "agent"
1799
+ "agent",
1800
+ "user"
1782
1801
  ]
1783
1802
  },
1784
1803
  "required": true,
@@ -3362,6 +3381,24 @@
3362
3381
  }
3363
3382
  }
3364
3383
  },
3384
+ "/api/integrations/mcp-user/config": {
3385
+ "get": {
3386
+ "summary": "Get server-derived config for end-user MCP clients.",
3387
+ "tags": [
3388
+ "Integrations"
3389
+ ],
3390
+ "security": [
3391
+ {
3392
+ "bearerAuth": []
3393
+ }
3394
+ ],
3395
+ "responses": {
3396
+ "200": {
3397
+ "description": "Server-derived MCP user config. `mcpBaseUrl` is the API server base URL and `mcpUserUrl` appends `/mcp-user`."
3398
+ }
3399
+ }
3400
+ }
3401
+ },
3365
3402
  "/api/kv/{key}": {
3366
3403
  "get": {
3367
3404
  "summary": "Get a KV entry by key (namespace resolved from request headers)",
@@ -10989,6 +11026,100 @@
10989
11026
  }
10990
11027
  }
10991
11028
  },
11029
+ "/api/users/{id}/mcp-tokens": {
11030
+ "post": {
11031
+ "summary": "Mint a one-time plaintext MCP token for a user",
11032
+ "description": "Returns the plaintext token exactly once. Subsequent reads only expose token summaries.",
11033
+ "tags": [
11034
+ "Users"
11035
+ ],
11036
+ "security": [
11037
+ {
11038
+ "bearerAuth": []
11039
+ }
11040
+ ],
11041
+ "parameters": [
11042
+ {
11043
+ "schema": {
11044
+ "type": "string"
11045
+ },
11046
+ "required": true,
11047
+ "name": "id",
11048
+ "in": "path"
11049
+ }
11050
+ ],
11051
+ "requestBody": {
11052
+ "content": {
11053
+ "application/json": {
11054
+ "schema": {
11055
+ "type": "object",
11056
+ "properties": {
11057
+ "label": {
11058
+ "type": [
11059
+ "string",
11060
+ "null"
11061
+ ]
11062
+ }
11063
+ }
11064
+ }
11065
+ }
11066
+ }
11067
+ },
11068
+ "responses": {
11069
+ "200": {
11070
+ "description": "Minted token plaintext, token summary and composed user"
11071
+ },
11072
+ "401": {
11073
+ "description": "Unauthorized"
11074
+ },
11075
+ "404": {
11076
+ "description": "User not found"
11077
+ }
11078
+ }
11079
+ }
11080
+ },
11081
+ "/api/users/{id}/mcp-tokens/{tokenId}": {
11082
+ "delete": {
11083
+ "summary": "Revoke a user's MCP token",
11084
+ "tags": [
11085
+ "Users"
11086
+ ],
11087
+ "security": [
11088
+ {
11089
+ "bearerAuth": []
11090
+ }
11091
+ ],
11092
+ "parameters": [
11093
+ {
11094
+ "schema": {
11095
+ "type": "string"
11096
+ },
11097
+ "required": true,
11098
+ "name": "id",
11099
+ "in": "path"
11100
+ },
11101
+ {
11102
+ "schema": {
11103
+ "type": "string"
11104
+ },
11105
+ "required": true,
11106
+ "name": "tokenId",
11107
+ "in": "path"
11108
+ }
11109
+ ],
11110
+ "responses": {
11111
+ "200": {
11112
+ "description": "Composed user after token revocation"
11113
+ },
11114
+ "401": {
11115
+ "description": "Unauthorized"
11116
+ },
11117
+ "404": {
11118
+ "description": "User or token not found"
11119
+ }
11120
+ }
11121
+ }
11122
+ },
10992
11123
  "/api/users/{id}/merge": {
10993
11124
  "post": {
10994
11125
  "summary": "Merge another user into this one — moves identities + email aliases, deletes source",
@@ -11263,6 +11394,25 @@
11263
11394
  }
11264
11395
  }
11265
11396
  },
11397
+ "/api/integrations/kapso/webhook": {
11398
+ "post": {
11399
+ "summary": "Handle native Kapso/WhatsApp webhook events",
11400
+ "tags": [
11401
+ "Webhooks"
11402
+ ],
11403
+ "responses": {
11404
+ "200": {
11405
+ "description": "Event received"
11406
+ },
11407
+ "401": {
11408
+ "description": "Invalid signature"
11409
+ },
11410
+ "503": {
11411
+ "description": "Kapso integration not configured"
11412
+ }
11413
+ }
11414
+ }
11415
+ },
11266
11416
  "/api/workflow-runs/{runId}/events": {
11267
11417
  "post": {
11268
11418
  "summary": "Fire a run-scoped event signal",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.83.1",
3
+ "version": "1.84.0",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -22,6 +22,28 @@ export interface ArtifactServer {
22
22
  tunnel: ReturnType<typeof createTunnel> extends Promise<infer T> ? T | null : never;
23
23
  }
24
24
 
25
+ const NativeResponse = globalThis.Response;
26
+
27
+ type NativeResponseArgs = ConstructorParameters<typeof Response>;
28
+
29
+ export function createBunResponse(
30
+ body?: NativeResponseArgs[0],
31
+ init?: NativeResponseArgs[1],
32
+ ): Response {
33
+ return new NativeResponse(body, init);
34
+ }
35
+
36
+ export function createBunHonoFetchHandler(app: Hono): (req: Request) => Promise<Response> {
37
+ return async (req: Request) => {
38
+ const response = await app.fetch(req);
39
+ return createBunResponse(response.body, {
40
+ status: response.status,
41
+ statusText: response.statusText,
42
+ headers: response.headers,
43
+ });
44
+ };
45
+ }
46
+
25
47
  export function createArtifactServer(opts: ArtifactServerOptions): ArtifactServer {
26
48
  const agentId = process.env.AGENT_ID || "unknown";
27
49
  const apiKey = getApiKey();
@@ -100,7 +122,7 @@ export function createArtifactServer(opts: ArtifactServerOptions): ArtifactServe
100
122
 
101
123
  async start() {
102
124
  actualPort = opts.port || (await getAvailablePort());
103
- server = Bun.serve({ port: actualPort, fetch: app.fetch });
125
+ server = Bun.serve({ port: actualPort, fetch: createBunHonoFetchHandler(app) });
104
126
  artifact.port = actualPort;
105
127
 
106
128
  const subdomain = opts.subdomain || `${agentId}-${opts.name}`;
@@ -8,7 +8,7 @@
8
8
  // /api/poll pool, MCP `task-action` `accept`) translate refusals into the
9
9
  // `budget_refused` trigger envelope.
10
10
 
11
- import { getBudget, getDailySpendForAgent, getDailySpendGlobal } from "./db";
11
+ import { getBudget, getDailySpendForAgent, getDailySpendForUser, getDailySpendGlobal } from "./db";
12
12
 
13
13
  export interface BudgetAdmissionAllowed {
14
14
  allowed: true;
@@ -16,11 +16,13 @@ export interface BudgetAdmissionAllowed {
16
16
 
17
17
  export interface BudgetAdmissionRefused {
18
18
  allowed: false;
19
- cause: "agent" | "global";
19
+ cause: "agent" | "global" | "user";
20
20
  agentSpend?: number;
21
21
  agentBudget?: number;
22
22
  globalSpend?: number;
23
23
  globalBudget?: number;
24
+ userSpend?: number;
25
+ userBudget?: number;
24
26
  /** ISO 8601 of the next UTC midnight (the moment daily spend rolls over). */
25
27
  resetAt: string;
26
28
  }
@@ -60,12 +62,17 @@ function nextUtcMidnight(now: Date): string {
60
62
  * 0. Kill-switch (`BUDGET_ADMISSION_DISABLED=true`) ⇒ allowed.
61
63
  * 1. Global budget set + global daily spend ≥ ceiling ⇒ refused (`global`).
62
64
  * 2. Agent budget set + agent daily spend ≥ ceiling ⇒ refused (`agent`).
63
- * 3. Otherwiseallowed.
65
+ * 3. User budget set + user's task spend ≥ ceiling refused (`user`).
66
+ * 4. Otherwise ⇒ allowed.
64
67
  *
65
68
  * Global is checked first by design: a tripped global budget halts the entire
66
69
  * swarm regardless of any single agent's spend.
67
70
  */
68
- export function canClaim(agentId: string, nowUtc: Date): BudgetAdmissionResult {
71
+ export function canClaim(
72
+ agentId: string,
73
+ nowUtc: Date,
74
+ requestedByUserId?: string,
75
+ ): BudgetAdmissionResult {
69
76
  if (process.env.BUDGET_ADMISSION_DISABLED === "true") {
70
77
  if (!killSwitchWarned) {
71
78
  killSwitchWarned = true;
@@ -109,6 +116,23 @@ export function canClaim(agentId: string, nowUtc: Date): BudgetAdmissionResult {
109
116
  }
110
117
  }
111
118
 
119
+ // 3. Per-user budget gate. Only applies to tasks tied to a canonical user.
120
+ if (requestedByUserId) {
121
+ const userBudget = getBudget("user", requestedByUserId);
122
+ if (userBudget !== null) {
123
+ const userSpend = getDailySpendForUser(requestedByUserId, dateUtc);
124
+ if (userSpend >= userBudget.dailyBudgetUsd) {
125
+ return {
126
+ allowed: false,
127
+ cause: "user",
128
+ userSpend,
129
+ userBudget: userBudget.dailyBudgetUsd,
130
+ resetAt,
131
+ };
132
+ }
133
+ }
134
+ }
135
+
112
136
  return { allowed: true };
113
137
  }
114
138
 
@@ -18,22 +18,28 @@ import {
18
18
  createTaskExtended,
19
19
  getAgentById,
20
20
  getLeadAgent,
21
+ getUserById,
21
22
  setBudgetRefusalFollowUpTaskId,
22
23
  } from "./db";
23
24
 
24
25
  export interface BudgetRefusalContext {
25
26
  /** The task that was refused (provides Slack context, description). */
26
- task: Pick<AgentTask, "id" | "task" | "slackChannelId" | "slackThreadTs" | "slackUserId">;
27
+ task: Pick<
28
+ AgentTask,
29
+ "id" | "task" | "requestedByUserId" | "slackChannelId" | "slackThreadTs" | "slackUserId"
30
+ >;
27
31
  /** Refusing agent id. */
28
32
  agentId: string;
29
33
  /** UTC date `YYYY-MM-DD` used as the dedup key alongside `task.id`. */
30
34
  date: string;
31
35
  /** Refusal cause. */
32
- cause: "agent" | "global";
36
+ cause: "agent" | "global" | "user";
33
37
  agentSpendUsd?: number;
34
38
  agentBudgetUsd?: number;
35
39
  globalSpendUsd?: number;
36
40
  globalBudgetUsd?: number;
41
+ userSpendUsd?: number;
42
+ userBudgetUsd?: number;
37
43
  /** ISO 8601 of the next UTC midnight (when the daily budget resets). */
38
44
  resetAt: string;
39
45
  }
@@ -49,6 +55,9 @@ function formatSpendSummary(ctx: BudgetRefusalContext): string {
49
55
  if (ctx.cause === "agent") {
50
56
  return `${fmt(ctx.agentSpendUsd)} / ${fmt(ctx.agentBudgetUsd)}`;
51
57
  }
58
+ if (ctx.cause === "user") {
59
+ return `${fmt(ctx.userSpendUsd)} / ${fmt(ctx.userBudgetUsd)}`;
60
+ }
52
61
  return `${fmt(ctx.globalSpendUsd)} / ${fmt(ctx.globalBudgetUsd)}`;
53
62
  }
54
63
 
@@ -79,6 +88,10 @@ export function emitBudgetRefusalSideEffects(ctx: BudgetRefusalContext, inserted
79
88
  if (leadAgent) {
80
89
  const refusingAgent = getAgentById(ctx.agentId);
81
90
  const agentName = refusingAgent?.name || ctx.agentId.slice(0, 8);
91
+ const userName = ctx.task.requestedByUserId
92
+ ? (getUserById(ctx.task.requestedByUserId)?.name ??
93
+ ctx.task.requestedByUserId.slice(0, 8))
94
+ : undefined;
82
95
  const taskDesc = ctx.task.task.slice(0, 200);
83
96
  const spendSummary = formatSpendSummary(ctx);
84
97
 
@@ -86,7 +99,8 @@ export function emitBudgetRefusalSideEffects(ctx: BudgetRefusalContext, inserted
86
99
  "task.budget.refused",
87
100
  {
88
101
  cause: ctx.cause,
89
- agent_name: agentName,
102
+ agent_name:
103
+ ctx.cause === "user" && userName ? `${agentName} for user ${userName}` : agentName,
90
104
  task_desc: taskDesc,
91
105
  spend_summary: spendSummary,
92
106
  reset_at: ctx.resetAt,
@@ -135,6 +149,8 @@ export function emitBudgetRefusalSideEffects(ctx: BudgetRefusalContext, inserted
135
149
  agentBudgetUsd: ctx.agentBudgetUsd,
136
150
  globalSpendUsd: ctx.globalSpendUsd,
137
151
  globalBudgetUsd: ctx.globalBudgetUsd,
152
+ userSpendUsd: ctx.userSpendUsd,
153
+ userBudgetUsd: ctx.userBudgetUsd,
138
154
  resetAt: ctx.resetAt,
139
155
  });
140
156
  });
@@ -127,6 +127,49 @@ export function storeOAuthTokens(
127
127
  .run(provider, data.accessToken, data.refreshToken ?? null, data.expiresAt, data.scope ?? null);
128
128
  }
129
129
 
130
+ export function updateOAuthTokensAfterRefresh(
131
+ provider: string,
132
+ expectedRefreshToken: string,
133
+ data: {
134
+ accessToken: string;
135
+ refreshToken: string;
136
+ expiresAt: string;
137
+ scope?: string | null;
138
+ },
139
+ ): void {
140
+ const result = getDb()
141
+ .query(
142
+ `UPDATE oauth_tokens
143
+ SET accessToken = ?,
144
+ refreshToken = ?,
145
+ expiresAt = ?,
146
+ scope = COALESCE(?, scope),
147
+ updatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
148
+ WHERE provider = ? AND refreshToken = ?`,
149
+ )
150
+ .run(
151
+ data.accessToken,
152
+ data.refreshToken,
153
+ data.expiresAt,
154
+ data.scope ?? null,
155
+ provider,
156
+ expectedRefreshToken,
157
+ );
158
+
159
+ if (result.changes === 1) return;
160
+
161
+ const current = getOAuthTokens(provider);
162
+ if (!current) {
163
+ throw new Error(`OAuth token refresh persistence failed for ${provider}: token row missing`);
164
+ }
165
+ if (current.refreshToken !== expectedRefreshToken) {
166
+ throw new Error(
167
+ `OAuth token refresh persistence failed for ${provider}: stored refresh token changed during refresh`,
168
+ );
169
+ }
170
+ throw new Error(`OAuth token refresh persistence failed for ${provider}: no rows updated`);
171
+ }
172
+
130
173
  export function deleteOAuthTokens(provider: string): void {
131
174
  getDb().query("DELETE FROM oauth_tokens WHERE provider = ?").run(provider);
132
175
  }
package/src/be/db.ts CHANGED
@@ -1401,6 +1401,8 @@ export interface TaskFilters {
1401
1401
  source?: AgentTaskSource[];
1402
1402
  /** ISO 8601 timestamp; only return tasks where createdAt >= this. */
1403
1403
  createdAfter?: string;
1404
+ /** Only return tasks requested by this canonical user. NULL rows are excluded. */
1405
+ requestedByUserId?: string;
1404
1406
  limit?: number;
1405
1407
  offset?: number;
1406
1408
  includeHeartbeat?: boolean;
@@ -1484,6 +1486,11 @@ export function getAllTasks(
1484
1486
  params.push(filters.createdAfter);
1485
1487
  }
1486
1488
 
1489
+ if (filters?.requestedByUserId) {
1490
+ conditions.push("requestedByUserId = ?");
1491
+ params.push(filters.requestedByUserId);
1492
+ }
1493
+
1487
1494
  // Exclude system/heartbeat tasks by default. The flag is still called
1488
1495
  // `includeHeartbeat` for backward compat with existing API callers, but we
1489
1496
  // also gate boot-triage + heartbeat-checklist behind it since those are
@@ -9684,6 +9691,8 @@ interface BudgetRefusalNotificationRow {
9684
9691
  agent_budget_usd: number | null;
9685
9692
  global_spend_usd: number | null;
9686
9693
  global_budget_usd: number | null;
9694
+ user_spend_usd: number | null;
9695
+ user_budget_usd: number | null;
9687
9696
  follow_up_task_id: string | null;
9688
9697
  createdAt: number;
9689
9698
  }
@@ -9714,6 +9723,8 @@ function rowToBudgetRefusalNotification(
9714
9723
  agentBudgetUsd: row.agent_budget_usd ?? undefined,
9715
9724
  globalSpendUsd: row.global_spend_usd ?? undefined,
9716
9725
  globalBudgetUsd: row.global_budget_usd ?? undefined,
9726
+ userSpendUsd: row.user_spend_usd ?? undefined,
9727
+ userBudgetUsd: row.user_budget_usd ?? undefined,
9717
9728
  followUpTaskId: row.follow_up_task_id ?? undefined,
9718
9729
  createdAt: row.createdAt,
9719
9730
  };
@@ -9964,6 +9975,24 @@ export function getDailySpendGlobal(dateUtc: string): number {
9964
9975
  return row?.total ?? 0;
9965
9976
  }
9966
9977
 
9978
+ /**
9979
+ * Sum of `totalCostUsd` across all `session_costs` rows whose task was
9980
+ * requested by a given user on a given UTC calendar day. `dateUtc` MUST be
9981
+ * `'YYYY-MM-DD'` (UTC). Costs are joined through `agent_tasks` deliberately;
9982
+ * `session_costs` stays task/session-scoped and does not grow a userId column.
9983
+ */
9984
+ export function getDailySpendForUser(userId: string, dateUtc: string): number {
9985
+ const row = getDb()
9986
+ .prepare<CoalesceSumRow, [string, string]>(
9987
+ `SELECT COALESCE(SUM(sc.totalCostUsd), 0) AS total
9988
+ FROM session_costs sc
9989
+ JOIN agent_tasks t ON sc.taskId = t.id
9990
+ WHERE t.requestedByUserId = ? AND substr(sc.createdAt, 1, 10) = ?`,
9991
+ )
9992
+ .get(userId, dateUtc);
9993
+ return row?.total ?? 0;
9994
+ }
9995
+
9967
9996
  export interface RecordBudgetRefusalNotificationInput {
9968
9997
  taskId: string;
9969
9998
  date: string;
@@ -9973,6 +10002,8 @@ export interface RecordBudgetRefusalNotificationInput {
9973
10002
  agentBudgetUsd?: number;
9974
10003
  globalSpendUsd?: number;
9975
10004
  globalBudgetUsd?: number;
10005
+ userSpendUsd?: number;
10006
+ userBudgetUsd?: number;
9976
10007
  }
9977
10008
 
9978
10009
  /**
@@ -9991,8 +10022,8 @@ export function recordBudgetRefusalNotification(input: RecordBudgetRefusalNotifi
9991
10022
  const result = db
9992
10023
  .prepare(
9993
10024
  `INSERT OR IGNORE INTO budget_refusal_notifications
9994
- (task_id, date, agent_id, cause, agent_spend_usd, agent_budget_usd, global_spend_usd, global_budget_usd, follow_up_task_id, createdAt)
9995
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
10025
+ (task_id, date, agent_id, cause, agent_spend_usd, agent_budget_usd, global_spend_usd, global_budget_usd, user_spend_usd, user_budget_usd, follow_up_task_id, createdAt)
10026
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)`,
9996
10027
  )
9997
10028
  .run(
9998
10029
  input.taskId,
@@ -10003,6 +10034,8 @@ export function recordBudgetRefusalNotification(input: RecordBudgetRefusalNotifi
10003
10034
  input.agentBudgetUsd ?? null,
10004
10035
  input.globalSpendUsd ?? null,
10005
10036
  input.globalBudgetUsd ?? null,
10037
+ input.userSpendUsd ?? null,
10038
+ input.userBudgetUsd ?? null,
10006
10039
  now,
10007
10040
  );
10008
10041
 
@@ -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;