talon-agent 5.25.1 → 5.26.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 (66) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/backend/agy/one-shot.ts +34 -3
  4. package/src/backend/codex/auth.ts +31 -2
  5. package/src/backend/codex/constants.ts +21 -7
  6. package/src/backend/codex/discovery.ts +32 -1
  7. package/src/backend/codex/factory.ts +3 -5
  8. package/src/backend/codex/handler/message.ts +7 -7
  9. package/src/backend/codex/models.ts +53 -11
  10. package/src/backend/codex/one-shot.ts +139 -37
  11. package/src/backend/codex/state.ts +11 -0
  12. package/src/core/agents/abort-reason.ts +46 -0
  13. package/src/core/agents/registry.ts +3 -2
  14. package/src/core/background/dream/index.ts +2 -2
  15. package/src/core/background/heartbeat/agent.ts +1 -1
  16. package/src/core/background/isolated-agent.ts +1 -1
  17. package/src/core/backup/index.ts +1 -0
  18. package/src/core/backup/status.ts +30 -1
  19. package/src/core/config/index.ts +8 -0
  20. package/src/core/engine/gateway.ts +5 -0
  21. package/src/core/mesh/credentials/store.ts +60 -9
  22. package/src/core/tools/bridge.ts +58 -4
  23. package/src/frontend/discord/callbacks/components/effort.ts +4 -4
  24. package/src/frontend/discord/callbacks/components/index.ts +2 -0
  25. package/src/frontend/discord/commands/backup-panel.ts +219 -0
  26. package/src/frontend/discord/commands/backup.ts +25 -34
  27. package/src/frontend/discord/commands/info.ts +17 -5
  28. package/src/frontend/discord/commands/settings.ts +12 -9
  29. package/src/frontend/discord/render.ts +1 -1
  30. package/src/frontend/native/bridge/credentials/claims.ts +5 -5
  31. package/src/frontend/native/bridge/routes/chats.ts +40 -20
  32. package/src/frontend/native/bridge/routes/host.ts +15 -2
  33. package/src/frontend/native/bridge/routes/table.ts +4 -0
  34. package/src/frontend/native/commands/admin.ts +64 -0
  35. package/src/frontend/native/commands/backup.ts +191 -0
  36. package/src/frontend/native/commands/definitions.ts +113 -0
  37. package/src/frontend/native/commands/format.ts +24 -0
  38. package/src/frontend/native/commands/index.ts +106 -0
  39. package/src/frontend/native/commands/info.ts +97 -0
  40. package/src/frontend/native/commands/session.ts +130 -0
  41. package/src/frontend/native/commands/types.ts +26 -0
  42. package/src/frontend/native/protocol.ts +18 -0
  43. package/src/frontend/native/surface/handlers.ts +14 -1
  44. package/src/frontend/native/surface/status.ts +9 -1
  45. package/src/frontend/native/turn/emit.ts +32 -1
  46. package/src/frontend/presentation/backup-panel.ts +425 -0
  47. package/src/frontend/presentation/memory-report.ts +109 -0
  48. package/src/frontend/presentation/text-commands.ts +243 -0
  49. package/src/frontend/telegram/admin/sessions.ts +20 -5
  50. package/src/frontend/telegram/admin.ts +9 -2
  51. package/src/frontend/telegram/callbacks/backup.ts +153 -22
  52. package/src/frontend/telegram/callbacks/effort.ts +5 -5
  53. package/src/frontend/telegram/callbacks/index.ts +2 -2
  54. package/src/frontend/telegram/callbacks/settings.ts +3 -40
  55. package/src/frontend/telegram/commands/admin.ts +8 -7
  56. package/src/frontend/telegram/commands/backup.ts +56 -46
  57. package/src/frontend/telegram/commands/index.ts +3 -2
  58. package/src/frontend/telegram/commands/info.ts +45 -26
  59. package/src/frontend/telegram/commands/memory.ts +8 -92
  60. package/src/frontend/telegram/commands/settings.ts +13 -45
  61. package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
  62. package/src/frontend/telegram/render/backup-panel.ts +47 -0
  63. package/src/frontend/telegram/render/menu.ts +21 -30
  64. package/src/frontend/terminal/builtins/model.ts +20 -11
  65. package/src/frontend/whatsapp/commands.ts +16 -216
  66. package/src/frontend/whatsapp/messages/inbound.ts +33 -2
package/README.md CHANGED
@@ -487,6 +487,7 @@ Config file: `~/.talon/config.json`
487
487
  | `model` | `"default"` | Default model. Interpretation depends on the active backend. |
488
488
  | `agyBinary` | --- | Path to the Antigravity `agy` executable. `AGY_BINARY` overrides it. |
489
489
  | `codexApiKey` | --- | Codex-only OpenAI API key. Prefer this over `openaiApiKey` for Codex API-key auth. `codex login` takes precedence over shared `openaiApiKey`. |
