talon-agent 5.26.0 → 5.26.2

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.
@@ -36,19 +36,20 @@
36
36
  * 1. `modelByBackend[B]` — per-chat-per-backend pick, if it still
37
37
  * validates against B's catalog. Cross-backend orphans surface
38
38
  * as `kind: "missing"` and fall through.
39
- * 2. `backend.models?.getDefaultModelId()` — backend's canonical default.
39
+ * 2. `config.backendDefaults[B]` — operator-configured per-backend
40
+ * default in `talon.json`.
41
+ * 3. `config.model` — only when B is the global chat-role backend
42
+ * (`config.backend === B`).
43
+ * Operator picks (2/3) must validate against B's catalog when B
44
+ * has a canonical default to fall back to; an unknown pin falls
45
+ * through to step 4 (what the boot-time model audit warns about).
46
+ * 4. `backend.models?.getDefaultModelId()` — backend's canonical default.
40
47
  * Codex picks auth-aware (`gpt-5-codex` on API key, `gpt-5.5`
41
48
  * on ChatGPT OAuth). Claude SDK returns the `"default"` alias.
42
49
  * Stock OpenAI Agents returns a constant.
43
50
  * Catalog-driven backends without a canonical (Kilo, OpenCode,
44
51
  * OpenAI Agents on OpenRouter / custom OpenAI-compatible) do
45
- * NOT implement this — they fall through to step 3.
46
- * 3. `config.backendDefaults[B]` — operator-configured per-backend
47
- * default in `talon.json`. Escape hatch for "no canonical"
48
- * backends.
49
- * 4. `config.model` — only when B is the global chat-role backend
50
- * (`config.backend === B`). Back-compat for installs that
51
- * predate `backendDefaults`.
52
+ * NOT implement this.
52
53
  * 5. `null` → UI renders "No model selected", send guard refuses
53
54
  * with a "use /model to pick one" reply.
54
55
  *
@@ -56,6 +57,10 @@
56
57
  * is called. Only `kind: "exact"` with `selectable: true` honours the
57
58
  * stored override. Anything else falls through to step 2.
58
59
  *
60
+ * Operator config ranks above the canonical so a pinned `config.model`
61
+ * is actually honoured (it used to lose to Claude's `"default"`, so a
62
+ * claude install pinned to any model silently ran the default).
63
+ *
59
64
  * Backends with no `resolveModel` (rare — defensive fallback only)
60
65
  * have their stored override returned verbatim — no way to validate.
61
66
  */
@@ -157,8 +162,8 @@ async function runChain(
157
162
  }
158
163
  }
159
164
 
160
- // ── Step 2-5: backend canonical → config.backendDefaults →
161
- // config.model (chat-role only) → null
165
+ // ── Steps 2-5: backendDefaults → config.model (chat-role only) →
166
+ // backend canonical → null
162
167
  return stepsTwoThroughFive(backend, backendId, config, null);
163
168
  }
164
169
 
