@desplega.ai/agent-swarm 1.83.1 → 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 (55) hide show
  1. package/openapi.json +139 -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/jira/client.ts +3 -5
  19. package/src/jira/oauth.ts +1 -0
  20. package/src/jira/sync.ts +2 -2
  21. package/src/oauth/ensure-token.ts +1 -0
  22. package/src/oauth/wrapper.ts +38 -7
  23. package/src/providers/claude-adapter.ts +7 -2
  24. package/src/providers/claude-managed-adapter.ts +1 -1
  25. package/src/providers/codex-adapter.ts +30 -0
  26. package/src/providers/opencode-adapter.ts +149 -14
  27. package/src/providers/pi-mono-adapter.ts +41 -1
  28. package/src/providers/types.ts +1 -1
  29. package/src/server-user.ts +117 -0
  30. package/src/tests/artifact-sdk.test.ts +23 -19
  31. package/src/tests/budget-user-scope.test.ts +376 -0
  32. package/src/tests/claude-managed-adapter.test.ts +6 -0
  33. package/src/tests/codex-adapter.test.ts +192 -0
  34. package/src/tests/codex-rate-limit-parse.test.ts +256 -0
  35. package/src/tests/db-queries-oauth.test.ts +43 -0
  36. package/src/tests/ensure-token.test.ts +93 -0
  37. package/src/tests/error-tracker.test.ts +52 -0
  38. package/src/tests/fetch-resolved-env.test.ts +33 -20
  39. package/src/tests/http-users.test.ts +29 -1
  40. package/src/tests/mcp-user-route.test.ts +325 -0
  41. package/src/tests/opencode-adapter.test.ts +75 -0
  42. package/src/tests/pi-mono-adapter.test.ts +21 -1
  43. package/src/tests/rate-limit-event.test.ts +69 -6
  44. package/src/tests/resume-session.test.ts +93 -0
  45. package/src/tests/task-tools-ctx.test.ts +100 -0
  46. package/src/tests/task-tools-ownership.test.ts +167 -0
  47. package/src/tests/user-token-routes.test.ts +221 -0
  48. package/src/tools/cancel-task.ts +137 -83
  49. package/src/tools/get-task-details.ts +73 -59
  50. package/src/tools/get-tasks.ts +134 -126
  51. package/src/tools/send-task.ts +312 -312
  52. package/src/tools/task-action.ts +464 -367
  53. package/src/tools/task-tool-ctx.ts +43 -0
  54. package/src/types.ts +6 -2
  55. package/src/utils/error-tracker.ts +122 -9
package/src/http/poll.ts CHANGED
@@ -42,11 +42,13 @@ import { json, jsonError } from "./utils";
42
42
  * + workflow bus emit. See `src/be/budget-refusal-notify.ts`.
43
43
  */
