@desplega.ai/agent-swarm 1.63.0 → 1.63.1

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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.63.0",
5
+ "version": "1.63.1",
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": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.63.0",
3
+ "version": "1.63.1",
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>",
package/src/cli.tsx CHANGED
@@ -255,6 +255,21 @@ const COMMAND_HELP: Record<
255
255
  options: " -h, --help Show this help",
256
256
  examples: [` ${binName} artifact serve`, ` ${binName} artifact help`].join("\n"),
257
257
  },
258
+ "codex-login": {
259
+ usage: `${binName} codex-login [options]`,
260
+ description:
261
+ "Authenticate Codex via ChatGPT OAuth (browser or manual paste).\nPrompts interactively for the target API URL and a best-effort masked API key, then stores credentials in the swarm API config store for deployed workers.",
262
+ options: [
263
+ " --api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)",
264
+ " --api-key <key> Swarm API key (default: API_KEY or 123123)",
265
+ " -h, --help Show this help",
266
+ ].join("\n"),
267
+ examples: [
268
+ ` ${binName} codex-login`,
269
+ ` ${binName} codex-login --api-url https://swarm.example.com`,
270
+ ` ${binName} codex-login --api-url https://swarm.example.com --api-key <api-key>`,
271
+ ].join("\n"),
272
+ },
258
273
  };
259
274
 
260
275
  function printHelp(command?: string) {
@@ -283,6 +298,7 @@ function printHelp(command?: string) {
283
298
  ["hook", "Handle Claude Code hook events (stdin)"],
284
299
  ["artifact", "Manage agent artifacts"],
285
300
  ["docs", "Open documentation (--open to launch in browser)"],
301
+ ["codex-login", "Authenticate Codex via ChatGPT OAuth"],
286
302
  ["version", "Show version number"],
287
303
  ["help", "Show this help message"],
288
304
  ];
@@ -535,6 +551,10 @@ if (args.showHelp || args.command === "help" || args.command === undefined) {
535
551
  port: args.port,
536
552
  key: args.key,
537
553
  });
554
+ } else if (args.command === "codex-login") {
555
+ const { runCodexLogin } = await import("./commands/codex-login");
556
+ const codexLoginArgs = process.argv.slice(process.argv.indexOf("codex-login") + 1);
557
+ await runCodexLogin(codexLoginArgs);
538
558
  } else {
539
559
  render(<App args={args} />);
540
560
  }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `agent-swarm codex-login` — authenticate Codex via ChatGPT OAuth.