@@ -196,57 +201,61 @@ async function stepsTwoThroughFive(
196
201
  config: TalonConfig,
197
202
  fallbackSourceOverride: "override-invalid-fallback" | null,
198
203
  ): Promise<{ model: string | null; source: ActiveModelSource }> {
199
- // Step 2: backend.models.getDefaultModelId()
200
- if (backend?.models) {
201
- const canonical = await safeBackendDefault(backend);
202
- if (canonical) {
203
- return {
204
- model: canonical,
205
- source: fallbackSourceOverride ?? "backend-canonical",
206
- };
204
+ const withSource = (model: string, source: ActiveModelSource) => ({
205
+ model,
206
+ source: fallbackSourceOverride ?? source,
207
+ });
208
+
209
+ const operatorPick = pickOperatorDefault(backendId, config);
210
+ const canonical = backend?.models ? await safeBackendDefault(backend) : null;
211
+
212
+ // Operator config (steps 2/3) beats the backend canonical (step 4): a
213
+ // pinned `config.model` is an explicit choice, and letting the canonical
214
+ // win meant a claude install pinned to e.g. "opus[1m]" silently ran
215
+ // "default" on every turn. The pin must still validate — a withdrawn id
216
+ // falls back to the canonical, exactly what the boot-time model audit
217
+ // warns about. Without a canonical to fall back to, the pin is returned
218
+ // unvalidated (catalog-driven backends with no default, unchanged).
219
+ if (operatorPick) {
220
+ if (!canonical) return withSource(operatorPick.model, operatorPick.source);
221
+ if (await validateModelOnBackend(backend, operatorPick.model)) {
222
+ return withSource(operatorPick.model, operatorPick.source);
207
223
  }
208
224
  }
209
225
 
210
- // Step 3: config.backendDefaults[backendId]
226
+ // Step 4: backend.models.getDefaultModelId()
227
+ if (canonical) return withSource(canonical, "backend-canonical");
228
+
229
+ // Step 5: null. Callers must render "No model selected" / refuse send.
230
+ return { model: null, source: "none" };
231
+ }
232
+
233
+ /**
234
+ * The operator-configured default for a backend, if any:
235
+ * - `config.backendDefaults[B]`;
236
+ * - else `config.model` — only when B is the global chat-role backend
237
+ * (`config.backend === B`), or when no backend id is known at all
238
+ * (pre-bootstrap callers passing null).
239
+ */
240
+ function pickOperatorDefault(
241
+ backendId: string | null,
242
+ config: TalonConfig,
243
+ ): { model: string; source: ActiveModelSource } | null {
211
244
  if (backendId && config.backendDefaults) {
212
245
  const operatorDefault = config.backendDefaults[backendId];
213
246
  if (operatorDefault && operatorDefault.length > 0) {
214
- return {
215
- model: operatorDefault,
216
- source: fallbackSourceOverride ?? "config-backend-defaults",
217
- };
247
+ return { model: operatorDefault, source: "config-backend-defaults" };
218
248
  }
219
249
  }
220
-
221
- // Step 4: legacy config.model — only for the global chat-role backend
250
+ const modelAppliesHere = !backendId || backendId === config.backend;
222
251
  if (
223
- backendId &&
224
- backendId === config.backend &&
252
+ modelAppliesHere &&
225
253
  typeof config.model === "string" &&
226
254
  config.model.length > 0
227
255
  ) {
228
- return {
229
- model: config.model,
230
- source: fallbackSourceOverride ?? "config-legacy-global",
231
- };
256
+ return { model: config.model, source: "config-legacy-global" };
232
257
  }
233
-
234
- // Step 4b: even without a backendId, honour config.model if no
235
- // backend is bound at all (callers passing null for both — rare,
236
- // typically pre-bootstrap code paths).
237
- if (
238
- !backendId &&
239
- typeof config.model === "string" &&
240
- config.model.length > 0
241
- ) {
242
- return {
243
- model: config.model,
244
- source: fallbackSourceOverride ?? "config-legacy-global",
245
- };
246
- }
247
-
248
- // Step 5: null. Callers must render "No model selected" / refuse send.
249
- return { model: null, source: "none" };
258
+ return null;
250
259
  }
251
260
 
252
261
  async function validateModelOnBackend(
@@ -98,6 +98,15 @@ function registerPluginInstance(
98
98
  return loaded;
99
99
  }
100
100
 
101
+ /**
102
+ * Run a plugin's init, waiting at most `timeoutMs` for it before boot moves
103
+ * on. The deadline bounds how long boot waits, not the init itself: a
104
+ * plugin stays registered either way (its tools are served from
105
+ * `mcpServer` whether or not init finished), so an init that outlives the
106
+ * deadline keeps running and, when it does finish, clears the alert the
107
+ * timeout raised — a slow handshake on a busy boot is a delay, not a
108
+ * failure that lingers until the next restart.
109
+ */
101
110
  export async function initPluginWithTimeout(
102
111
  plugin: TalonPlugin,
103
112
  config: Record<string, unknown>,
@@ -108,28 +117,54 @@ export async function initPluginWithTimeout(
108
117
  if (!plugin.init) return;
109
118
 
110
119
  let timer: ReturnType<typeof setTimeout> | undefined;
111
-
112
120
  const alertKey = `plugin.${plugin.name}`;
121
+ const startedAt = Date.now();
122
+ // Started (and timed) only here, when this plugin's own init begins.
123
+ const init = Promise.resolve().then(() => plugin.init!(config));
124
+ const TIMED_OUT = Symbol("timed out");
125
+
113
126
  try {
114
- await Promise.race([
115
- Promise.resolve(plugin.init(config)),
116
- new Promise<never>((_, reject) => {
117
- timer = setTimeout(() => {
118
- reject(
119
- new Error(`${timeoutLabel} timed out after ${timeoutMs / 1000}s`),
120
- );
121
- }, timeoutMs);
127
+ const outcome = await Promise.race([
128
+ init,
129
+ new Promise<typeof TIMED_OUT>((settle) => {
130
+ timer = setTimeout(() => settle(TIMED_OUT), timeoutMs);
122
131
  timer.unref?.();
123
132
  }),
124
133
  ]);
125
- resolveAlert(alertKey, `Plugin "${plugin.name}" initialised normally.`);
134
+ if (outcome !== TIMED_OUT) {
135
+ resolveAlert(alertKey, `Plugin "${plugin.name}" initialised normally.`);
136
+ return;
137
+ }
138
+ const message = `${timeoutLabel} timed out after ${timeoutMs / 1000}s`;
139
+ logError("plugin", `${errorPrefix}: ${message}; still waiting for it`);
140
+ raiseAlert(
141
+ alertKey,
142
+ `Plugin "${plugin.name}" failed to initialise: ${message}. Its tools stay registered; this clears itself if init finishes late.`,
143
+ );
144
+ void init.then(
145
+ () => {
146
+ // Reloaded or unloaded meanwhile: this instance's verdict is moot.
147
+ if (registry.getByName(plugin.name)?.plugin !== plugin) return;
148
+ const took = Math.round((Date.now() - startedAt) / 1000);
149
+ log("plugin", `${plugin.name} init finished late (${took}s)`);
150
+ resolveAlert(
151
+ alertKey,
152
+ `Plugin "${plugin.name}" finished initialising late (${took}s).`,
153
+ );
154
+ },
155
+ (err: unknown) =>
156
+ logError(
157
+ "plugin",
158
+ `${errorPrefix} (after timing out): ${err instanceof Error ? err.message : err}`,
159
+ ),
160
+ );
126
161
  } catch (err) {
127
162
  logError(
128
163
  "plugin",
129
164
  `${errorPrefix}: ${err instanceof Error ? err.message : err}`,
130
165
  );
131
- // Init runs once per boot or reload: a plugin that failed it stays
132
- // half-loaded until someone fixes it, so this needs no threshold.
166
+ // An init that threw won't retry on its own: it needs a reload or
167
+ // restart, so this needs no threshold.
133
168
  raiseAlert(
134
169
  alertKey,
135
170
  `Plugin "${plugin.name}" failed to initialise: ${faultText(err)}. Its tools may not work until the next reload or restart.`,
@@ -27,10 +27,19 @@
27
27
  * can never leave the deployment un-updatable, which is what the old
28
28
  * `pull --ff-only` did (it aborts on any dirty/diverged tree). `.gitignore`
29
29
  * is respected (no `-x`), so node_modules, secrets and local config survive.
30
+ *
31
+ * Before anything in the checkout moves, a pinned pre-update checkpoint is
32
+ * taken. If it fails, the update is refused. An update with no way back is
33
+ * when data goes missing, so the operator must say `force` to go on
34
+ * without one. Opting out in config (`backup.checkpointBeforeUpdate:
35
+ * false`) is the only way to skip it without being asked.
30
36
  */
31
37
 
32
38
  import { execFile } from "node:child_process";
33
- import { checkpointBeforeUpdate } from "../backup/index.js";
39
+ import {
40
+ checkpointBeforeUpdate,
41
+ type UpdateCheckpoint,
42
+ } from "../backup/index.js";
34
43
  import { existsSync } from "node:fs";
35
44
  import { dirname, join } from "node:path";
36
45
  import { fileURLToPath } from "node:url";
@@ -60,6 +69,13 @@ export interface UpdateOptions {
60
69
  entry?: { cmd: string; args: readonly string[] };
61
70
  /** Injectable command runner (tests). */
62
71
  runner?: CommandRunner;
72
+ /**
73
+ * Go on even when the pre-update checkpoint fails (`/update force`).
74
+ * The result still says the checkpoint failed.
75
+ */
76
+ force?: boolean;
77
+ /** Injectable pre-update checkpoint (tests). */
78
+ checkpoint?: (from: string, to: string) => Promise<UpdateCheckpoint>;
63
79
  }
64
80
 
65
81
  /** One executed step in an update run. */
@@ -82,6 +98,13 @@ export interface UpdateResult {
82
98
  changed: boolean;
83
99
  /** Human-readable failure reason when `ok` is false. */
84
100
  error?: string;
101
+ /** The pre-update checkpoint, when the update got far enough to take one. */
102
+ checkpoint?: UpdateCheckpoint;
103
+ /**
104
+ * True when the update was refused because the checkpoint failed. Nothing
105
+ * in the checkout was touched; `force` would have gone on.
106
+ */
107
+ checkpointRefused?: boolean;
85
108
  }
86
109
 
87
110
  export type CommandRunner = (
@@ -171,12 +194,7 @@ export async function runSelfUpdate(
171
194
  const branch = opts.branch?.trim() || "main";
172
195
  const run = opts.runner ?? defaultRunner;
173
196
 
174
- const record = async (
175
- label: string,
176
- cmd: string,
177
- args: readonly string[],
178
- timeoutMs: number,
179
- ): Promise<UpdateStep> => {
197
+ const record: Recorder = async (label, cmd, args, timeoutMs) => {
180
198
  const { ok, output } = await run(cmd, args, repoRoot, timeoutMs);
181
199
  const step: UpdateStep = { label, ok, output };
182
200
  steps.push(step);
@@ -210,22 +228,58 @@ export async function runSelfUpdate(
210
228
  );
211
229
  if (!fetch.ok) return fail(`git fetch failed: ${fetch.output}`, before);
212
230
 
231
+ // Where the update is going, read before the tree moves: the checkpoint
232
+ // is labelled with it, and "already up to date" needs no checkpoint.
233
+ const targetRef = `${remote}/${branch}`;
234
+ const target = await record(
235
+ `rev-parse ${targetRef}`,
236
+ "git",
237
+ ["rev-parse", targetRef],
238
+ GIT_TIMEOUT_MS,
239
+ );
240
+ if (!target.ok) {
241
+ return fail(`Failed to read ${targetRef}: ${target.output}`, before);
242
+ }
243
+ const upcoming = shortSha(target.output);
244
+
245
+ // The safety net goes up before anything is destroyed: the reset and
246
+ // clean below discard local state, and the new code may migrate data.
247
+ let checkpoint: UpdateCheckpoint | undefined;
248
+ if (upcoming !== before) {
249
+ checkpoint = await (opts.checkpoint ?? checkpointBeforeUpdate)(
250
+ before,
251
+ upcoming,
252
+ );
253
+ steps.push({
254
+ label: "pre-update checkpoint",
255
+ ok: checkpoint.status !== "failed",
256
+ output: describeCheckpoint(checkpoint),
257
+ });
258
+ if (checkpoint.status === "failed" && !opts.force) {
259
+ return {
260
+ ...fail(refusal(checkpoint.error), before),
261
+ checkpoint,
262
+ checkpointRefused: true,
263
+ };
264
+ }
265
+ }
266
+
213
267
  // Force the checkout to exactly match the freshly-fetched remote
214
268
  // branch, discarding ANY local edits or diverged commits. The old
215
269
  // `pull --ff-only` aborted here whenever the tree was dirty, leaving the
216
270
  // deployment stuck; a bot host is meant to mirror the remote, so resetting
217
271
  // to it is both correct and reliable.
218
272
  const reset = await record(
219
- `reset --hard ${remote}/${branch}`,
273
+ `reset --hard ${targetRef}`,
220
274
  "git",
221
- ["reset", "--hard", `${remote}/${branch}`],
275
+ ["reset", "--hard", targetRef],
222
276
  GIT_TIMEOUT_MS,
223
277
  );
224
278
  if (!reset.ok) {
225
- return fail(
226
- `git reset --hard ${remote}/${branch} failed: ${reset.output}`,
227
- before,
228
- );
279
+ return {
280
+ ...fail(`git reset --hard ${targetRef} failed: ${reset.output}`, before),
281
+ checkpoint,
282
+ };
229
283
  }
230
284
 
231
285
  // Drop untracked files too so the tree is pristine and a future update
@@ -246,28 +300,62 @@ export async function runSelfUpdate(
246
300
  // Nothing moved — skip the expensive install/setup and tell the
247
301
  // caller no restart is needed.
248
302
  if (!changed) {
249
- return { ok: true, repoRoot, steps, before, after, changed: false };
303
+ return {
304
+ ok: true,
305
+ repoRoot,
306
+ steps,
307
+ before,
308
+ after,
309
+ changed: false,
310
+ checkpoint,
311
+ };
250
312
  }
251
313
 
252
- await checkpointBeforeUpdate(before, after);
314
+ const error = await installAndVerify(record, opts, before, after);
315
+ return {
316
+ ok: !error,
317
+ repoRoot,
318
+ steps,
319
+ before,
320
+ after,
321
+ changed: true,
322
+ checkpoint,
323
+ ...(error ? { error } : {}),
324
+ };
325
+ }
326
+
327
+ type Recorder = (
328
+ label: string,
329
+ cmd: string,
330
+ args: readonly string[],
331
+ timeoutMs: number,
332
+ ) => Promise<UpdateStep>;
253
333
 
334
+ function refusal(error: string): string {
335
+ return (
336
+ `the pre-update checkpoint failed (${error}). ` +
337
+ `Nothing was changed. Fix the backup problem, or run the update ` +
338
+ `with "force" to go on without a checkpoint.`
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Reinstall, run setup, and prove the new tree imports. Returns the
344
+ * failure reason, or null when the tree is ready to restart into.
345
+ */
346
+ async function installAndVerify(
347
+ record: Recorder,
348
+ opts: UpdateOptions,
349
+ before: string,
350
+ after: string,
351
+ ): Promise<string | null> {
254
352
  const install = await record(
255
353
  "npm install",
256
354
  "npm",
257
355
  ["install"],
258
356
  INSTALL_TIMEOUT_MS,
259
357
  );
260
- if (!install.ok) {
261
- return {
262
- ok: false,
263
- repoRoot,
264
- steps,
265
- before,
266
- after,
267
- changed,
268
- error: `npm install failed: ${install.output}`,
269
- };
270
- }
358
+ if (!install.ok) return `npm install failed: ${install.output}`;
271
359
 
272
360
  for (const cmd of opts.setup ?? []) {
273
361
  const trimmed = cmd.trim();
@@ -279,15 +367,7 @@ export async function runSelfUpdate(
279
367
  SETUP_TIMEOUT_MS,
280
368
  );
281
369
  if (!setup.ok) {
282
- return {
283
- ok: false,
284
- repoRoot,
285
- steps,
286
- before,
287
- after,
288
- changed,
289
- error: `setup command failed (${trimmed}): ${setup.output}`,
290
- };
370
+ return `setup command failed (${trimmed}): ${setup.output}`;
291
371
  }
292
372
  }
293
373
 
@@ -299,18 +379,35 @@ export async function runSelfUpdate(
299
379
  VERIFY_TIMEOUT_MS,
300
380
  );
301
381
  if (!verify.ok || !verify.output.includes(BOOT_SMOKE_OK)) {
302
- return {
303
- ok: false,
304
- repoRoot,
305
- steps,
306
- before,
307
- after,
308
- changed,
309
- error:
310
- `the updated tree does not import — not restarting into it. ` +
311
- `Still running ${before}; the checkout is at ${after}.`,
312
- };
382
+ return (
383
+ `the updated tree does not import — not restarting into it. ` +
384
+ `Still running ${before}; the checkout is at ${after}.`
385
+ );
386
+ }
387
+ return null;
388
+ }
389
+
390
+ /** One line for the step log and the chat reply. */
391
+ export function describeCheckpoint(checkpoint: UpdateCheckpoint): string {
392
+ switch (checkpoint.status) {
393
+ case "taken":
394
+ return `checkpoint ${checkpoint.id} taken`;
395
+ case "disabled":
396
+ return "no checkpoint (backup.checkpointBeforeUpdate is off)";
397
+ case "failed":
398
+ return `checkpoint failed: ${checkpoint.error}`;
313
399
  }
400
+ }
314
401
 
315
- return { ok: true, repoRoot, steps, before, after, changed: true };
402
+ /**
403
+ * Whether the arguments to `/update` ask to go on without a checkpoint.
404
+ * `force` and `--force` both work, so the chat command and a CLI habit
405
+ * read the same.
406
+ */
407
+ export function wantsForce(args: string | undefined | null): boolean {
408
+ return (args ?? "")
409
+ .trim()
410
+ .toLowerCase()
411
+ .split(/\s+/)
412
+ .some((token) => token === "force" || token === "--force");
316
413
  }
@@ -32,6 +32,7 @@ import {
32
32
  import {
33
33
  getRepoRoot,
34
34
  runSelfUpdate,
35
+ describeCheckpoint,
35
36
  } from "../../../core/update/self-update.js";
36
37
  import { reply } from "./interaction.js";
37
38
 
@@ -153,7 +154,8 @@ export async function handleDream(
153
154
  }
154
155
 
155
156
  /**
156
- * /update — pull, reinstall, run setup, restart. Only reachable on developer
157
+ * /update [force] — pull, reinstall, run setup, restart. Refused when the
158
+ * pre-update checkpoint fails unless `force` is set. Only reachable on developer
157
159
  * builds running from a git checkout; the command is not registered at all
158
160
  * otherwise (see buildCommandDefinitions).
159
161
  */
@@ -172,8 +174,13 @@ export async function handleUpdate(
172
174
  }
173
175
  const remote = config.update?.remote ?? "origin";
174
176
  const branch = config.update?.branch ?? "main";
177
+ const force = i.options.getBoolean("force") ?? false;
175
178
  await i.deferReply({ flags: MessageFlags.Ephemeral });
176
- await i.editReply(`⏳ Updating from \`${remote}/${branch}\`…`);
179
+ await i.editReply(
180
+ `⏳ Updating from \`${remote}/${branch}\`` +
181
+ (force ? " (forced: a failed checkpoint will not stop it)" : "") +
182
+ "…",
183
+ );
177
184
  const edit = (text: string) => i.editReply(safeSlice(text, DISCORD_MAX_TEXT));
178
185
 
179
186
  // Fire-and-forget so the gateway keeps processing other interactions.
@@ -182,12 +189,24 @@ export async function handleUpdate(
182
189
  branch,
183
190
  setup: config.update?.setup,
184
191
  repoRoot,
192
+ force,
185
193
  })
186
194
  .then(async (res) => {
195
+ if (res.checkpointRefused) {
196
+ await edit(
197
+ `🛑 Update refused: ${res.error ?? "the pre-update checkpoint failed"}\n\n` +
198
+ "Run `/update force:true` to update without a checkpoint.",
199
+ );
200
+ return;
201
+ }
202
+ const note = res.checkpoint
203
+ ? `\n${describeCheckpoint(res.checkpoint)}`
204
+ : "";
187
205
  if (!res.ok) {
188
206
  const tail = res.steps[res.steps.length - 1]?.output ?? "";
189
207
  await edit(
190
208
  `⚠️ Update failed: ${res.error ?? "unknown error"}` +
209
+ note +
191
210
  (tail
192
211
  ? `\n\`\`\`\n${escapeForCodeBlock(tail.slice(-1200))}\n\`\`\``
193
212
  : ""),
@@ -201,7 +220,7 @@ export async function handleUpdate(
201
220
  return;
202
221
  }
203
222
  await edit(
204
- `✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`. ♻️ Restarting…`,
223
+ `✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`.${note}\n♻️ Restarting…`,
205
224
  );
206
225
  // The successor documents any provisioning changes (plugin runtime
207
226
  // upgrades, migrations) back to this channel once it's up.
@@ -156,6 +156,13 @@ function buildCommandDefinitions(devBuild = false): unknown[] {
156
156
  new SlashCommandBuilder()
157
157
  .setName("update")
158
158
  .setDescription("Pull latest, reinstall, restart (admin)")
159
+ .addBooleanOption((o) =>
160
+ o
161
+ .setName("force")
162
+ .setDescription(
163
+ "Update even if the pre-update checkpoint fails",
164
+ ),
165
+ )
159
166
  .toJSON(),
160
167
  ]
161
168
  : []),