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
@@ -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
 
@@ -48,6 +48,15 @@ export interface CodexState {
48
48
  * when synthesising entries for ids absent from the curated table.
49
49
  */
50
50
  discoveredModelMetadata: Map<string, DiscoveredModelMetadata>;
51
+ /**
52
+ * The Codex CLI's own default model for the signed-in ChatGPT account:
53
+ * the listed, API-callable entry of `~/.codex/models_cache.json` with
54
+ * the lowest `priority` (the CLI sorts its picker the same way and
55
+ * treats the first entry as the default). `null` until the cache has
56
+ * been read, or when it lists nothing usable. Consumed by
57
+ * `getCodexChatGptDefaultModel()`.
58
+ */
59
+ discoveredDefaultModel: string | null;
51
60
  /**
52
61
  * In-flight discovery promise. Non-null while a fetch is pending;
53
62
  * cleared once the fetch settles (success or failure). Callers that
@@ -70,6 +79,7 @@ const state: CodexState = {
70
79
  frontendName: "telegram",
71
80
  discoveredModels: new Set<string>(),
72
81
  discoveredModelMetadata: new Map<string, DiscoveredModelMetadata>(),
82
+ discoveredDefaultModel: null,
73
83
  discoveryPromise: null,
74
84
  discoveryAt: null,
75
85
  };
@@ -87,6 +97,7 @@ export function resetState(): void {
87
97
  state.frontendName = "telegram";
88
98
  state.discoveredModels.clear();
89
99
  state.discoveredModelMetadata.clear();
100
+ state.discoveredDefaultModel = null;
90
101
  state.discoveryPromise = null;
91
102
  state.discoveryAt = null;
92
103
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Why a run's AbortSignal fired — so logs and results can say "killed" for
3
+ * a deliberate kill_agent instead of blaming a timeout that never happened.
4
+ *
5
+ * Abort sites pass a typed reason (`controller.abort(reason)`); consumers
6
+ * read it back with `abortKind(signal)`. A bare `abort()` carries the
7
+ * runtime's default AbortError reason and reads as a plain "aborted".
8
+ */
9
+
10
+ /** Reason passed when an operator/agent deliberately kills a run. */
11
+ export class RunKilledError extends Error {
12
+ constructor(message = "run killed on request") {
13
+ super(message);
14
+ this.name = "RunKilledError";
15
+ }
16
+ }
17
+
18
+ export type AbortKind = "killed" | "timeout" | "aborted";
19
+
20
+ /** Classify an abort reason (typically `signal.reason`). */
21
+ export function abortKindOf(reason: unknown): AbortKind {
22
+ if (reason instanceof RunKilledError) return "killed";
23
+ const r = reason as { name?: unknown; message?: unknown } | null;
24
+ const name = typeof r?.name === "string" ? r.name : "";
25
+ const message = typeof r?.message === "string" ? r.message : "";
26
+ if (name === "RunKilledError") return "killed";
27
+ if (/timeout/i.test(name) || /timed out/i.test(message)) return "timeout";
28
+ return "aborted";
29
+ }
30
+
31
+ /** Classify why `signal` fired ("aborted" when it has not, or gave no reason). */
32
+ export function abortKind(signal: AbortSignal): AbortKind {
33
+ return signal.aborted ? abortKindOf(signal.reason) : "aborted";
34
+ }
35
+
36
+ /** One-line run-log sentence for an aborted run. */
37
+ export function abortLogLine(signal: AbortSignal): string {
38
+ switch (abortKind(signal)) {
39
+ case "killed":
40
+ return "Run killed on request.";
41
+ case "timeout":
42
+ return "Run aborted by timeout.";
43
+ default:
44
+ return "Run aborted.";
45
+ }
46
+ }
@@ -26,6 +26,7 @@ import type { AgentSettledEvent, AgentSpawnedEvent } from "../bus/events.js";
26
26
  import type { ReasoningEffortLevel } from "../types.js";
27
27
  import { bus } from "../bus/index.js";
28
28
  import { logWarn } from "../../util/log.js";
29
+ import { RunKilledError } from "./abort-reason.js";
29
30
 
30
31
  /** Settled agents kept for status queries after they leave the live map. */
31
32
  const DEFAULT_HISTORY_LIMIT = 100;
@@ -224,7 +225,7 @@ export class AgentRegistry {
224
225
  // right after start() and settles the run as "killed".
225
226
  if (entry.killRequested) {
226
227
  try {
227
- binding.abort.abort();
228
+ binding.abort.abort(new RunKilledError(`agent ${id} killed`));
228
229
  } catch (err) {
229
230
  logWarn(
230
231
  "agents",
@@ -312,7 +313,7 @@ export class AgentRegistry {
312
313
  if (!entry.killRequested) {
313
314
  entry.killRequested = true;
314
315
  try {
315
- entry.abort?.abort();
316
+ entry.abort?.abort(new RunKilledError(`agent ${id} killed`));
316
317
  } catch (err) {
317
318
  // An abort hook must not be able to break the kill path — but a
318
319
  // throwing one may leave the agent running, so say so.
@@ -320,11 +320,11 @@ If commands fail, log the error and continue — this stage is optional.`
320
320
  const timeoutPromise = new Promise<never>((_, reject) => {
321
321
  const t = setTimeout(() => {
322
322
  try {
323
- abortController.abort();
323
+ abortController.abort(new Error("Dream agent timed out"));
324
324
  } catch {
325
325
  /* ignore */
326
326
  }
327
- reject(new Error("Dream agent timed out"));
327
+ reject(abortController.signal.reason as Error);
328
328
  }, DREAM_TIMEOUT_MS);
329
329
  t.unref(); // Don't prevent Node.js from exiting cleanly during shutdown
330
330
  timeoutHandle = t;
@@ -233,7 +233,7 @@ async function runOneShotWithTimeout(
233
233
  const t = setTimeout(() => {
234
234
  timeoutFired = true;
235
235
  try {
236
- abortController.abort();
236
+ abortController.abort(new HeartbeatTimeoutError());
237
237
  } catch {
238
238
  /* ignore */
239
239
  }
@@ -94,7 +94,7 @@ export async function runIsolatedAgent(
94
94
  timer = setTimeout(() => {
95
95
  timeoutError = new IsolatedAgentTimeoutError(timeoutMs);
96
96
  try {
97
- params.abortController.abort();
97
+ params.abortController.abort(timeoutError);
98
98
  } catch {
99
99
  /* ignore */
100
100
  }
@@ -49,6 +49,7 @@ export {
49
49
  formatBytes,
50
50
  formatRelative,
51
51
  formatSnapshotList,
52
+ type BackupStatus,
52
53
  } from "./status.js";
53
54
 
54
55
  export type { SnapshotSummary } from "./types.js";
@@ -16,7 +16,8 @@ import {
16
16
  type BackupTarget,
17
17
  } from "./targets.js";
18
18
  import { backupSettings, schedulerStatus } from "./scheduler.js";
19
- import type { SnapshotSummary } from "./types.js";
19
+ import { PASSPHRASE_ENV } from "./passphrase.js";
20
+ import type { BackupSettings, SnapshotSummary } from "./types.js";
20
21
 
21
22
  type TargetStatus = {
22
23
  id: string;
@@ -38,8 +39,35 @@ export type BackupStatus = {
38
39
  };
39
40
  targets: TargetStatus[];
40
41
  snapshots: SnapshotSummary[];
42
+ /** Retention and encryption, when the subsystem is initialised. */
43
+ policy?: BackupPolicy;
41
44
  };
42
45
 
46
+ /** The settings a status panel renders alongside the numbers. */
47
+ type BackupPolicy = {
48
+ keepLocal: number;
49
+ keepRemote: number;
50
+ /**
51
+ * Snapshots are written encrypted: `backup.encryption` is configured or
52
+ * the passphrase comes from the environment (see passphrase.ts). Off
53
+ * means they stay on this machine — upload refuses plaintext.
54
+ */
55
+ encrypted: boolean;
56
+ };
57
+
58
+ function describePolicy(
59
+ settings: BackupSettings | null,
60
+ env: NodeJS.ProcessEnv = process.env,
61
+ ): BackupPolicy | undefined {
62
+ if (!settings) return undefined;
63
+ return {
64
+ keepLocal: settings.keepLocal,
65
+ keepRemote: settings.keepRemote,
66
+ encrypted:
67
+ settings.encryption !== undefined || Boolean(env[PASSPHRASE_ENV]?.trim()),
68
+ };
69
+ }
70
+
43
71
  export function formatBytes(bytes: number): string {
44
72
  if (bytes < 1024) return `${bytes} B`;
45
73
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -119,6 +147,7 @@ export async function collectBackupStatus(
119
147
  },
120
148
  targets,
121
149
  snapshots,
150
+ policy: describePolicy(settings),
122
151
  };
123
152
  }
124
153
 
@@ -779,6 +779,14 @@ const configSchema = z.object({
779
779
  * Codex.
780
780
  */
781
781
  codexApiKey: z.string().optional(),
782
+ /**
783
+ * Model a ChatGPT-OAuth Codex session falls back to (pre-emptive swaps,
784
+ * mismatch retries, the default when no model is configured). Unset =
785
+ * the Codex CLI's own default for the signed-in account (first listed
786
+ * model in `~/.codex/models_cache.json`), else a bundled default. The
787
+ * TALON_CODEX_CHATGPT_MODEL env var overrides this.
788
+ */
789
+ codexChatGptDefaultModel: z.string().min(1).optional(),
782
790
  /**
783
791
  * OpenAI API key — used by the OpenAI Agents backend and accepted by
784
792
  * Codex only as a last-resort legacy fallback when no Codex-specific
@@ -275,6 +275,11 @@ export class Gateway {
275
275
  return this.port;
276
276
  }
277
277
 
278
+ /** True while the tool bridge's HTTP server is bound and accepting. */
279
+ isListening(): boolean {
280
+ return this.server?.listening === true;
281
+ }
282
+
278
283
  getActiveChats(): number {
279
284
  return this.loom.activeContextCount();
280
285
  }
@@ -7,7 +7,10 @@
7
7
  * wiring, which does the same thing privately). Alerts raised before that
8
8
  * — early boot is exactly when restore reports and security alerts fire —
9
9
  * are held in a small bounded queue and flushed, oldest first, the moment
10
- * a notifier is wired. With nothing ever wired (tests, terminal mode with
10
+ * a notifier is wired. An alert carrying a key (operator alerts do) is
11
+ * deduplicated while it waits: a re-raise replaces the queued copy instead
12
+ * of queueing another, and a key that resolves before anyone could hear it
13
+ * is withdrawn outright. With nothing ever wired (tests, terminal mode with
11
14
  * no admin) they stay a log line; nothing throws.
12
15
  *
13
16
  * First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
@@ -25,7 +28,14 @@ let deliver: Deliver | null = null;
25
28
  /** Most alerts held while no notifier is wired; the oldest are dropped past it. */
26
29
  export const ADMIN_NOTIFY_QUEUE_MAX = 20;
27
30
 
28
- type Pending = { text: string; at: number };
31
+ type Pending = {
32
+ text: string;
33
+ at: number;
34
+ /** Dedup key (operator alert key); unkeyed alerts never coalesce. */
35
+ key?: string;
36
+ /** Re-raises folded into this entry while it waited. */
37
+ repeats: number;
38
+ };
29
39
  const pending: Pending[] = [];
30
40
  let droppedWhileUnwired = 0;
31
41
  let flushing: Promise<void> | null = null;
@@ -68,6 +78,7 @@ async function flushPending(fn: Deliver): Promise<void> {
68
78
  batch.unshift({
69
79
  text: `${dropped} earlier admin alert(s) were dropped before a notifier was wired (queue holds ${ADMIN_NOTIFY_QUEUE_MAX}); see the daemon log.`,
70
80
  at: Date.now(),
81
+ repeats: 0,
71
82
  });
72
83
  }
73
84
  log("notify", `Flushing ${batch.length} queued admin alert(s)`);
@@ -79,7 +90,10 @@ async function flushPending(fn: Deliver): Promise<void> {
79
90
  continue;
80
91
  }
81
92
  const ageS = Math.round((Date.now() - item.at) / 1000);
82
- const text = ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text;
93
+ const repeated =
94
+ item.repeats > 0 ? `\n(raised ${item.repeats + 1}× while starting)` : "";
95
+ const text =
96
+ (ageS >= 5 ? `(delayed ${ageS}s) ${item.text}` : item.text) + repeated;
83
97
  try {
84
98
  await fn(text);
85
99
  log("notify", `Admin notified (queued): ${preview(item.text)}`);
@@ -95,6 +109,17 @@ async function flushPending(fn: Deliver): Promise<void> {
95
109
  }
96
110
 
97
111
  function enqueue(item: Pending): void {
112
+ if (item.key !== undefined) {
113
+ const i = pending.findIndex((p) => p.key === item.key);
114
+ if (i >= 0) {
115
+ // Same fault raised again while waiting: keep one entry, newest text,
116
+ // original timestamp (so the "delayed" note stays honest).
117
+ const prior = pending[i];
118
+ prior.text = item.text;
119
+ prior.repeats += item.repeats + 1;
120
+ return;
121
+ }
122
+ }
98
123
  pending.push(item);
99
124
  while (pending.length > ADMIN_NOTIFY_QUEUE_MAX) {
100
125
  const lost = pending.shift();
@@ -111,14 +136,29 @@ function preview(text: string): string {
111
136
  return text.slice(0, 80).replace(/\n/g, " ");
112
137
  }
113
138
 
139
+ /**
140
+ * Withdraw a queued, not-yet-delivered alert by key (its fault cleared
141
+ * before a notifier was wired). Returns whether one was withdrawn.
142
+ */
143
+ export function withdrawAdminNotification(key: string): boolean {
144
+ const i = pending.findIndex((p) => p.key === key);
145
+ if (i < 0) return false;
146
+ const [gone] = pending.splice(i, 1);
147
+ log("notify", `Withdrew queued admin alert ${key}: ${preview(gone.text)}`);
148
+ return true;
149
+ }
150
+
114
151
  /**
115
152
  * Send `text` to the admin chat. Never throws; returns whether it was
116
153
  * delivered now (false = failed, or queued because no notifier is wired
117
- * yet — it is sent when one is).
154
+ * yet — it is sent when one is). `key` deduplicates while queued.
118
155
  */
119
- export async function notifyAdmin(text: string): Promise<boolean> {
156
+ export async function notifyAdmin(
157
+ text: string,
158
+ key?: string,
159
+ ): Promise<boolean> {
120
160
  if (!deliver) {
121
- enqueue({ text, at: Date.now() });
161
+ enqueue({ text, at: Date.now(), key, repeats: 0 });
122
162
  logWarn(
123
163
  "notify",
124
164
  `No admin notifier wired yet; queued (${pending.length}/${ADMIN_NOTIFY_QUEUE_MAX}): ${text.slice(0, 120)}`,
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { log, logWarn } from "../../util/log.js";
18
- import { notifyAdmin } from "./admin-notify.js";
18
+ import { notifyAdmin, withdrawAdminNotification } from "./admin-notify.js";
19
19
 
20
20
  export type AlertSeverity = "warn" | "error" | "critical";
21
21
 
@@ -39,7 +39,8 @@ const RANK: Record<AlertSeverity, number> = { warn: 0, error: 1, critical: 2 };
39
39
  const active = new Map<string, ActiveAlert>();
40
40
  let cooldownMs = DEFAULT_COOLDOWN_MS;
41
41
  let enabled = true;
42
- let send: (text: string) => Promise<unknown> = notifyAdmin;
42
+ type Send = (text: string, key?: string) => Promise<unknown>;
43
+ let send: Send = notifyAdmin;
43
44
 
44
45
  /** Apply operator settings (config `alerts`). */
45
46
  export function configureAlerts(opts: {
@@ -86,7 +87,9 @@ export function raiseAlert(
86
87
  // Nobody heard it: don't let the cooldown swallow the next raise.
87
88
  if (active.get(key) === entry) entry.lastSentAt = 0;
88
89
  };
89
- void send(`${ICON[severity]} ${message}${repeat}`).then((ok) => {
90
+ // The key lets a still-queued copy (no notifier wired yet) be replaced by
91
+ // this raise rather than queued twice.
92
+ void send(`${ICON[severity]} ${message}${repeat}`, key).then((ok) => {
90
93
  if (ok === false) undelivered();
91
94
  }, undelivered);
92
95
  }
@@ -99,6 +102,9 @@ export function resolveAlert(key: string, message?: string): void {
99
102
  const mins = Math.max(1, Math.round((Date.now() - prior.firstAt) / 60_000));
100
103
  log("alert", `resolved ${key} after ${mins} min`);
101
104
  if (!enabled) return;
105
+ // Still queued for a notifier that never got to send it: withdraw it, and
106
+ // there is nothing to announce a recovery from.
107
+ if (withdrawAdminNotification(key)) return;
102
108
  void send(`✅ ${message ?? `Recovered: ${key}`} (after ${mins} min)`).catch(
103
109
  () => {},
104
110
  );
@@ -120,9 +126,7 @@ export function activeAlerts(): ReadonlyArray<{
120
126
  }
121
127
 
122
128
  /** Test seam: reset state and swap the delivery function. */
123
- export function resetAlertsForTest(
124
- deliver: (text: string) => Promise<unknown> = notifyAdmin,
125
- ): void {
129
+ export function resetAlertsForTest(deliver: Send = notifyAdmin): void {
126
130
  active.clear();
127
131
  cooldownMs = DEFAULT_COOLDOWN_MS;
128
132
  enabled = true;