3
+ *
4
+ * Runs the OAuth PKCE flow (browser redirect to localhost:1455, manual paste
5
+ * fallback), extracts chatgpt_account_id from the JWT, and stores the
6
+ * credentials in the swarm API config store at global scope.
7
+ *
8
+ * This is a non-UI command (plain stdout, no Ink) — it exits immediately
9
+ * after completing or failing the OAuth flow.
10
+ */
11
+
12
+ import { exec } from "node:child_process";
13
+ import { emitKeypressEvents } from "node:readline";
14
+
15
+ import { loginCodexOAuth } from "../providers/codex-oauth/flow.js";
16
+ import { storeCodexOAuth } from "../providers/codex-oauth/storage.js";
17
+
18
+ type PromptTextFn = (label: string, defaultValue: string) => Promise<string>;
19
+ type PromptSecretFn = (label: string, defaultValue: string, helpText?: string) => Promise<string>;
20
+
21
+ type ResolveCodexLoginConfigDeps = {
22
+ env?: Record<string, string | undefined>;
23
+ isInteractive?: boolean;
24
+ promptText?: PromptTextFn;
25
+ promptSecret?: PromptSecretFn;
26
+ };
27
+
28
+ type RunCodexLoginDeps = {
29
+ resolveConfig?: typeof resolveCodexLoginConfig;
30
+ login?: typeof loginCodexOAuth;
31
+ store?: typeof storeCodexOAuth;
32
+ log?: (message: string) => void;
33
+ error?: (message: string) => void;
34
+ exit?: (code: number) => void;
35
+ };
36
+
37
+ type ParsedCodexLoginArgs = {
38
+ apiUrl?: string;
39
+ apiKey?: string;
40
+ showHelp: boolean;
41
+ };
42
+
43
+ function parseCodexLoginArgs(args: string[]): ParsedCodexLoginArgs {
44
+ const parsed: ParsedCodexLoginArgs = { showHelp: false };
45
+
46
+ for (let i = 0; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg === "--api-url" && args[i + 1]) {
49
+ parsed.apiUrl = args[++i]!;
50
+ } else if (arg === "--api-key" && args[i + 1]) {
51
+ parsed.apiKey = args[++i]!;
52
+ } else if (arg === "--help" || arg === "-h") {
53
+ parsed.showHelp = true;
54
+ }
55
+ }
56
+
57
+ return parsed;
58
+ }
59
+
60
+ async function promptTextInput(label: string, defaultValue: string): Promise<string> {
61
+ const { createInterface } = await import("node:readline");
62
+ const rl = createInterface({
63
+ input: process.stdin,
64
+ output: process.stdout,
65
+ });
66
+
67
+ return new Promise<string>((resolve) => {
68
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
69
+ rl.question(`${label}${suffix}: `, (answer) => {
70
+ rl.close();
71
+ resolve(answer.trim());
72
+ });
73
+ });
74
+ }
75
+
76
+ async function promptHiddenInput(
77
+ label: string,
78
+ _defaultValue: string,
79
+ helpText?: string,
80
+ ): Promise<string> {
81
+ const stdin = process.stdin;
82
+ const stdout = process.stdout;
83
+
84
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") {
85
+ return promptTextInput(label, "");
86
+ }
87
+
88
+ if (helpText) {
89
+ stdout.write(`${helpText}\n`);
90
+ }
91
+ stdout.write(`${label}: `);
92
+
93
+ emitKeypressEvents(stdin);
94
+ const wasRaw = stdin.isRaw;
95
+ stdin.setRawMode(true);
96
+ stdin.resume();
97
+
98
+ return new Promise<string>((resolve, reject) => {
99
+ let value = "";
100
+
101
+ const cleanup = () => {
102
+ stdin.setRawMode(Boolean(wasRaw));
103
+ stdin.pause();
104
+ stdin.removeListener("keypress", onKeypress);
105
+ stdout.write("\n");
106
+ };
107
+
108
+ const onKeypress = (str: string, key: { name?: string; ctrl?: boolean; meta?: boolean }) => {
109
+ if (key.ctrl && key.name === "c") {
110
+ cleanup();
111
+ reject(new Error("Aborted"));
112
+ return;
113
+ }
114
+
115
+ if (key.name === "return" || key.name === "enter") {
116
+ cleanup();
117
+ resolve(value.trim());
118
+ return;
119
+ }
120
+
121
+ if (key.name === "backspace") {
122
+ if (value.length > 0) {
123
+ value = value.slice(0, -1);
124
+ stdout.write("\b \b");
125
+ }
126
+ return;
127
+ }
128
+
129
+ if (!key.ctrl && !key.meta && str) {
130
+ value += str;
131
+ stdout.write("*");
132
+ }
133
+ };
134
+
135
+ stdin.on("keypress", onKeypress);
136
+ });
137
+ }
138
+
139
+ export async function resolveCodexLoginConfig(
140
+ args: string[],
141
+ deps: ResolveCodexLoginConfigDeps = {},
142
+ ): Promise<{ apiUrl: string; apiKey: string }> {
143
+ const env = deps.env ?? process.env;
144
+ const parsed = parseCodexLoginArgs(args);
145
+ const promptText = deps.promptText ?? promptTextInput;
146
+ const promptSecret = deps.promptSecret ?? promptHiddenInput;
147
+ const isInteractive = deps.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
148
+ const defaultApiUrl = env.MCP_BASE_URL || "http://localhost:3013";
149
+ const defaultApiKey = env.API_KEY || "123123";
150
+
151
+ let apiUrl = parsed.apiUrl ?? defaultApiUrl;
152
+ let apiKey = parsed.apiKey ?? defaultApiKey;
153
+
154
+ if (!parsed.apiUrl && isInteractive) {
155
+ apiUrl = (await promptText("Swarm API URL", defaultApiUrl)).trim() || defaultApiUrl;
156
+ }
157
+
158
+ if (!parsed.apiKey && isInteractive) {
159
+ const apiKeyHelp = env.API_KEY
160
+ ? "Press Enter to use API_KEY from the environment"
161
+ : "Press Enter to use the default local API key";
162
+ apiKey =
163
+ (await promptSecret("Swarm API key", defaultApiKey, apiKeyHelp)).trim() || defaultApiKey;
164
+ }
165
+
166
+ return { apiUrl, apiKey };
167
+ }
168
+
169
+ function printHelp() {
170
+ console.log(`
171
+ agent-swarm codex-login — Authenticate Codex via ChatGPT OAuth
172
+
173
+ Usage:
174
+ agent-swarm codex-login [options]
175
+
176
+ Options:
177
+ --api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)
178
+ --api-key <key> Swarm API key (default: API_KEY or 123123)
179
+ -h, --help Show this help
180
+
181
+ Without flags, the command prompts interactively for the target API URL and
182
+ for the swarm API key using masked input when the terminal supports it.
183
+
184
+ This command runs the OpenAI Codex OAuth PKCE flow:
185
+ 1. Opens a browser to ChatGPT login
186
+ 2. Receives the authorization code via localhost:1455 callback
187
+ 3. Exchanges the code for access/refresh tokens
188
+ 4. Stores credentials in the swarm API config store
189
+
190
+ Deployed Codex workers automatically restore these credentials at boot.
191
+ `);
192
+ }
193
+
194
+ export async function runCodexLogin(args: string[], deps: RunCodexLoginDeps = {}): Promise<void> {
195
+ const resolveConfig = deps.resolveConfig ?? resolveCodexLoginConfig;
196
+ const login = deps.login ?? loginCodexOAuth;
197
+ const store = deps.store ?? storeCodexOAuth;
198
+ const log = deps.log ?? console.log;
199
+ const error = deps.error ?? console.error;
200
+ const exit = deps.exit ?? ((code: number) => process.exit(code));
201
+
202
+ if (parseCodexLoginArgs(args).showHelp) {
203
+ printHelp();
204
+ return;
205
+ }
206
+
207
+ let browserOpened = false;
208
+
209
+ try {
210
+ const { apiUrl, apiKey } = await resolveConfig(args);
211
+
212
+ log("Starting Codex ChatGPT OAuth login...\n");
213
+ log(`Target swarm API: ${apiUrl}\n`);
214
+
215
+ const creds = await login({
216
+ onAuth: ({ url, instructions }) => {
217
+ log(`Open this URL in your browser:\n\n ${url}\n`);
218
+ if (instructions) {
219
+ log(instructions);
220
+ }
221
+ // Try to open the browser (fire-and-forget, non-fatal)
222
+ if (!browserOpened) {
223
+ browserOpened = true;
224
+ const cmd =
225
+ process.platform === "darwin"
226
+ ? "open"
227
+ : process.platform === "win32"
228
+ ? "start"
229
+ : "xdg-open";
230
+ exec(`${cmd} "${url}"`, (err) => {
231
+ if (err) {
232
+ log("(Could not open browser automatically)\n");
233
+ }
234
+ });
235
+ }
236
+ },
237
+ onPrompt: async ({ message }) => {
238
+ return promptTextInput(message, "");
239
+ },
240
+ onProgress: (message) => {
241
+ log(message);
242
+ },
243
+ onManualCodeInput: async () => {
244
+ return promptTextInput("Or paste the authorization code here", "");
245
+ },
246
+ });
247
+
248
+ log("\nOAuth flow completed successfully!");
249
+ log(` Account ID: ${creds.accountId}`);
250
+ log(` Expires: ${new Date(creds.expires).toISOString()}`);
251
+
252
+ // Store credentials in the swarm API config store
253
+ log("\nStoring credentials in swarm API config store...");
254
+ await store(apiUrl, apiKey, creds);
255
+ log("Credentials stored successfully!");
256
+
257
+ log("\nDeployed Codex workers will automatically restore these credentials at boot.");
258
+ } catch (err) {
259
+ const message = err instanceof Error ? err.message : String(err);
260
+ error(`\nError: ${message}`);
261
+ exit(1);
262
+ }
263
+ }
@@ -10,6 +10,7 @@ import {
10
10
  generateDefaultToolsMd,
11
11
  } from "../prompts/defaults.ts";