490
+ | `codexChatGptDefaultModel` | --- | Model a ChatGPT-login Codex session falls back to when a model is rejected or none is set. Default: the Codex CLI's own default for the account (from `~/.codex/models_cache.json`). `TALON_CODEX_CHATGPT_MODEL` overrides it. |
490
491
  | `concurrency` | `1` | Max concurrent AI queries (1--20) |
491
492
  | `pulse` | `true` | Periodic group engagement |
492
493
  | `heartbeat` | `false` | Background maintenance agent |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.25.1",
3
+ "version": "5.26.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "The Falconry",
6
6
  "license": "Apache-2.0",
@@ -33,6 +33,7 @@ import {
33
33
  type AgyResult,
34
34
  type AgyStepUpdate,
35
35
  } from "./events.js";
36
+ import { abortLogLine } from "../../core/agents/abort-reason.js";
36
37
 
37
38
  const ts = (): string => new Date().toISOString().slice(11, 19);
38
39
 
@@ -257,20 +258,39 @@ export async function runOneShotAgent(
257
258
  appendLog,
258
259
  ...(onAssistantText ? { onAssistantText } : {}),
259
260
  aborted: abortController.signal.aborted,
261
+ abortSignal: abortController.signal,
260
262
  });
261
263
  } catch (err) {
264
+ // settleOneShot already logged its own failure — just pass it on.
265
+ if (err instanceof AgyOneShotError) throw err;
262
266
  const msg = err instanceof Error ? err.message : String(err);
263
267
  if (abortController.signal.aborted || /abort/i.test(msg)) {
264
- await appendLog(`\n### [${ts()}] Aborted\nRun aborted by timeout.\n`);
268
+ await appendLog(
269
+ `\n### [${ts()}] Aborted\n${abortLogLine(abortController.signal)}\n`,
270
+ );
265
271
  return;
266
272
  }
267
273
  logWarn("agent", `agy one-shot run failed: ${msg}`);
268
274
  await appendLog(`\n### [${ts()}] Error\n${msg}\n`);
275
+ // Surface it: a swallowed failure is recorded as a successful run by
276
+ // cron, heartbeat and the task table.
277
+ throw new AgyOneShotError(msg, { cause: err });
269
278
  } finally {
270
279
  unregisterMcpScope(scope);
271
280
  }
272
281
  }
273
282
 
283
+ /**
284
+ * An agy one-shot that failed: no result, a non-SUCCESS turn, or a spawn
285
+ * error. Thrown so callers record the run as failed rather than ok.
286
+ */
287
+ class AgyOneShotError extends Error {
288
+ constructor(message: string, options?: { cause?: unknown }) {
289
+ super(message, options);
290
+ this.name = "AgyOneShotError";
291
+ }
292
+ }
293
+
274
294
  /** Report the run's answer + usage, or its failure, to the log. */