44
44
  function buildBudgetRefusedTrigger(refusal: {
45
- cause: "agent" | "global";
45
+ cause: "agent" | "global" | "user";
46
46
  agentSpend?: number;
47
47
  agentBudget?: number;
48
48
  globalSpend?: number;
49
49
  globalBudget?: number;
50
+ userSpend?: number;
51
+ userBudget?: number;
50
52
  resetAt: string;
51
53
  }): { type: "budget_refused"; [key: string]: unknown } {
52
54
  const trigger: { type: "budget_refused"; [key: string]: unknown } = {
@@ -58,6 +60,8 @@ function buildBudgetRefusedTrigger(refusal: {
58
60
  if (refusal.agentBudget !== undefined) trigger.agentBudget = refusal.agentBudget;
59
61
  if (refusal.globalSpend !== undefined) trigger.globalSpend = refusal.globalSpend;
60
62
  if (refusal.globalBudget !== undefined) trigger.globalBudget = refusal.globalBudget;
63
+ if (refusal.userSpend !== undefined) trigger.userSpend = refusal.userSpend;
64
+ if (refusal.userBudget !== undefined) trigger.userBudget = refusal.userBudget;
61
65
  return trigger;
62
66
  }
63
67
 
@@ -180,7 +184,7 @@ export async function handlePoll(
180
184
  // the capacity check so capacity AND budget gates share atomicity.
181
185
  // Phase 5 also records the dedup row + captures the side-effect
182
186
  // context here so the after-commit step can notify the lead.
183
- const admission = canClaim(myAgentId, new Date());
187
+ const admission = canClaim(myAgentId, new Date(), pendingTask.requestedByUserId);
184
188
  if (!admission.allowed) {
185
189
  const utcDate = new Date().toISOString().slice(0, 10);
186
190
  const dedup = recordBudgetRefusalNotification({
@@ -192,6 +196,8 @@ export async function handlePoll(
192
196
  agentBudgetUsd: admission.agentBudget,
193
197
  globalSpendUsd: admission.globalSpend,
194
198
  globalBudgetUsd: admission.globalBudget,
199
+ userSpendUsd: admission.userSpend,
200
+ userBudgetUsd: admission.userBudget,
195
201
  });
196
202
  return {
197
203
  trigger: buildBudgetRefusedTrigger(admission),
@@ -200,6 +206,7 @@ export async function handlePoll(
200
206
  task: {
201
207
  id: pendingTask.id,
202
208
  task: pendingTask.task,
209
+ requestedByUserId: pendingTask.requestedByUserId,
203
210
  slackChannelId: pendingTask.slackChannelId,
204
211
  slackThreadTs: pendingTask.slackThreadTs,
205
212
  slackUserId: pendingTask.slackUserId,
@@ -211,6 +218,8 @@ export async function handlePoll(
211
218
  agentBudgetUsd: admission.agentBudget,
212
219
  globalSpendUsd: admission.globalSpend,
213
220
  globalBudgetUsd: admission.globalBudget,
221
+ userSpendUsd: admission.userSpend,
222
+ userBudgetUsd: admission.userBudget,
214
223
  resetAt: admission.resetAt,
215
224
  },
216
225
  inserted: dedup.inserted,
@@ -303,10 +312,10 @@ export async function handlePoll(
303
312
  // refusal, and the dedup is per-(task,date) so subsequent same-day
304
313
  // refusals on the same lead-candidate are suppressed.
305
314
  if (unassignedIds.length > 0) {
306
- const admission = canClaim(myAgentId, new Date());
315
+ const candidateId = unassignedIds[0]!;
316
+ const candidateTask = getTaskById(candidateId);
317
+ const admission = canClaim(myAgentId, new Date(), candidateTask?.requestedByUserId);
307
318
  if (!admission.allowed) {
308
- const candidateId = unassignedIds[0]!;
309
- const candidateTask = getTaskById(candidateId);
310
319
  const utcDate = new Date().toISOString().slice(0, 10);
311
320
  const dedup = recordBudgetRefusalNotification({
312
321
  taskId: candidateId,
@@ -317,6 +326,8 @@ export async function handlePoll(
317
326
  agentBudgetUsd: admission.agentBudget,
318
327
  globalSpendUsd: admission.globalSpend,
319
328
  globalBudgetUsd: admission.globalBudget,
329
+ userSpendUsd: admission.userSpend,
330
+ userBudgetUsd: admission.userBudget,
320
331
  });
321
332
  return {
322
333
  trigger: buildBudgetRefusedTrigger(admission),
@@ -326,6 +337,7 @@ export async function handlePoll(
326
337
  task: {
327
338
  id: candidateTask.id,
328
339
  task: candidateTask.task,
340
+ requestedByUserId: candidateTask.requestedByUserId,
329
341
  slackChannelId: candidateTask.slackChannelId,
330
342
  slackThreadTs: candidateTask.slackThreadTs,
331
343
  slackUserId: candidateTask.slackUserId,
@@ -337,6 +349,8 @@ export async function handlePoll(
337
349
  agentBudgetUsd: admission.agentBudget,
338
350
  globalSpendUsd: admission.globalSpend,
339
351
  globalBudgetUsd: admission.globalBudget,
352
+ userSpendUsd: admission.userSpend,
353
+ userBudgetUsd: admission.userBudget,
340
354
  resetAt: admission.resetAt,
341
355
  },
342
356
  inserted: dedup.inserted,
@@ -466,11 +466,11 @@ export async function handleSchedules(
466
466
  const mergedTimezone =
467
467
  parsed.body.timezone !== undefined ? parsed.body.timezone : existing.timezone;
468
468
  if (timing.mergedCron || timing.mergedInterval) {
469
- // biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
470
469
  body.nextRunAt = calculateNextRun({
471
470
  cronExpression: timing.mergedCron,
472
471
  intervalMs: timing.mergedInterval,
473
472
  timezone: mergedTimezone,
473
+ // biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
474
474
  } as any);
475
475
  }
476
476
  }
package/src/http/users.ts CHANGED
@@ -7,8 +7,7 @@
7
7
  * pass it as the `IdentityActor` arg to the helpers in `src/be/users.ts` — so
8
8
  * every identity mutation lands an event row tagged `op:<sha256-16>`.
9
9
  *
10
- * Endpoint set (Core Req #6, minus `POST/DELETE /users/:id/mcp-tokens` which
11
- * are deferred to the MCP plan):
10
+ * Endpoint set:
12
11
  *
13
12
  * GET /api/users
14
13
  * POST /api/users
@@ -16,6 +15,8 @@
16
15
  * POST /api/users/unmapped/:kind/:externalId/resolve
17
16
  * GET /api/users/:id
18
17
  * PATCH /api/users/:id
18
+ * POST /api/users/:id/mcp-tokens
19
+ * DELETE /api/users/:id/mcp-tokens/:tokenId
19
20
  * POST /api/users/:id/merge
20
21
  * GET /api/users/:id/events
21
22
  * POST /api/users/:id/identities
@@ -26,12 +27,14 @@ import type { IncomingMessage, ServerResponse } from "node:http";
26
27
  import { z } from "zod";
27
28
  import {
28
29
  createUser,
30
+ deleteBudget,
29
31
  deleteKv,
30
32
  deleteUser,
31
33
  getAllUsers,
32
34
  getUserById,
33
35
  listKv,
34
36
  updateUser,
37
+ upsertBudget,
35
38
  } from "../be/db";
36
39
  import {
37
40
  getUserIdentities,
@@ -39,7 +42,9 @@ import {
39
42
  linkIdentity,
40
43
  listUserEvents,
41
44
  listUserTokens,
45
+ mintToken,
42
46
  recordIdentityEvent,
47
+ revokeToken,
43
48
  unlinkIdentity,
44
49
  } from "../be/users";
45
50
  import { getOperatorActor } from "./operator-actor";
@@ -64,6 +69,15 @@ function composeUser(userId: string, recentEventLimit = 5) {
64
69
  };
65
70
  }
66
71
 
72
+ function syncUserBudgetMirror(userId: string, dailyBudgetUsd: number | null | undefined): void {
73
+ if (dailyBudgetUsd === undefined) return;
74
+ if (dailyBudgetUsd === null) {
75
+ deleteBudget("user", userId);
76
+ return;
77
+ }
78
+ upsertBudget("user", userId, dailyBudgetUsd);
79
+ }
80
+
67
81
  // ─── Route Definitions ───────────────────────────────────────────────────────
68
82
 
69
83
  const listUsers = route({
@@ -203,6 +217,41 @@ const updateUserRoute = route({
203
217
  auth: { apiKey: true },
204
218
  });
205
219
 
220
+ const mintUserMcpTokenRoute = route({
221
+ method: "post",
222
+ path: "/api/users/{id}/mcp-tokens",
223
+ pattern: ["api", "users", null, "mcp-tokens"],
224
+ summary: "Mint a one-time plaintext MCP token for a user",
225
+ description:
226
+ "Returns the plaintext token exactly once. Subsequent reads only expose token summaries.",
227
+ tags: ["Users"],
228
+ params: z.object({ id: z.string() }),
229
+ body: z.object({
230
+ label: z.string().nullable().optional(),
231
+ }),
232
+ responses: {
233
+ 200: { description: "Minted token plaintext, token summary and composed user" },
234
+ 401: { description: "Unauthorized" },
235
+ 404: { description: "User not found" },
236
+ },
237
+ auth: { apiKey: true },
238
+ });
239
+
240
+ const revokeUserMcpTokenRoute = route({
241
+ method: "delete",
242
+ path: "/api/users/{id}/mcp-tokens/{tokenId}",
243
+ pattern: ["api", "users", null, "mcp-tokens", null],
244
+ summary: "Revoke a user's MCP token",
245
+ tags: ["Users"],
246
+ params: z.object({ id: z.string(), tokenId: z.string() }),
247
+ responses: {
248
+ 200: { description: "Composed user after token revocation" },
249
+ 401: { description: "Unauthorized" },
250
+ 404: { description: "User or token not found" },
251
+ },
252
+ auth: { apiKey: true },
253
+ });
254
+
206
255
  const mergeUsersRoute = route({
207
256
  method: "post",
208
257
  path: "/api/users/{id}/merge",
@@ -370,6 +419,7 @@ export async function handleUsers(
370
419
  try {
371
420
  const { identities, ...userFields } = parsed.body;
372
421
  const user = createUser(userFields);
422
+ syncUserBudgetMirror(user.id, userFields.dailyBudgetUsd);
373
423
  for (const ident of identities ?? []) {
374
424
  linkIdentity(user.id, ident.kind, ident.externalId, actor);
375
425
  }
@@ -459,6 +509,60 @@ export async function handleUsers(
459
509
  return true;
460
510
  }
461
511
 
512
+ // ─── POST /api/users/:id/mcp-tokens ───────────────────────────────────────
513
+ if (mintUserMcpTokenRoute.match(req.method, pathSegments)) {
514
+ const parsed = await mintUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
515
+ if (!parsed) return true;
516
+ const actor = getOperatorActor(req, res);
517
+ if (!actor) return true;
518
+ if (!getUserById(parsed.params.id)) {
519
+ jsonError(res, "User not found", 404);
520
+ return true;
521
+ }
522
+
523
+ try {
524
+ const { tokenId, plaintext } = mintToken(parsed.params.id, parsed.body.label ?? null, actor);
525
+ const token = listUserTokens(parsed.params.id).find((t) => t.id === tokenId);
526
+ json(res, { plaintext, token, user: composeUser(parsed.params.id) });
527
+ } catch (err) {
528
+ jsonError(res, err instanceof Error ? err.message : "Failed to mint token", 500);
529
+ }
530
+ return true;
531
+ }
532
+
533
+ // ─── DELETE /api/users/:id/mcp-tokens/:tokenId ────────────────────────────
534
+ if (revokeUserMcpTokenRoute.match(req.method, pathSegments)) {
535
+ const parsed = await revokeUserMcpTokenRoute.parse(req, res, pathSegments, queryParams);
536
+ if (!parsed) return true;
537
+ const actor = getOperatorActor(req, res);
538
+ if (!actor) return true;
539
+ if (!getUserById(parsed.params.id)) {
540
+ jsonError(res, "User not found", 404);
541
+ return true;
542
+ }
543
+
544
+ const tokenBelongsToUser = listUserTokens(parsed.params.id).some(
545
+ (token) => token.id === parsed.params.tokenId,
546
+ );
547
+ if (!tokenBelongsToUser) {
548
+ jsonError(res, "Token not found", 404);
549
+ return true;
550
+ }
551
+
552
+ try {
553
+ revokeToken(parsed.params.tokenId, actor);
554
+ json(res, { user: composeUser(parsed.params.id) });
555
+ } catch (err) {
556
+ const message = err instanceof Error ? err.message : "Failed to revoke token";
557
+ if (message.includes("Token not found")) {
558
+ jsonError(res, "Token not found", 404);
559
+ } else {
560
+ jsonError(res, message, 500);
561
+ }
562
+ }
563
+ return true;
564
+ }
565
+
462
566
  // ─── POST /api/users/:id/identities ────────────────────────────────────────
463
567
  if (addIdentityRoute.match(req.method, pathSegments)) {
464
568
  const parsed = await addIdentityRoute.parse(req, res, pathSegments, queryParams);
@@ -625,6 +729,7 @@ export async function handleUsers(
625
729
  jsonError(res, "User not found", 404);
626
730
  return true;
627
731
  }
732
+ syncUserBudgetMirror(parsed.params.id, parsed.body.dailyBudgetUsd);
628
733
 
629
734
  // Budget event
630
735
  if (
@@ -1,5 +1,5 @@
1
1
  import { getOAuthTokens } from "../be/db-queries/oauth";
2
- import { ensureToken } from "../oauth/ensure-token";
2
+ import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
3
3
  import { getJiraMetadata } from "./metadata";
4
4
 
5
5
  /**
@@ -36,8 +36,7 @@ export function getJiraCloudId(): string {
36
36
  * - Prepends `https://api.atlassian.com/ex/jira/{cloudId}` to `path`.
37
37
  * - Sets `Authorization: Bearer <token>` and `Accept: application/json`.
38
38
  * - Sets `Content-Type: application/json` when a body is provided.
39
- * - On 401: refreshes the token (forced via `ensureToken("jira", 0)`) and
40
- * retries once.
39
+ * - On 401: forces a token refresh and retries once.
41
40
  * - On 429: respects `Retry-After` (in seconds) with a single retry.
42
41
  *
43
42
  * Returns the raw `Response` — callers handle `response.json()`/`response.text()`
@@ -64,8 +63,7 @@ export async function jiraFetch(path: string, init?: RequestInit): Promise<Respo
64
63
  let response = await send(token);
65
64
 
66
65
  if (response.status === 401) {
67
- // Force refresh — bufferMs=0 means "always refresh if any expiry is set"
68
- await ensureToken("jira", 0);
66
+ await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
69
67
  token = await getJiraAccessToken();
70
68
  response = await send(token);
71
69
  }
package/src/jira/oauth.ts CHANGED
@@ -29,6 +29,7 @@ export function getJiraOAuthConfig(): OAuthProviderConfig | null {
29
29
  scopes: app.scopes.split(","),
30
30
  scopeSeparator: " ",
31
31
  extraParams: { audience: "api.atlassian.com" },
32
+ requiresRefreshTokenRotation: true,
32
33
  };
33
34
  }
34
35
 
package/src/jira/sync.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  getTrackerSyncByExternalId,
22
22
  updateTrackerSyncSwarmId,
23
23
  } from "../be/db-queries/tracker";
24
- import { ensureToken } from "../oauth/ensure-token";
24
+ import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
25
25
  import { resolveTemplate } from "../prompts/resolver";
26
26
  import { buildJiraContextKey } from "../tasks/context-key";
27
27
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
@@ -91,7 +91,7 @@ export async function resolveBotAccountId(): Promise<string | null> {
91
91
  // Mirror jiraFetch's 401-retry pattern: a token may go stale between the
92
92
  // proactive ensureToken call and the request reaching Atlassian.
93
93
  if (res.status === 401) {
94
- await ensureToken("jira", 0);
94
+ await ensureTokenOrThrow("jira", Number.MAX_SAFE_INTEGER);
95
95
  tokens = getOAuthTokens("jira");
96
96
  if (!tokens?.accessToken) {
97
97
  console.warn("[Jira Sync] /me returned 401 and refresh produced no token");
@@ -18,6 +18,7 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
18
18
  redirectUri: app.redirectUri,
19
19
  scopes: app.scopes.split(","),
20
20
  extraParams: metadata.extraParams ?? (metadata.actor ? { actor: metadata.actor } : undefined),
21
+ requiresRefreshTokenRotation: provider === "jira",
21
22
  };
22
23
  }
23
24
 
@@ -1,5 +1,5 @@
1
1
  import * as oauth from "oauth4webapi";
2
- import { storeOAuthTokens } from "../be/db-queries/oauth";
2
+ import { storeOAuthTokens, updateOAuthTokensAfterRefresh } from "../be/db-queries/oauth";
3
3
 
4
4
  // ─── Types ───────────────────────────────────────────────────────────────────
5
5
 
@@ -13,6 +13,12 @@ export interface OAuthProviderConfig {
13
13
  scopes: string[];
14
14
  /** Extra query params appended to the authorization URL (e.g. { actor: "app" } for Linear) */
15
15
  extraParams?: Record<string, string>;
16
+ /**
17
+ * Provider rotates refresh tokens on every refresh. When true, a refresh
18
+ * response without a new refresh token is unusable because the old one may
19
+ * already be invalidated server-side.
20
+ */
21
+ requiresRefreshTokenRotation?: boolean;
16
22
  /**
17
23
  * How to join `scopes` in the authorization URL.
18
24
  *
@@ -160,7 +166,7 @@ export async function exchangeCode(
160
166
  export async function refreshAccessToken(
161
167
  config: OAuthProviderConfig,
162
168
  refreshToken: string,
163
- ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> {
169
+ ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number; scope?: string }> {
164
170
  const body = new URLSearchParams({
165
171
  grant_type: "refresh_token",
166
172
  client_id: config.clientId,
@@ -183,23 +189,48 @@ export async function refreshAccessToken(
183
189
  access_token: string;
184
190
  token_type: string;
185
191
  expires_in?: number;
192
+ scope?: string;
186
193
  refresh_token?: string;
187
194
  };
188
195
 
196
+ if (typeof data.access_token !== "string" || data.access_token.length === 0) {
197
+ throw new Error(`Token refresh failed: ${config.provider} response missing access_token`);
198
+ }
199
+
200
+ if (
201
+ config.requiresRefreshTokenRotation &&
202
+ (typeof data.refresh_token !== "string" || data.refresh_token.length === 0)
203
+ ) {
204
+ throw new Error(
205
+ `Token refresh failed: ${config.provider} response did not include a rotated refresh_token`,
206
+ );
207
+ }
208
+
189
209
  const expiresAt = data.expires_in
190
210
  ? new Date(Date.now() + data.expires_in * 1000).toISOString()
191
211
  : new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
192
212
 
193
- storeOAuthTokens(config.provider, {
194
- accessToken: data.access_token,
195
- refreshToken: data.refresh_token ?? null,
196
- expiresAt,
197
- });
213
+ const nextRefreshToken = data.refresh_token ?? refreshToken;
214
+ try {
215
+ updateOAuthTokensAfterRefresh(config.provider, refreshToken, {
216
+ accessToken: data.access_token,
217
+ refreshToken: nextRefreshToken,
218
+ expiresAt,
219
+ scope: data.scope ?? null,
220
+ });
221
+ } catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ console.warn(
224
+ `[OAuth] Refusing to use refreshed ${config.provider} access token because persistence failed: ${message}`,
225
+ );
226
+ throw err;
227
+ }
198
228
 
199
229
  return {
200
230
  accessToken: data.access_token,
201
231
  refreshToken: data.refresh_token,
202
232
  expiresIn: data.expires_in,
233
+ scope: data.scope,
203
234
  };
204
235
  }
205
236
 
@@ -395,6 +395,10 @@ class ClaudeSession implements ProviderSession {
395
395
  this.config.prompt,
396
396
  ];
397
397
 
398
+ if (this.config.resumeSessionId) {
399
+ cmd.push("--resume", this.config.resumeSessionId);
400
+ }
401
+
398
402
  if (this.config.additionalArgs?.length) {
399
403
  cmd.push(...this.config.additionalArgs);
400
404
  }
@@ -688,10 +692,11 @@ class ClaudeSession implements ProviderSession {
688
692
  // Stale session retry: if process failed because session not found and we used --resume,
689
693
  // strip --resume and retry with a fresh session
690
694
  if (result.exitCode !== 0 && this.errorTracker.isSessionNotFound()) {
691
- const hasResume = (this.config.additionalArgs || []).includes("--resume");
695
+ const hasResume =
696
+ !!this.config.resumeSessionId || (this.config.additionalArgs || []).includes("--resume");
692
697
  if (hasResume) {
693
698
  console.log(
694
- `\x1b[33m[${this.config.role}] Session not found for task ${this.config.taskId.slice(0, 8)} — retrying without --resume\x1b[0m`,
699
+ `\x1b[33m[${this.config.role}] Session resume failed for task ${this.config.taskId.slice(0, 8)} — retrying without --resume\x1b[0m`,
695
700
  );
696
701
 
697
702
  const freshArgs = (this.config.additionalArgs || []).filter((arg, idx, arr) => {
@@ -642,7 +642,7 @@ class ClaudeManagedSession implements ProviderSession {
642
642
  this.emit({
643
643
  type: "session_init",
644
644
  sessionId: this._sessionId,
645
- provider: "claude" as const,
645
+ provider: "claude-managed",
646
646
  providerMeta: { managed: true },
647
647
  });
648
648
 
@@ -71,6 +71,7 @@ import {
71
71
  clampContextPercent,
72
72
  computeContextUsedUnified,
73
73
  } from "../utils/context-window";
74
+ import { SessionErrorTracker } from "../utils/error-tracker";
74
75
  import { summarizeSession as runSummarize } from "../utils/internal-ai";
75
76
  import { scrubSecrets } from "../utils/secret-scrubber";
76
77
  import { type CodexAgentsMdHandle, writeCodexAgentsMd } from "./codex-agents-md";
@@ -413,6 +414,7 @@ class CodexSession implements ProviderSession {
413
414
  private lastUsage: Usage | null = null;
414
415
  private aborted = false;
415
416
  private settled = false;
417
+ private readonly errorTracker = new SessionErrorTracker();
416
418
  /**
417
419
  * Result captured by `settle` but held back from `resolveCompletion` until
418
420
  * `runSession`'s `finally` block has fully cleaned up (log writer flush,
@@ -951,9 +953,11 @@ class CodexSession implements ProviderSession {
951
953
  }
952
954
  if (event.type === "turn.failed" && !terminalError) {
953
955
  terminalError = this.formatTerminalError(event.error.message);
956
+ this.errorTracker.processCodexUsageLimitMessage(event.error.message);
954
957
  }
955
958
  if (event.type === "error" && !terminalError) {
956
959
  terminalError = this.formatTerminalError(event.message);
960
+ this.errorTracker.processCodexUsageLimitMessage(event.message);
957
961
  }
958
962
  }
959
963
  } catch (err) {
@@ -970,6 +974,30 @@ class CodexSession implements ProviderSession {
970
974
  });
971
975
  return;
972
976
  }
977
+ // The Codex CLI exits with code 1 after emitting a UsageLimitReached or
978
+ // other terminal error event. The SDK then throws "Codex Exec exited with
979
+ // code 1: Reading prompt from stdin" AFTER the event loop ends, which
980
+ // would overwrite the structured terminalError we already captured above.
981
+ // Preserve the structured error so the [usage-limit] prefix survives to
982
+ // the runner's rate-limit resolver.
983
+ if (terminalError) {
984
+ const cost = this.buildCostData(this.lastUsage, true);
985
+ this.emit({
986
+ type: "result",
987
+ cost,
988
+ isError: true,
989
+ errorCategory: terminalError.category ?? "turn_failed",
990
+ });
991
+ this.settle({
992
+ exitCode: 1,
993
+ sessionId: this._sessionId,
994
+ cost,
995
+ isError: true,
996
+ failureReason: terminalError.message,
997
+ rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
998
+ });
999
+ return;
1000
+ }
973
1001
  throw err;
974
1002
  }
975
1003
 
@@ -987,6 +1015,7 @@ class CodexSession implements ProviderSession {
987
1015
  cost,
988
1016
  isError,
989
1017
  failureReason: terminalError?.message,
1018
+ rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
990
1019
  });
991
1020
  } catch (err) {
992
1021
  const message = err instanceof Error ? err.message : String(err);
@@ -1000,6 +1029,7 @@ class CodexSession implements ProviderSession {
1000
1029
  cost,
1001
1030
  isError: true,
1002
1031
  failureReason: message,
1032
+ rateLimitResetAt: this.errorTracker.getRateLimitResetAt(),
1003
1033
  });
1004
1034
  } finally {
1005
1035
  // Session-end summarization. Pure addition for codex — no behavior to