12
12
  import { configureHttpResolver, resolveTemplateAsync } from "../prompts/resolver.ts";
13
+ import { authJsonToCredentialSelection } from "../providers/codex-oauth/auth-json.js";
13
14
  import {
14
15
  type CostData,
15
16
  createProviderAdapter,
@@ -17,6 +18,7 @@ import {
17
18
  type ProviderSession,
18
19
  type ProviderSessionConfig,
19
20
  } from "../providers/index.ts";
21
+ import { initTelemetry, telemetry } from "../telemetry.ts";
20
22
  import type { RepoGuidelines } from "../types.ts";
21
23
  import { getContextWindowSize } from "../utils/context-window.ts";
22
24
  import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
@@ -634,6 +636,33 @@ async function reportKeyUsage(
634
636
  }
635
637
  }
636
638
 
639
+ async function resolveCodexOAuthCredentialInfo(): Promise<CredentialSelection | null> {
640
+ try {
641
+ const home = process.env.HOME;
642
+ if (!home) return null;
643
+
644
+ const authFile = Bun.file(`${home}/.codex/auth.json`);
645
+ if (!(await authFile.exists())) {
646
+ return null;
647
+ }
648
+
649
+ const auth = JSON.parse(await authFile.text()) as {
650
+ auth_mode?: string;
651
+ tokens?: { account_id?: string };
652
+ };
653
+
654
+ if (auth.auth_mode !== "chatgpt" || !auth.tokens?.account_id) {
655
+ return null;
656
+ }
657
+
658
+ return authJsonToCredentialSelection(
659
+ auth as Parameters<typeof authJsonToCredentialSelection>[0],
660
+ );
661
+ } catch {
662
+ return null;
663
+ }
664
+ }
665
+
637
666
  /** Report a rate-limited key to the API (fire-and-forget) */