275
295
  async function settleOneShot(inputs: {
276
296
  outcome: SpawnOutcome;
@@ -278,10 +298,14 @@ async function settleOneShot(inputs: {
278
298
  appendLog: (text: string) => Promise<void>;
279
299
  onAssistantText?: OneShotAgentParams["onAssistantText"];
280
300
  aborted: boolean;
301
+ abortSignal?: AbortSignal;
281
302
  }): Promise<OneShotUsage | void> {
282
303
  const { outcome, appendLog } = inputs;
283
304
  if (inputs.aborted) {
284
- await appendLog(`\n### [${ts()}] Aborted\nRun aborted by timeout.\n`);
305
+ const line = inputs.abortSignal
306
+ ? abortLogLine(inputs.abortSignal)
307
+ : "Run aborted.";
308
+ await appendLog(`\n### [${ts()}] Aborted\n${line}\n`);
285
309
  return;
286
310
  }
287
311
  if (!outcome.result) {
@@ -290,7 +314,14 @@ async function settleOneShot(inputs: {
290
314
  : outcome.stderr.trim() || `agy exited ${outcome.code ?? "n/a"}`;
291
315
  logWarn("agent", `agy one-shot produced no result: ${reason}`);
292
316
  await appendLog(`\n### [${ts()}] Error\n${reason}\n`);
293
- return;
317
+ throw new AgyOneShotError(reason);
318
+ }
319
+ if (outcome.result.status && outcome.result.status !== "SUCCESS") {
320
+ // logResult already wrote the "Turn <status>" section.
321
+ const reason =
322
+ outcome.result.error?.trim() || `agy turn ${outcome.result.status}`;
323
+ logWarn("agent", `agy one-shot turn ${outcome.result.status}: ${reason}`);
324
+ throw new AgyOneShotError(reason);
294
325
  }
295
326
  const response = outcome.result.response ?? "";
296
327
  if (response.trim()) {
@@ -293,10 +293,39 @@ export function detectCodexAuth(
293
293
  * `"not supported when using Codex with a ChatGPT account"`. Match on
294
294
  * that substring (case-insensitive, generous on whitespace) so a
295
295
  * future wording shift still trips a soft-match.
296
+ *
297
+ * Also matches the 404 "model … does not exist or you do not have
298
+ * access" shape ({@link isCodexModelNotFoundError}): a model retired for
299
+ * the account is the same situation from the caller's point of view.
296
300
  */
297
301
  export function isChatGptModelMismatchError(message: string): boolean {
298
- return /not\s+supported\s+when\s+using\s+codex\s+with\s+a\s+chatgpt\s+account/i.test(
299
- message,
302
+ if (
303
+ /not\s+supported\s+when\s+using\s+codex\s+with\s+a\s+chatgpt\s+account/i.test(
304
+ message,
305
+ )
306
+ ) {
307
+ return true;
308
+ }
309
+ return isCodexModelNotFoundError(message);
310
+ }
311
+
312
+ /**
313
+ * Detect the "model retired / not granted" 404 the ChatGPT Codex endpoint
314
+ * returns once a model is withdrawn from an account:
315
+ *
316
+ * `unexpected status 404 Not Found: The model \`gpt-5.5\` does not exist
317
+ * or you do not have access to it.`
318
+ *
319
+ * Every Codex cron run hit exactly this from 2026-09-24 onward. It is as
320
+ * definitive as the 400 mismatch — the server names the model and says
321
+ * the account can't use it — so it takes the same fallback/learning path.
322
+ * Requires both the 404 status and the "model … does not exist" wording
323
+ * so an unrelated 404 (a missing MCP resource, a bad URL) can't trip it.
324
+ */
325
+ export function isCodexModelNotFoundError(message: string): boolean {
326
+ return (
327
+ /\b404\b/.test(message) &&
328
+ /\bmodel\b[^\n]{0,120}?\bdoes\s+not\s+exist\b/i.test(message)
300
329
  );
301
330
  }
302
331
 
@@ -37,14 +37,28 @@ export const CODEX_SYSTEM_PROMPT_SUFFIX = codexSystemPromptSuffix("telegram");
37
37
  export const CODEX_DEFAULT_MODEL = "gpt-5-codex";
38
38
 
39
39
  /**
40
- * Default model used by the Codex backend when the user is signed in
41
- * via ChatGPT OAuth (`~/.codex/auth.json` `auth_mode: "chatgpt"`).
42
- * The `gpt-5-codex` model is rejected with a 400
43
- * `invalid_request_error` ("not supported when using Codex with a
44
- * ChatGPT account") on this auth path; `gpt-5.5` is the supported
45
- * flagship for ChatGPT users.
40
+ * Last-resort default model for the Codex backend when the user is signed
41
+ * in via ChatGPT OAuth (`~/.codex/auth.json` `auth_mode: "chatgpt"`).
42
+ * The `gpt-5-codex` model is rejected with a 400 `invalid_request_error`
43
+ * ("not supported when using Codex with a ChatGPT account") on this auth
44
+ * path.
45
+ *
46
+ * This is only the floor of the resolution ladder — see
47
+ * `getCodexChatGptDefaultModel()` in `models.ts`, which prefers (1) an
48
+ * explicit operator override, then (2) the Codex CLI's own default for the
49
+ * signed-in account (the first listed model in `~/.codex/models_cache.json`
50
+ * by priority). `gpt-6-astra` is the first entry of the model catalog
51
+ * bundled with codex-cli 0.154. The previous value, `gpt-5.5`, was retired
52
+ * for ChatGPT accounts in Sep 2026: every run on it returned
53
+ * `404 The model gpt-5.5 does not exist or you do not have access to it`.
54
+ */
55
+ export const CODEX_CHATGPT_DEFAULT_MODEL = "gpt-6-astra";
56
+
57
+ /**
58
+ * Environment override for the ChatGPT-OAuth default model. Takes
59
+ * precedence over the `codexChatGptDefaultModel` config key.
46
60
  */
47
- export const CODEX_CHATGPT_DEFAULT_MODEL = "gpt-5.5";
61
+ export const CODEX_CHATGPT_MODEL_ENV = "TALON_CODEX_CHATGPT_MODEL";
48
62
 
49
63
  /**
50
64
  * ThreadOptions permission settings shared by both the chat handler and
@@ -72,6 +72,8 @@ interface CodexCacheModelEntry {
72
72
  description?: string;
73
73
  visibility?: "list" | "hide" | string;
74
74
  supported_in_api?: boolean;
75
+ /** Picker order — lower sorts first; the CLI's default is the lowest. */
76
+ priority?: number;
75
77
  context_window?: number;
76
78
  default_reasoning_level?: string;
77
79
  supported_reasoning_levels?: Array<{
@@ -351,6 +353,33 @@ export async function fetchOpenAiModels(
351
353
  * Non-existent cache file throws so the caller's catch path logs it
352
354
  * at debug level and the picker falls back to curated.
353
355
  */
356
+ /**
357
+ * The Codex CLI's own default for the account: the listed, API-callable
358
+ * entry with the lowest `priority`. First entry wins ties, matching the
359
+ * CLI's stable sort. `null` when nothing qualifies.
360
+ */
361
+ function cliDefaultModel(
362
+ data: ReadonlyArray<CodexCacheModelEntry | null | undefined>,
363
+ ): string | null {
364
+ let best: string | null = null;
365
+ let bestPriority = Number.POSITIVE_INFINITY;
366
+ for (const entry of data) {
367
+ if (!entry || typeof entry.slug !== "string" || !entry.slug) continue;
368
+ if (entry.visibility === "hide" || entry.supported_in_api === false) {
369
+ continue;
370
+ }
371
+ const priority =
372
+ typeof entry.priority === "number" && Number.isFinite(entry.priority)
373
+ ? entry.priority
374
+ : Number.MAX_SAFE_INTEGER;
375
+ if (priority < bestPriority) {
376
+ bestPriority = priority;
377
+ best = entry.slug;
378
+ }
379
+ }
380
+ return best;
381
+ }
382
+
354
383
  export async function loadCodexCacheModels(): Promise<void> {
355
384
  const path = getCodexCachePath();
356
385
  let raw: string;
@@ -381,6 +410,7 @@ export async function loadCodexCacheModels(): Promise<void> {
381
410
  const state = getState();
382
411
  state.discoveredModels.clear();
383
412
  state.discoveredModelMetadata.clear();
413
+ state.discoveredDefaultModel = cliDefaultModel(data);
384
414
  let kept = 0;
385
415
  let dropped = 0;
386
416
  for (const entry of data) {
@@ -429,6 +459,7 @@ export async function loadCodexCacheModels(): Promise<void> {
429
459
  const fetchedAt = json.fetched_at ?? "unknown";
430
460
  log(
431
461
  "agent",
432
- `Codex: loaded ${kept} models from ${path} (fetched_at=${fetchedAt}, filtered ${dropped} hidden/api-disabled entries)`,
462
+ `Codex: loaded ${kept} models from ${path} (fetched_at=${fetchedAt}, filtered ${dropped} hidden/api-disabled entries` +
463
+ `${state.discoveredDefaultModel ? `, CLI default ${state.discoveredDefaultModel}` : ""})`,
433
464
  );
434
465
  }
@@ -28,10 +28,7 @@ import { initCodexAgent, getCodexAuthInfo } from "./init.js";
28
28
  import { handleMessage as codexHandleMessage } from "./handler/index.js";
29
29
  import { runOneShotAgent as codexRunOneShotAgent } from "./one-shot.js";
30
30
  import { resetState as resetCodexState } from "./state.js";
31
- import {
32
- CODEX_DEFAULT_MODEL,
33
- CODEX_CHATGPT_DEFAULT_MODEL,
34
- } from "./constants.js";
31
+ import { CODEX_DEFAULT_MODEL } from "./constants.js";
35
32
  import {
36
33
  resolveModel as codexResolveModel,
37
34
  getModelInfo as codexGetModelInfo,
@@ -40,6 +37,7 @@ import {
40
37
  getProviderModels as codexGetProviderModels,
41
38
  formatModelError as codexFormatModelError,
42
39
  listModels as codexListModels,
40
+ getCodexChatGptDefaultModel,
43
41
  } from "./models.js";
44
42
 
45
43
  const codexFactory: BackendFactory = {
@@ -71,7 +69,7 @@ const codexFactory: BackendFactory = {
71
69
  getDefaultModelId: () => {
72
70
  const auth = getCodexAuthInfo();
73
71
  return auth?.mode === "chatgpt"
74
- ? CODEX_CHATGPT_DEFAULT_MODEL
72
+ ? getCodexChatGptDefaultModel()
75
73
  : CODEX_DEFAULT_MODEL;
76
74
  },
77
75
  getRawModelInfo: (id) => codexGetModelInfo(id),
@@ -45,7 +45,6 @@ import {
45
45
  import {
46
46
  codexSystemPromptSuffix,
47
47
  CODEX_DEFAULT_MODEL,
48
- CODEX_CHATGPT_DEFAULT_MODEL,
49
48
  CODEX_THREAD_PERMISSIONS,
50
49
  } from "../constants.js";
51
50
  import {
@@ -64,6 +63,7 @@ import {
64
63
  chatGptFallbackFor,
65
64
  getModelInfo,
66
65
  isCodexOAuthIncompat,
66
+ getCodexChatGptDefaultModel,
67
67
  } from "../models.js";
68
68
  import { supportsReasoningLevel } from "../../../core/models/reasoning-levels.js";
69
69
  import { toCodexReasoningEffort } from "../effort.js";
@@ -142,7 +142,7 @@ async function maybeFallbackForChatGptMismatch(
142
142
  const isOAuth = authInfo?.mode === "chatgpt";
143
143
  const silent =
144
144
  isOAuth &&
145
- activeModel !== CODEX_CHATGPT_DEFAULT_MODEL &&
145
+ activeModel !== getCodexChatGptDefaultModel() &&
146
146
  isSilentOAuthExitError(probeText);
147
147
 
148
148
  if (!explicit && !silent) return undefined;
@@ -170,7 +170,7 @@ async function maybeFallbackForChatGptMismatch(
170
170
  }
171
171
 
172
172
  const fallbackModel =
173
- chatGptFallbackFor(activeModel) ?? CODEX_CHATGPT_DEFAULT_MODEL;
173
+ chatGptFallbackFor(activeModel) ?? getCodexChatGptDefaultModel();
174
174
  if (fallbackModel === activeModel) return undefined;
175
175
 
176
176
  // Only EXPLICIT mismatch errors are persisted as OAuth-incompat —
@@ -227,8 +227,8 @@ function buildTurnPrompt(params: QueryParams): string {
227
227
  /**
228
228
  * Codex accepts arbitrary model strings; we pass through whatever the
229
229
  * caller resolved (chat-settings → config) and fall back to the auth-aware
230
- * default: `gpt-5-codex` when an API key is present, `gpt-5.5` when only
231
- * ChatGPT OAuth is configured (because `gpt-5-codex` is rejected with a
230
+ * default: `gpt-5-codex` when an API key is present, the resolved ChatGPT
231
+ * default (`getCodexChatGptDefaultModel`) when only ChatGPT OAuth is configured (because `gpt-5-codex` is rejected with a
232
232
  * 400 on ChatGPT-mode accounts). A model known to be OAuth-incompat on a
233
233
  * ChatGPT-OAuth account is swapped pre-emptively rather than letting the
234
234
  * first turn fail.
@@ -237,13 +237,13 @@ function resolveCodexModel(chatId: string, requested: string | undefined) {
237
237
  const authInfo = getCodexAuthInfo();
238
238
  const authAwareDefault =
239
239
  authInfo?.mode === "chatgpt"
240
- ? CODEX_CHATGPT_DEFAULT_MODEL
240
+ ? getCodexChatGptDefaultModel()
241
241
  : CODEX_DEFAULT_MODEL;
242
242
  const requestedModel = requested ?? authAwareDefault;
243
243
  let activeModel = requestedModel;
244
244
  if (authInfo?.mode === "chatgpt" && isCodexOAuthIncompat(requestedModel)) {
245
245
  const fallback =
246
- chatGptFallbackFor(requestedModel) ?? CODEX_CHATGPT_DEFAULT_MODEL;
246
+ chatGptFallbackFor(requestedModel) ?? getCodexChatGptDefaultModel();
247
247
  // Guard against a learned-but-no-fallback case — only swap when the
248
248
  // fallback is actually different from what we'd already run.
249
249
  if (fallback !== requestedModel) {
@@ -37,6 +37,36 @@ import { awaitDiscovery, hasAttemptedDiscovery } from "./discovery.js";
37
37
  import { getState } from "./state.js";
38
38
  import { getCodexAuthInfo } from "./init.js";
39
39
  import { isKnownOAuthIncompat } from "./oauth-incompat.js";
40
+ import {
41
+ CODEX_CHATGPT_DEFAULT_MODEL,
42
+ CODEX_CHATGPT_MODEL_ENV,
43
+ } from "./constants.js";
44
+
45
+ /**
46
+ * The model a ChatGPT-OAuth Codex session runs when nothing more specific
47
+ * applies — the target of every pre-emptive OAuth swap and mismatch
48
+ * fallback. Resolution order:
49
+ *
50
+ * 1. `TALON_CODEX_CHATGPT_MODEL` env var (operator override);
51
+ * 2. `codexChatGptDefaultModel` in config;
52
+ * 3. the Codex CLI's own default for the signed-in account (first
53
+ * listed model in `~/.codex/models_cache.json` by priority), unless
54
+ * Talon has already learned that id fails on this account;
55
+ * 4. {@link CODEX_CHATGPT_DEFAULT_MODEL}, the bundled floor.
56
+ *
57
+ * Hardcoding a single id is what broke in Sep 2026: OpenAI retired
58
+ * `gpt-5.5` for ChatGPT accounts and every run 404'd until a release.
59
+ */
60
+ export function getCodexChatGptDefaultModel(): string {
61
+ const env = process.env[CODEX_CHATGPT_MODEL_ENV]?.trim();
62
+ if (env) return env;
63
+ const state = getState();
64
+ const configured = state.config?.codexChatGptDefaultModel?.trim();
65
+ if (configured) return configured;
66
+ const discovered = state.discoveredDefaultModel;
67
+ if (discovered && !isKnownOAuthIncompat(discovered)) return discovered;
68
+ return CODEX_CHATGPT_DEFAULT_MODEL;
69
+ }
40
70
 
41
71
  /**
42
72
  * Codex-specific model metadata extension.
@@ -56,15 +86,26 @@ export interface CodexModelInfo extends UnifiedModelInfo {
56
86
  * Curated metadata for models we recognise.
57
87
  *
58
88
  * Order matters: `getSettingsPresentation` lists curated models in
59
- * this order, and `gpt-5.5` is intentionally first because it's the
60
- * broadest-access flagship (works on both auth modes) — the safe
61
- * default for Talon-on-Codex deployments where the operator hasn't
62
- * explicitly picked a model.
89
+ * this order, and the current flagship is intentionally first — the
90
+ * safe default for Talon-on-Codex deployments where the operator hasn't
91
+ * explicitly picked a model. (`gpt-5.5` held that slot until it was
92
+ * retired for ChatGPT accounts in Sep 2026.)
63
93
  *
64
94
  * Discovered-but-not-curated ids are appended at the end (synthesised
65
95
  * with minimal metadata), so the curated entries always render first.
66
96
  */
67
97
  export const CODEX_MODELS: CodexModelInfo[] = [
98
+ {
99
+ // Flagship of the catalog bundled with codex-cli 0.154 and the floor
100
+ // of the ChatGPT default ladder (`getCodexChatGptDefaultModel`).
101
+ // No context window here: the account's models_cache.json supplies it.
102
+ id: "gpt-6-astra",
103
+ displayName: "GPT-6 Astra",
104
+ provider: "openai",
105
+ providerName: "OpenAI",
106
+ selectable: true,
107
+ reasoning: true,
108
+ },
68
109
  {
69
110
  id: "gpt-5.5",
70
111
  displayName: "GPT-5.5",
@@ -149,19 +190,20 @@ export function isCodexOAuthIncompat(id: string): boolean {
149
190
  *
150
191
  * Returns `undefined` when:
151
192
  * - The id isn't recognised as OAuth-incompat (caller can skip).
152
- * - The id IS the broadest-access flagship (`gpt-5.5`) itself — no
153
- * further fallback exists; if even `gpt-5.5` fails, the credential
154
- * is the problem, not the model.
193
+ * - The id IS the resolved ChatGPT default
194
+ * ({@link getCodexChatGptDefaultModel}) itself — no further fallback
195
+ * exists; if even the default fails, the credential is the problem,
196
+ * not the model.
155
197
  *
156
- * For everything else returns `gpt-5.5` as the verified-working OAuth
157
- * default. (The curated table has only `gpt-5-codex` flagged as
198
+ * For everything else returns the resolved ChatGPT default. (The curated table has only `gpt-5-codex` flagged as
158
199
  * `apiKeyOnly: true`; runtime-learned entries cover the rest:
159
200
  * `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.3-codex`, `gpt-5.2`, etc.)
160
201
  */
161
202
  export function chatGptFallbackFor(id: string): string | undefined {
162
203
  if (!isCodexOAuthIncompat(id)) return undefined;
163
- if (id === "gpt-5.5") return undefined;
164
- return "gpt-5.5";
204
+ const fallback = getCodexChatGptDefaultModel();
205
+ if (id === fallback) return undefined;
206
+ return fallback;
165
207
  }
166
208
 
167
209
  /**
@@ -24,13 +24,17 @@ import { emitAssistantText } from "../runtime/one-shot-hooks.js";
24
24
  import { ensureCodex, getCodexAuthInfo } from "./init.js";
25
25
  import {
26
26
  CODEX_SYSTEM_PROMPT_SUFFIX,
27
- CODEX_CHATGPT_DEFAULT_MODEL,
28
27
  CODEX_THREAD_PERMISSIONS,
29
28
  } from "./constants.js";
30
29
  import { isChatGptModelMismatchError } from "./auth.js";
31
- import { chatGptFallbackFor, isCodexOAuthIncompat } from "./models.js";
30
+ import {
31
+ chatGptFallbackFor,
32
+ getCodexChatGptDefaultModel,
33
+ isCodexOAuthIncompat,
34
+ } from "./models.js";
32
35
  import { markOAuthIncompat } from "./oauth-incompat.js";
33
36
  import { toCodexReasoningEffort } from "./effort.js";
37
+ import { abortLogLine } from "../../core/agents/abort-reason.js";
34
38
 
35
39
  /**
36
40
  * Resolve the effective model for a one-shot run, applying the same
@@ -39,7 +43,8 @@ import { toCodexReasoningEffort } from "./effort.js";
39
43
  * Heartbeats and dream calls pass `params.model` straight through from
40
44
  * `config.heartbeatModel ?? config.model`. If that's an OAuth-incompat
41
45
  * id (curated `apiKeyOnly: true` or runtime-learned) AND the active
42
- * Codex credential is ChatGPT OAuth, swap to `gpt-5.5` to avoid the
46
+ * Codex credential is ChatGPT OAuth, swap to the resolved ChatGPT default
47
+ * (`getCodexChatGptDefaultModel`) to avoid the
43
48
  * silent exit-1 failure mode that hit a group chat on 2026-05-20 23:13Z.
44
49
  *
45
50
  * Returns the resolved model id, whether a swap occurred, and an
@@ -56,7 +61,8 @@ function resolveOneShotModel(requested: string): {
56
61
  return { model: requested, swapped: false };
57
62
  }
58
63
 
59
- const fallback = chatGptFallbackFor(requested) ?? CODEX_CHATGPT_DEFAULT_MODEL;
64
+ const fallback =
65
+ chatGptFallbackFor(requested) ?? getCodexChatGptDefaultModel();
60
66
  if (fallback === requested) return { model: requested, swapped: false };
61
67
 
62
68
  return {
@@ -131,6 +137,14 @@ export async function runOneShotAgent(
131
137
  ...CODEX_THREAD_PERMISSIONS,
132
138
  });
133
139
 
140
+ // The real reason a run failed lives in the stream, not in whatever the
141
+ // SDK throws afterwards: a model the account can't use yields
142
+ // `turn.failed` ("404 … The model `gpt-5.5` does not exist …") and the
143
+ // SDK then throws only "Codex Exec exited with code 1: Reading prompt
144
+ // from stdin...". Capture both so the failure the caller records is the
145
+ // one that explains it.
146
+ const failure = new StreamFailure();
147
+
134
148
  try {
135
149
  if (abortController.signal.aborted) {
136
150
  throw new Error("Aborted before prompt was sent");
@@ -146,6 +160,7 @@ export async function runOneShotAgent(
146
160
  for await (const event of events) {
147
161
  if (abortController.signal.aborted) break;
148
162
  await appendCodexEvent(appendLog, event, onAssistantText);
163
+ failure.observe(event);
149
164
  if (event.type === "turn.completed") {
150
165
  const u = (event as { usage?: Record<string, number> }).usage;
151
166
  if (u) {
@@ -158,49 +173,136 @@ export async function runOneShotAgent(
158
173
  }
159
174
  }
160
175
  }
176
+ // A stream that ends cleanly after `turn.failed` is still a failed run
177
+ // — returning here is how 26/26 Codex cron runs were stored as "ok".
178
+ if (!abortController.signal.aborted && failure.message) {
179
+ throw new CodexOneShotError(failure.message);
180
+ }
161
181
  return usage;
162
182
  } catch (err) {
183
+ const thrown = err instanceof Error ? err.message : String(err);
163
184
  if (
164
- abortController.signal.aborted ||
165
- /abort/i.test(err instanceof Error ? err.message : String(err))
185
+ !(err instanceof CodexOneShotError) &&
186
+ (abortController.signal.aborted || /abort/i.test(thrown))
166
187
  ) {
167
188
  const ts = new Date().toISOString().slice(11, 19);
168
- await appendLog(`\n### [${ts}] Aborted\nRun aborted by timeout.\n`);
189
+ await appendLog(
190
+ `\n### [${ts}] Aborted\n${abortLogLine(abortController.signal)}\n`,
191
+ );
169
192
  return;
170
193
  }
171
- const msg = err instanceof Error ? err.message : String(err);
172
-
173
- // Learn only from EXPLICIT mismatches in one-shot context.
174
- // Silent-exit failures are ambiguous (transient outage vs real
175
- // model-incompat) and persisting them would over-poison the
176
- // learning store with the result that one bad heartbeat
177
- // permanently downgrades the model. Explicit mismatches carry the
178
- // unambiguous server message so they're safe to mark.
179
- //
180
- // Unlike the interactive handler, heartbeat/dream can't recurse for
181
- // a retry (would mess with the timing contract and lock
182
- // semantics), so silent-exit failures here simply surface to the
183
- // run log; the next scheduled run takes a fresh swing.
184
- const authInfo = getCodexAuthInfo();
185
- if (
186
- authInfo?.mode === "chatgpt" &&
187
- activeModel !== CODEX_CHATGPT_DEFAULT_MODEL &&
188
- isChatGptModelMismatchError(msg)
189
- ) {
190
- const recorded = await markOAuthIncompat(activeModel);
191
- if (recorded) {
192
- logWarn(
193
- "agent",
194
- `[${contextLabel}] Codex one-shot: recorded ${activeModel} as ` +
195
- `OAuth-incompat (explicit mismatch) — next ${contextLabel} run ` +
196
- `will pre-emptively swap to ${CODEX_CHATGPT_DEFAULT_MODEL}`,
197
- );
198
- }
199
- }
200
-
194
+ const msg = failure.describe(thrown);
195
+ await learnFromMismatch(activeModel, msg, contextLabel);
201
196
  logWarn("agent", `Codex one-shot run failed: ${msg}`);
202
197
  const ts = new Date().toISOString().slice(11, 19);
203
198
  await appendLog(`\n### [${ts}] Error\n${msg}\n`);
199
+ throw err instanceof CodexOneShotError
200
+ ? err
201
+ : new CodexOneShotError(msg, { cause: err });
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Record a model as OAuth-incompat when a one-shot failed on it with an
207
+ * explicit server mismatch, so the next run pre-emptively swaps.
208
+ */
209
+ async function learnFromMismatch(
210
+ activeModel: string,
211
+ msg: string,
212
+ contextLabel: string,
213
+ ): Promise<void> {
214
+ // Learn only from EXPLICIT mismatches in one-shot context.
215
+ // Silent-exit failures are ambiguous (transient outage vs real
216
+ // model-incompat) and persisting them would over-poison the
217
+ // learning store with the result that one bad heartbeat
218
+ // permanently downgrades the model. Explicit mismatches (the 400
219
+ // "not supported … ChatGPT account" and the 404 "model … does not
220
+ // exist") carry the unambiguous server message so they're safe to
221
+ // mark.
222
+ //
223
+ // Unlike the interactive handler, heartbeat/dream can't recurse for
224
+ // a retry (would mess with the timing contract and lock
225
+ // semantics), so the failure is surfaced to the caller — the task
226
+ // settles as failed — and the next scheduled run takes a fresh
227
+ // swing on the learned fallback.
228
+ const authInfo = getCodexAuthInfo();
229
+ const fallback = getCodexChatGptDefaultModel();
230
+ if (
231
+ authInfo?.mode !== "chatgpt" ||
232
+ activeModel === fallback ||
233
+ !isChatGptModelMismatchError(msg)
234
+ ) {
235
+ return;
236
+ }
237
+ const recorded = await markOAuthIncompat(activeModel);
238
+ if (recorded) {
239
+ logWarn(
240
+ "agent",
241
+ `[${contextLabel}] Codex one-shot: recorded ${activeModel} as ` +
242
+ `OAuth-incompat (explicit mismatch) — next ${contextLabel} run ` +
243
+ `will pre-emptively swap to ${fallback}`,
244
+ );
245
+ }
246
+ }
247
+
248
+ /**
249
+ * A Codex one-shot that failed upstream. The message is the most specific
250
+ * reason the stream carried (the `turn.failed` text when there was one),
251
+ * so task tables, cron run records and the backend router see the cause
252
+ * rather than the SDK's generic exit wrapper.
253
+ */
254
+ class CodexOneShotError extends Error {
255
+ constructor(message: string, options?: { cause?: unknown }) {
256
+ super(message, options);
257
+ this.name = "CodexOneShotError";
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Codex emits `error` events for transient retries it handles itself
263
+ * ("Reconnecting... 2/5 (…)", "Falling back from WebSockets to HTTPS
264
+ * transport. …"). Those are progress, not failure — only the terminal
265
+ * `turn.failed` / final `error` count.
266
+ */
267
+ const TRANSIENT_ERROR_RE = /^\s*(reconnecting\.\.\.|falling back from)/i;
268
+
269
+ /** Tracks the terminal failure reported on a Codex event stream. */
270
+ class StreamFailure {
271
+ private turnFailed: string | undefined;
272
+ private lastError: string | undefined;
273
+
274
+ observe(event: { type: string } & Record<string, unknown>): void {
275
+ if (event.type === "turn.failed") {
276
+ const err = (event as { error?: { message?: unknown } }).error;
277
+ const text =
278
+ typeof err?.message === "string" && err.message.trim()
279
+ ? err.message.trim()
280
+ : "turn failed (no message)";
281
+ this.turnFailed = text;
282
+ return;
283
+ }
284
+ if (event.type === "error" && typeof event.message === "string") {
285
+ const text = event.message.trim();
286
+ if (text && !TRANSIENT_ERROR_RE.test(text)) this.lastError = text;
287
+ }
288
+ }
289
+
290
+ /** The failure the stream reported, or undefined when it reported none. */
291
+ get message(): string | undefined {
292
+ return this.turnFailed ?? this.lastError;
293
+ }
294
+
295
+ /**
296
+ * Combine the stream's failure with whatever the SDK threw, most
297
+ * specific first, without repeating the same text twice.
298
+ */
299
+ describe(thrown: string): string {
300
+ const streamed = this.message;
301
+ if (!streamed) return thrown;
302
+ if (!thrown || thrown === streamed || streamed.includes(thrown)) {
303
+ return streamed;
304
+ }
305
+ return `${streamed} (${thrown.trim()})`;
204
306
  }
205
307
  }
206
308