talon-agent 5.25.1 → 5.26.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.
Files changed (73) 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/claude-sdk/model-provider.ts +59 -12
  5. package/src/backend/codex/auth.ts +31 -2
  6. package/src/backend/codex/constants.ts +21 -7
  7. package/src/backend/codex/discovery.ts +32 -1
  8. package/src/backend/codex/factory.ts +3 -5
  9. package/src/backend/codex/handler/message.ts +7 -7
  10. package/src/backend/codex/models.ts +53 -11
  11. package/src/backend/codex/one-shot.ts +139 -37
  12. package/src/backend/codex/state.ts +11 -0
  13. package/src/core/agents/abort-reason.ts +46 -0
  14. package/src/core/agents/registry.ts +3 -2
  15. package/src/core/background/dream/index.ts +2 -2
  16. package/src/core/background/heartbeat/agent.ts +1 -1
  17. package/src/core/background/isolated-agent.ts +1 -1
  18. package/src/core/backup/index.ts +1 -0
  19. package/src/core/backup/status.ts +30 -1
  20. package/src/core/config/index.ts +8 -0
  21. package/src/core/engine/gateway.ts +5 -0
  22. package/src/core/frontend-runtime/admin-notify.ts +46 -6
  23. package/src/core/frontend-runtime/alerts.ts +10 -6
  24. package/src/core/mesh/credentials/store.ts +60 -9
  25. package/src/core/models/active-model.ts +57 -48
  26. package/src/core/plugin/loader.ts +47 -12
  27. package/src/core/tools/bridge.ts +58 -4
  28. package/src/frontend/discord/callbacks/components/effort.ts +4 -4
  29. package/src/frontend/discord/callbacks/components/index.ts +2 -0
  30. package/src/frontend/discord/commands/backup-panel.ts +219 -0
  31. package/src/frontend/discord/commands/backup.ts +25 -34
  32. package/src/frontend/discord/commands/info.ts +17 -5
  33. package/src/frontend/discord/commands/settings.ts +12 -9
  34. package/src/frontend/discord/render.ts +1 -1
  35. package/src/frontend/native/bridge/credentials/claims.ts +5 -5
  36. package/src/frontend/native/bridge/routes/chats.ts +40 -20
  37. package/src/frontend/native/bridge/routes/host.ts +15 -2
  38. package/src/frontend/native/bridge/routes/table.ts +4 -0
  39. package/src/frontend/native/commands/admin.ts +64 -0
  40. package/src/frontend/native/commands/backup.ts +191 -0
  41. package/src/frontend/native/commands/definitions.ts +113 -0
  42. package/src/frontend/native/commands/format.ts +24 -0
  43. package/src/frontend/native/commands/index.ts +106 -0
  44. package/src/frontend/native/commands/info.ts +97 -0
  45. package/src/frontend/native/commands/session.ts +130 -0
  46. package/src/frontend/native/commands/types.ts +26 -0
  47. package/src/frontend/native/protocol.ts +18 -0
  48. package/src/frontend/native/surface/handlers.ts +14 -1
  49. package/src/frontend/native/surface/status.ts +9 -1
  50. package/src/frontend/native/turn/emit.ts +32 -1
  51. package/src/frontend/presentation/backup-panel.ts +425 -0
  52. package/src/frontend/presentation/memory-report.ts +109 -0
  53. package/src/frontend/presentation/text-commands.ts +243 -0
  54. package/src/frontend/telegram/actions/media.ts +60 -19
  55. package/src/frontend/telegram/actions/messaging.ts +9 -0
  56. package/src/frontend/telegram/admin/sessions.ts +20 -5
  57. package/src/frontend/telegram/admin.ts +9 -2
  58. package/src/frontend/telegram/callbacks/backup.ts +153 -22
  59. package/src/frontend/telegram/callbacks/effort.ts +5 -5
  60. package/src/frontend/telegram/callbacks/index.ts +2 -2
  61. package/src/frontend/telegram/callbacks/settings.ts +3 -40
  62. package/src/frontend/telegram/commands/admin.ts +8 -7
  63. package/src/frontend/telegram/commands/backup.ts +56 -46
  64. package/src/frontend/telegram/commands/index.ts +3 -2
  65. package/src/frontend/telegram/commands/info.ts +45 -26
  66. package/src/frontend/telegram/commands/memory.ts +8 -92
  67. package/src/frontend/telegram/commands/settings.ts +13 -45
  68. package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
  69. package/src/frontend/telegram/render/backup-panel.ts +47 -0
  70. package/src/frontend/telegram/render/menu.ts +21 -30
  71. package/src/frontend/terminal/builtins/model.ts +20 -11
  72. package/src/frontend/whatsapp/commands.ts +16 -216
  73. 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.1",
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()) {
@@ -71,19 +71,68 @@ function isSelectedModel(currentModel: string, candidateId: string): boolean {
71
71
 
72
72
  // ── Public API ─────────────────────────────────────────────────────────────
73
73
 
74
- export async function resolveModel(
75
- query: string,
76
- ): Promise<UnifiedModelResolution> {
77
- const canonicalId = resolveModelId(query);
78
- const model = getModel(canonicalId);
74
+ const ONE_MILLION_SUFFIX = "[1m]";
79
75
 
80
- if (model) {
76
+ /**
77
+ * Split a `[1m]` context-variant suffix off a query ("opus[1m]" →
78
+ * { stem: "opus", oneMillion: true }). Claude Code accepts the suffix on
79
+ * any alias or id; the SDK's `supportedModels()` list does not enumerate
80
+ * the suffixed forms, so the catalog can't match them directly.
81
+ */
82
+ function splitOneMillion(query: string): { stem: string; oneMillion: boolean } {
83
+ const trimmed = query.trim();
84
+ if (trimmed.toLowerCase().endsWith(ONE_MILLION_SUFFIX)) {
81
85
  return {
82
- kind: "exact",
83
- model: toUnified(model),
84
- storedValue: model.id,
86
+ stem: trimmed.slice(0, -ONE_MILLION_SUFFIX.length).trim(),
87
+ oneMillion: true,
85
88
  };
86
89
  }
90
+ return { stem: trimmed, oneMillion: false };
91
+ }
92
+
93
+ /**
94
+ * Exact-match a query against the registry: its id/alias directly, or —
95
+ * for a `[1m]` query the registry doesn't list — the base model, returned
96
+ * as a 1M variant whose id keeps the suffix so the SDK actually selects
97
+ * the 1M context window.
98
+ *
99
+ * The SDK reports no context-window metadata per model, so there's no
100
+ * evidence to reject a `[1m]` request on; the binary is the authority.
101
+ * The one form refused is `default[1m]`: "default" is a moving target,
102
+ * not an alias Claude Code suffixes.
103
+ */
104
+ function resolveExactUnified(query: string): UnifiedModelInfo | undefined {
105
+ const direct = getModel(resolveModelId(query));
106
+ if (direct) return toUnified(direct);
107
+
108
+ const { stem, oneMillion } = splitOneMillion(query);
109
+ if (!oneMillion || !stem) return undefined;
110
+ const baseId = resolveModelId(stem);
111
+ const base = getModel(baseId);
112
+ if (!base || stem.toLowerCase() === "default") return undefined;
113
+
114
+ // Pass the SDK a form it knows: the canonical id when it's concrete, the
115
+ // user's own stem when the canonical is the "default" alias (e.g. "opus"
116
+ // folds into "default" when default currently serves Opus).
117
+ const sdkStem = baseId === "default" ? stem : baseId;
118
+ const displayName = /\(1m context\)/i.test(base.displayName)
119
+ ? base.displayName
120
+ : `${base.displayName} (1M context)`;
121
+ return {
122
+ ...toUnified(base),
123
+ id: `${sdkStem}${ONE_MILLION_SUFFIX}`,
124
+ displayName,
125
+ contextWindow: 1_000_000,
126
+ };
127
+ }
128
+
129
+ export async function resolveModel(
130
+ query: string,
131
+ ): Promise<UnifiedModelResolution> {
132
+ const exact = resolveExactUnified(query);
133
+ if (exact) {
134
+ return { kind: "exact", model: exact, storedValue: exact.id };
135
+ }
87
136
 
88
137
  // No exact match -- try a substring search across display names and aliases
89
138
  const allModels = getModels(PROVIDER_ID);
@@ -112,9 +161,7 @@ export async function resolveModel(
112
161
  export async function getModelInfo(
113
162
  id: string,
114
163
  ): Promise<UnifiedModelInfo | undefined> {
115
- const canonicalId = resolveModelId(id);
116
- const model = getModel(canonicalId);
117
- return model ? toUnified(model) : undefined;
164
+ return resolveExactUnified(id);
118
165
  }
119
166
 
120
167
  export async function getSettingsPresentation(
@@ -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
  /**