638
667
  async function reportKeyRateLimit(
639
668
  apiUrl: string,
@@ -848,6 +877,7 @@ function setupShutdownHandlers(
848
877
  }
849
878
 
850
879
  if (apiConfig) {
880
+ telemetry.session("ended", { agentId: apiConfig.agentId });
851
881
  await closeAgent(apiConfig, role);
852
882
  }
853
883
  await savePm2State(role);
@@ -1567,6 +1597,20 @@ async function spawnProviderProcess(
1567
1597
 
1568
1598
  const session = await adapter.createSession(config);
1569
1599
 
1600
+ let oauthSelection: CredentialSelection | undefined;
1601
+ if (adapter.name === "codex" && credentialSelections.length === 0) {
1602
+ oauthSelection = (await resolveCodexOAuthCredentialInfo()) ?? undefined;
1603
+ if (oauthSelection && realTaskId) {
1604
+ reportKeyUsage(
1605
+ opts.apiUrl,
1606
+ opts.apiKey,
1607
+ oauthSelection.keyType,
1608
+ oauthSelection,
1609
+ realTaskId,
1610
+ ).catch(() => {});
1611
+ }
1612
+ }
1613
+
1570
1614
  // Set up log streaming
1571
1615
  const logBuffer: LogBuffer = { lines: [], lastFlush: Date.now(), partialLine: "" };
1572
1616
  const shouldStream = opts.apiUrl && opts.runnerSessionId && opts.iteration;
@@ -1874,7 +1918,7 @@ async function spawnProviderProcess(
1874
1918
  });
1875
1919
 
1876
1920
  // Build credential info for rate limit tracking
1877
- const primarySelection = credentialSelections[0];
1921
+ const primarySelection = credentialSelections[0] ?? oauthSelection;
1878
1922
  const credentialInfo = primarySelection
1879
1923
  ? {
1880
1924
  keyType: primarySelection.keyType,
@@ -2008,7 +2052,7 @@ async function checkCompletedProcesses(
2008
2052
  console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
2009
2053
 
2010
2054
  // If rate-limited and we know which key was used, report it
2011
- if (credentialInfo && /rate.?limit/i.test(failureReason)) {
2055
+ if (credentialInfo && /rate.?limit|hit your limit/i.test(failureReason)) {
2012
2056
  // Try to extract reset time from the error message (e.g. "resets 3pm (UTC)")
2013
2057
  const parsedResetTime = parseRateLimitResetTime(failureReason);
2014
2058
  const defaultCooldownMs = 5 * 60 * 1000;
@@ -2162,6 +2206,46 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2162
2206
  configureHttpResolver(apiUrl, process.env.API_KEY);
2163
2207
  }
2164
2208
 
2209
+ // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false)
2210
+ // Workers use HTTP-based config access (cannot import DB directly)
2211
+ {
2212
+ const telemetryApiKey = process.env.API_KEY;
2213
+ await initTelemetry(
2214
+ "worker",
2215
+ async (key) => {
2216
+ if (!telemetryApiKey) return undefined;
2217
+ try {
2218
+ const resp = await fetch(`${apiUrl}/api/config?scope=global&includeSecrets=true`, {
2219
+ headers: { Authorization: `Bearer ${telemetryApiKey}` },
2220
+ signal: AbortSignal.timeout(5_000),
2221
+ });
2222
+ if (!resp.ok) return undefined;
2223
+ const data = (await resp.json()) as { configs: { key: string; value: string }[] };
2224
+ return data.configs.find((c) => c.key === key)?.value;
2225
+ } catch {
2226
+ return undefined;
2227
+ }
2228
+ },
2229
+ async (key, value) => {
2230
+ if (!telemetryApiKey) return;
2231
+ try {
2232
+ await fetch(`${apiUrl}/api/config`, {
2233
+ method: "PUT",
2234
+ headers: {
2235
+ Authorization: `Bearer ${telemetryApiKey}`,
2236
+ "Content-Type": "application/json",
2237
+ },
2238
+ body: JSON.stringify({ scope: "global", key, value }),
2239
+ signal: AbortSignal.timeout(5_000),
2240
+ });
2241
+ } catch {
2242
+ // Silently ignore — telemetry is best-effort
2243
+ }
2244
+ },
2245
+ );
2246
+ }
2247
+ telemetry.session("started", { agentId });
2248
+
2165
2249
  let capabilities = config.capabilities;
2166
2250
 
2167
2251
  // Agent identity fields — populated after registration by fetching full profile
package/src/http/index.ts CHANGED
@@ -8,12 +8,13 @@ import { ensure, initialize } from "@desplega.ai/business-use";
8
8
  import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
9
  import { getEnabledCapabilities, hasCapability } from "@/server";
10
10
  import { initAgentMail } from "../agentmail";
11
- import { closeDb } from "../be/db";
11
+ import { closeDb, getSwarmConfigs, upsertSwarmConfig } from "../be/db";
12
12
  import { initGitHub } from "../github";
13
13
  import { initGitLab } from "../gitlab";
14
14
  import { stopHeartbeat } from "../heartbeat";
15
15
  import { initLinear } from "../linear";
16
16
  import { startSlackApp, stopSlackApp } from "../slack";
17
+ import { initTelemetry, telemetry } from "../telemetry";
17
18
  import { initWorkflows } from "../workflows";
18
19
  import { handleActiveSessions } from "./active-sessions";
19
20
  import { handleAgentRegister, handleAgentsRest } from "./agents";
@@ -214,6 +215,16 @@ httpServer
214
215
  console.error("Failed to load global swarm configs:", e);
215
216
  }
216
217
 
218
+ // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false)
219
+ await initTelemetry(
220
+ "api-server",
221
+ (key) => getSwarmConfigs({ scope: "global", key })?.[0]?.value,
222
+ (key, value) => {
223
+ upsertSwarmConfig({ scope: "global", key, value });
224
+ },
225
+ );
226
+ telemetry.server("started", { port });
227
+
217
228
  // Start Slack bot (if configured)
218
229
  await startSlackApp();
219
230
 
package/src/http/poll.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  upsertChannelActivityCursor,
19
19
  } from "../be/db";
20
20
  import { fetchChannelActivity } from "../slack/channel-activity";
21
+ import { telemetry } from "../telemetry";
21
22
  import { route } from "./route-def";
22
23
  import { json, jsonError } from "./utils";
23
24
 
@@ -145,6 +146,12 @@ export async function handlePoll(
145
146
  conditions: [{ timeout_ms: 300_000 }], // 5 min: polling interval + queue wait
146
147
  });
147
148
 
149
+ telemetry.taskEvent("started", {
150
+ taskId: pendingTask.id,
151
+ source: pendingTask.source,
152
+ agentId: myAgentId,
153
+ });
154
+
148
155
  // Resolve requesting user if available
149
156
  const requestedByUser = pendingTask.requestedByUserId
150
157
  ? getUserById(pendingTask.requestedByUserId)
@@ -199,6 +206,11 @@ export async function handlePoll(
199
206
  for (const candidateId of unassignedIds) {
200
207
  const claimed = claimTask(candidateId, myAgentId);
201
208
  if (claimed) {
209
+ telemetry.taskEvent("claimed", {
210
+ taskId: claimed.id,
211
+ source: claimed.source,
212
+ agentId: myAgentId,
213
+ });
202
214
  return {
203
215
  trigger: {
204
216
  type: "task_assigned",
package/src/http/tasks.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  updateTaskProgress,
20
20
  updateTaskVcs,
21
21
  } from "../be/db";
22
+ import { telemetry } from "../telemetry";
22
23
  import { route } from "./route-def";
23
24
  import { json, jsonError } from "./utils";
24
25
 
@@ -269,6 +270,14 @@ export async function handleTasks(
269
270
  },
270
271
  });
271
272
 
273
+ telemetry.taskEvent("created", {
274
+ taskId: task.id,
275
+ source: task.source,
276
+ tags: parsed.body.tags ?? [],
277
+ hasParent: !!task.parentTaskId,
278
+ priority: task.priority,
279
+ });
280
+
272
281
  json(res, task, 201);
273
282
  } catch (error) {
274
283
  console.error("[HTTP] Failed to create task:", error);
@@ -370,6 +379,14 @@ export async function handleTasks(
370
379
  });
371
380
  }
372
381
 
382
+ telemetry.taskEvent("cancelled", {
383
+ taskId: parsed.params.id,
384
+ source: task.source,
385
+ agentId: task.agentId ?? undefined,
386
+ previousStatus: task.status,
387
+ durationMs: task.createdAt ? Date.now() - new Date(task.createdAt).getTime() : undefined,
388
+ });
389
+
373
390
  if (task.agentId) {
374
391
  updateAgentStatusFromCapacity(task.agentId);
375
392
  }
@@ -469,6 +486,16 @@ export async function handleTasks(
469
486
 
470
487
  if (result.task && !("alreadyFinished" in result && result.alreadyFinished)) {
471
488
  const finishEventId = parsed.body.status === "completed" ? "completed" : "failed";
489
+
490
+ const durationMs = result.task.createdAt
491
+ ? Date.now() - new Date(result.task.createdAt).getTime()
492
+ : undefined;
493
+
494
+ telemetry.taskEvent(finishEventId, {
495
+ taskId: parsed.params.id,
496
+ agentId: myAgentId,
497
+ durationMs,
498
+ });
472
499
  ensure({
473
500
  id: finishEventId,
474
501
  flow: "task",
@@ -72,6 +72,8 @@ import {
72
72
  getCodexContextWindow,
73
73
  resolveCodexModel,
74
74
  } from "./codex-models";
75
+ import { credentialsToAuthJson } from "./codex-oauth/auth-json.js";
76
+ import { getValidCodexOAuth } from "./codex-oauth/storage.js";
75
77
  import { resolveCodexPrompt } from "./codex-skill-resolver";
76
78
  import { createCodexSwarmEventHandler } from "./codex-swarm-events";
77
79
  import type {
@@ -792,6 +794,46 @@ export class CodexAdapter implements ProviderAdapter {
792
794
  ...(config.env ?? {}),
793
795
  };
794
796
 
797
+ // OAuth credential resolution: if no OPENAI_API_KEY is set, try to
798
+ // restore or refresh ChatGPT OAuth credentials from the config store.
799
+ // The entrypoint also restores at boot, but this handles cases where
800
+ // the entrypoint didn't run (local dev) or tokens expired mid-session.
801
+ if (!process.env.OPENAI_API_KEY && config.apiUrl && config.apiKey) {
802
+ const authJsonPath = join(os.homedir(), ".codex", "auth.json");
803
+ let hasAuth = false;
804
+ try {
805
+ const fs = await import("node:fs/promises");
806
+ await fs.access(authJsonPath);
807
+ hasAuth = true;
808
+ } catch {
809
+ // auth.json doesn't exist
810
+ }
811
+
812
+ if (!hasAuth) {
813
+ const oauthCreds = await getValidCodexOAuth(config.apiUrl, config.apiKey);
814
+ if (oauthCreds) {
815
+ try {
816
+ const fs = await import("node:fs/promises");
817
+ const authJson = credentialsToAuthJson(oauthCreds);
818
+ await fs.mkdir(join(os.homedir(), ".codex"), { recursive: true, mode: 0o700 });
819
+ await fs.writeFile(authJsonPath, JSON.stringify(authJson, null, 2), {
820
+ mode: 0o600,
821
+ });
822
+ bufferedEmit({
823
+ type: "raw_stderr",
824
+ content: "[codex] Restored OAuth credentials from config store\n",
825
+ });
826
+ } catch (err) {
827
+ const message = err instanceof Error ? err.message : String(err);
828
+ bufferedEmit({
829
+ type: "raw_stderr",
830
+ content: `[codex] Failed to write auth.json: ${message}\n`,
831
+ });
832
+ }
833
+ }
834
+ }
835
+ }
836
+
795
837
  // The SDK's default `findCodexPath()` does `require.resolve("@openai/codex")`
796
838
  // from the SDK's own module. When agent-swarm runs as a Bun single-file
797
839
  // compiled executable, the bundled SDK can't resolve `@openai/codex` at