@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.
- package/openapi.json +158 -8
- package/package.json +1 -1
- package/src/artifact-sdk/server.ts +23 -1
- package/src/be/budget-admission.ts +28 -4
- package/src/be/budget-refusal-notify.ts +19 -3
- package/src/be/db-queries/oauth.ts +43 -0
- package/src/be/db.ts +35 -2
- package/src/be/migrations/074_user_budget_scope.sql +85 -0
- package/src/commands/resume-session.ts +118 -0
- package/src/commands/runner.ts +137 -67
- package/src/http/core.ts +4 -1
- package/src/http/index.ts +16 -0
- package/src/http/integrations.ts +26 -0
- package/src/http/mcp-user.ts +111 -0
- package/src/http/poll.ts +19 -5
- package/src/http/schedules.ts +1 -1
- package/src/http/users.ts +107 -2
- package/src/http/webhooks.ts +101 -0
- package/src/integrations/kapso/client.ts +198 -0
- package/src/integrations/kapso/config.ts +104 -0
- package/src/integrations/kapso/inbound.ts +111 -0
- package/src/jira/client.ts +3 -5
- package/src/jira/oauth.ts +1 -0
- package/src/jira/sync.ts +2 -2
- package/src/oauth/ensure-token.ts +1 -0
- package/src/oauth/wrapper.ts +38 -7
- package/src/providers/claude-adapter.ts +7 -2
- package/src/providers/claude-managed-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +30 -0
- package/src/providers/opencode-adapter.ts +149 -14
- package/src/providers/pi-mono-adapter.ts +41 -1
- package/src/providers/types.ts +1 -1
- package/src/server-user.ts +117 -0
- package/src/server.ts +14 -0
- package/src/tests/artifact-sdk.test.ts +23 -19
- package/src/tests/budget-user-scope.test.ts +376 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/codex-adapter.test.ts +192 -0
- package/src/tests/codex-rate-limit-parse.test.ts +256 -0
- package/src/tests/db-queries-oauth.test.ts +43 -0
- package/src/tests/ensure-token.test.ts +93 -0
- package/src/tests/error-tracker.test.ts +52 -0
- package/src/tests/fetch-resolved-env.test.ts +33 -20
- package/src/tests/http-users.test.ts +29 -1
- package/src/tests/kapso-client.test.ts +94 -0
- package/src/tests/kapso-inbound.test.ts +198 -0
- package/src/tests/mcp-user-route.test.ts +325 -0
- package/src/tests/opencode-adapter.test.ts +75 -0
- package/src/tests/pi-mono-adapter.test.ts +21 -1
- package/src/tests/rate-limit-event.test.ts +69 -6
- package/src/tests/resume-session.test.ts +93 -0
- package/src/tests/task-tools-ctx.test.ts +100 -0
- package/src/tests/task-tools-ownership.test.ts +167 -0
- package/src/tests/tool-annotations.test.ts +3 -2
- package/src/tests/user-token-routes.test.ts +221 -0
- package/src/tools/cancel-task.ts +137 -83
- package/src/tools/get-task-details.ts +73 -59
- package/src/tools/get-tasks.ts +134 -126
- package/src/tools/register-kapso-number.ts +210 -0
- package/src/tools/send-task.ts +312 -312
- package/src/tools/task-action.ts +464 -367
- package/src/tools/task-tool-ctx.ts +43 -0
- package/src/tools/templates.ts +35 -0
- package/src/tools/tool-config.ts +6 -0
- package/src/tools/whatsapp-message.ts +135 -0
- package/src/types.ts +6 -2
- package/src/utils/error-tracker.ts +122 -9
- package/templates/skills/agentmail-sending/SKILL.md +49 -0
- 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
|
+
}
|
package/src/jira/client.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
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
|
|
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
|
|
package/src/oauth/wrapper.ts
CHANGED
|
@@ -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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 =
|
|
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
|
|
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) => {
|
|
@@ -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
|