@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
@@ -0,0 +1,104 @@
1
+ import { deleteKv, getKv, getSwarmConfigs, upsertKv } from "@/be/db";
2
+
3
+ /**
4
+ * Native Kapso/WhatsApp integration — shared server-side config + mapping store.
5
+ *
6
+ * The mapping (phone-number-id → routing target) is backed by the swarm KV store
7
+ * under a pinned namespace, NOT a dedicated table. The inbound webhook handler and
8
+ * the `register-kapso-number` MCP tool are the only readers/writers.
9
+ */
10
+
11
+ /** Pinned KV namespace for phone-number → routing mappings. No TTL. */
12
+ export const KAPSO_NUMBERS_NAMESPACE = "integrations:kapso:numbers";
13
+
14
+ /** Pinned KV namespace for inbound message-id dedupe markers (24h TTL). */
15
+ export const KAPSO_DEDUPE_NAMESPACE = "integrations:kapso:dedupe";
16
+
17
+ /** How long a dedupe marker lives — long enough to cover Kapso's webhook retries. */
18
+ export const KAPSO_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
19
+
20
+ /** Default Kapso API host when `KAPSO_API_BASE_URL` is unset (host only, no path). */
21
+ export const DEFAULT_KAPSO_API_BASE_URL = "https://api.kapso.ai";
22
+
23
+ /** A registered phone number and where its inbound messages should route. */
24
+ export interface KapsoNumberMapping {
25
+ phoneNumberId: string;
26
+ /** Route inbound to this agent as a task. */
27
+ agentId?: string;
28
+ /** Advanced override: dispatch via this workflow's webhook trigger instead of a task. */
29
+ workflowId?: string;
30
+ /** Human-friendly display name for the number. */
31
+ name?: string;
32
+ createdAt: string;
33
+ }
34
+
35
+ export interface KapsoConfig {
36
+ apiKey: string | undefined;
37
+ apiBaseUrl: string;
38
+ webhookHmacSecret: string | undefined;
39
+ phoneNumberId: string | undefined;
40
+ }
41
+
42
+ /**
43
+ * Read a swarm-config value (global scope) by key, falling back to the process
44
+ * env. Decryption happens inside `getSwarmConfigs`.
45
+ */
46
+ function readConfigValue(key: string): string | undefined {
47
+ const found = getSwarmConfigs({ scope: "global", key }).find(
48
+ (c) => typeof c.value === "string" && c.value.length > 0,
49
+ );
50
+ if (found) return found.value;
51
+ const env = process.env[key];
52
+ return env && env.length > 0 ? env : undefined;
53
+ }
54
+
55
+ /** Resolve the Kapso integration config from swarm config (env fallback). */
56
+ export function getKapsoConfig(): KapsoConfig {
57
+ const base = readConfigValue("KAPSO_API_BASE_URL") ?? DEFAULT_KAPSO_API_BASE_URL;
58
+ return {
59
+ apiKey: readConfigValue("KAPSO_API_KEY"),
60
+ apiBaseUrl: base.replace(/\/+$/, ""),
61
+ webhookHmacSecret: readConfigValue("KAPSO_WEBHOOK_HMAC_SECRET"),
62
+ phoneNumberId: readConfigValue("KAPSO_PHONE_NUMBER_ID"),
63
+ };
64
+ }
65
+
66
+ /** Look up the routing mapping for a phone-number-id, or null if unregistered. */
67
+ export function getKapsoNumberMapping(phoneNumberId: string): KapsoNumberMapping | null {
68
+ const row = getKv(KAPSO_NUMBERS_NAMESPACE, phoneNumberId);
69
+ return row ? (row.value as KapsoNumberMapping) : null;
70
+ }
71
+
72
+ /** Upsert a routing mapping (no TTL). */
73
+ export function putKapsoNumberMapping(mapping: KapsoNumberMapping): KapsoNumberMapping {
74
+ upsertKv({
75
+ namespace: KAPSO_NUMBERS_NAMESPACE,
76
+ key: mapping.phoneNumberId,
77
+ value: mapping,
78
+ valueType: "json",
79
+ expiresAt: null,
80
+ });
81
+ return mapping;
82
+ }
83
+
84
+ /** Delete a routing mapping. Returns true if a row was removed. */
85
+ export function deleteKapsoNumberMapping(phoneNumberId: string): boolean {
86
+ return deleteKv(KAPSO_NUMBERS_NAMESPACE, phoneNumberId);
87
+ }
88
+
89
+ /**
90
+ * Record a message-id as processed. Returns true the FIRST time a given id is
91
+ * seen and false on every subsequent delivery within the TTL window — so the
92
+ * caller drops duplicates (Kapso retries deliveries).
93
+ */
94
+ export function markKapsoMessageSeen(messageId: string): boolean {
95
+ if (getKv(KAPSO_DEDUPE_NAMESPACE, messageId)) return false;
96
+ upsertKv({
97
+ namespace: KAPSO_DEDUPE_NAMESPACE,
98
+ key: messageId,
99
+ value: 1,
100
+ valueType: "integer",
101
+ expiresAt: Date.now() + KAPSO_DEDUPE_TTL_MS,
102
+ });
103
+ return true;
104
+ }
@@ -0,0 +1,111 @@
1
+ import { resolveTemplate } from "@/prompts/resolver";
2
+ import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
3
+ import { workflowEventBus } from "@/workflows/event-bus";
4
+ import "@/tools/templates";
5
+ import { getKapsoNumberMapping, markKapsoMessageSeen } from "./config";
6
+
7
+ /** Minimal shape of the Kapso v2 inbound webhook payload (see the kapso-whatsapp skill). */
8
+ export interface KapsoWebhookPayload {
9
+ message?: {
10
+ id?: string;
11
+ from?: string;
12
+ type?: string;
13
+ text?: { body?: string };
14
+ kapso?: { direction?: string; content?: string; has_media?: boolean };
15
+ };
16
+ conversation?: {
17
+ id?: string;
18
+ phone_number?: string;
19
+ contact_name?: string;
20
+ };
21
+ phone_number_id?: string;
22
+ test?: boolean;
23
+ }
24
+
25
+ /** Outcome of routing one inbound webhook delivery. */
26
+ export type KapsoRouting =
27
+ | { kind: "skip"; reason: string }
28
+ | { kind: "duplicate"; messageId: string }
29
+ | { kind: "workflow"; workflowId: string }
30
+ | { kind: "task"; taskId: string }
31
+ | { kind: "no_mapping"; phoneNumberId: string };
32
+
33
+ function extractText(message: NonNullable<KapsoWebhookPayload["message"]>): string {
34
+ if (message.text?.body) return message.text.body;
35
+ if (message.kapso?.content) return message.kapso.content;
36
+ return `(non-text message — type: ${message.type ?? "unknown"})`;
37
+ }
38
+
39
+ function buildTaskDescription(payload: KapsoWebhookPayload): string {
40
+ const message = payload.message ?? {};
41
+ const conversation = payload.conversation ?? {};
42
+ return resolveTemplate("kapso.message.received", {
43
+ conversation_id: conversation.id ?? "unknown",
44
+ inbound_wamid: message.id ?? "unknown",
45
+ sender_phone: message.from ?? conversation.phone_number ?? "unknown",
46
+ contact_name: conversation.contact_name ?? "unknown",
47
+ phone_number_id: payload.phone_number_id ?? "unknown",
48
+ test_note: payload.test ? "\n- test: true (do NOT send a real WhatsApp reply)" : "",
49
+ message_text: extractText(message),
50
+ }).text;
51
+ }
52
+
53
+ /**
54
+ * Route one inbound Kapso webhook delivery. Pure of HTTP concerns — the caller
55
+ * handles HMAC verification and the workflow-trigger dispatch (which needs the
56
+ * raw body + executor registry). This:
57
+ * 1. drops non-inbound events and deliveries missing a message id,
58
+ * 2. dedupes by message id (KV, 24h TTL),
59
+ * 3. emits the `kapso.message.received` workflow event (additive),
60
+ * 4. looks up the phone-number mapping and either signals a workflow dispatch
61
+ * or creates a native `kapso-inbound` task,
62
+ * 5. returns `no_mapping` when the number isn't registered (caller logs a warning).
63
+ */
64
+ export function routeKapsoInbound(payload: KapsoWebhookPayload): KapsoRouting {
65
+ const message = payload.message;
66
+ const direction = message?.kapso?.direction;
67
+ if (direction !== "inbound") {
68
+ return { kind: "skip", reason: `non_inbound (direction=${direction ?? "none"})` };
69
+ }
70
+
71
+ const messageId = message?.id;
72
+ if (!messageId) {
73
+ return { kind: "skip", reason: "missing_message_id" };
74
+ }
75
+
76
+ if (!markKapsoMessageSeen(messageId)) {
77
+ return { kind: "duplicate", messageId };
78
+ }
79
+
80
+ const phoneNumberId = payload.phone_number_id ?? "";
81
+
82
+ // Additive: let event-subscribed workflows observe inbound regardless of mapping.
83
+ workflowEventBus.emit("kapso.message.received", {
84
+ phoneNumberId,
85
+ conversationId: payload.conversation?.id,
86
+ messageId,
87
+ from: message?.from,
88
+ type: message?.type,
89
+ text: extractText(message ?? {}),
90
+ });
91
+
92
+ const mapping = phoneNumberId ? getKapsoNumberMapping(phoneNumberId) : null;
93
+ if (!mapping) {
94
+ return { kind: "no_mapping", phoneNumberId };
95
+ }
96
+
97
+ if (mapping.workflowId) {
98
+ return { kind: "workflow", workflowId: mapping.workflowId };
99
+ }
100
+
101
+ const task = createTaskWithSiblingAwareness(buildTaskDescription(payload), {
102
+ agentId: mapping.agentId ?? null,
103
+ source: "system",
104
+ taskType: "kapso-inbound",
105
+ tags: ["kapso-whatsapp", "inbound"],
106
+ priority: 70,
107
+ contextKey: `kapso:conversation:${payload.conversation?.id ?? messageId}`,
108
+ });
109
+
110
+ return { kind: "task", taskId: task.id };
111
+ }
@@ -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