talon-agent 3.16.1 → 3.18.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.
@@ -29,6 +29,12 @@ export interface DoctorCheck {
29
29
  * starts but a backend won't work.
30
30
  */
31
31
  issue?: boolean;
32
+ /**
33
+ * A backend that is configured but not serving chats. Renderers group
34
+ * these away from the environment so a handful of idle providers can't
35
+ * bury the checks that describe the running deployment.
36
+ */
37
+ inactive?: boolean;
32
38
  }
33
39
 
34
40
  /** One embedded native module: provenance plus a live self-test result. */
@@ -59,6 +65,8 @@ export interface DoctorReport {
59
65
  export interface DoctorConfigSlice {
60
66
  frontend: string | string[];
61
67
  backend?: string;
68
+ /** Backends config exposes. Empty / absent means every registered one. */
69
+ enabledBackends?: string[];
62
70
  model?: string;
63
71
  heartbeatModel?: string;
64
72
  heartbeatBackend?: string;
@@ -284,12 +292,55 @@ async function checkClaudeConfiguredModels(
284
292
  return checks;
285
293
  }
286
294
 
287
- /** Binary / auth checks for the active backend only. */
295
+ /** Every backend id doctor knows how to inspect. */
296
+ const KNOWN_BACKENDS = [
297
+ "claude",
298
+ "codex",
299
+ "kilo",
300
+ "opencode",
301
+ "openai-agents",
302
+ ] as const;
303
+
304
+ /**
305
+ * Binary / auth checks across every backend the config exposes.
306
+ *
307
+ * Only the active one counts toward the issue total; the rest are reported
308
+ * so a switch doesn't have to be the thing that discovers a backend can't
309
+ * run. That distinction matters now that a chat can be rebound at runtime —
310
+ * a report of all-green while two backends are one click from failing is
311
+ * worse than no report.
312
+ */
288
313
  async function checkBackend(
289
314
  config: DoctorConfigSlice | undefined,
315
+ ): Promise<DoctorCheck[]> {
316
+ const active = config?.backend ?? "claude";
317
+ const exposed = config?.enabledBackends?.length
318
+ ? config.enabledBackends
319
+ : [...KNOWN_BACKENDS];
320
+
321
+ const checks = await checkOneBackend(active, config, true);
322
+ for (const id of exposed) {
323
+ if (id === active || !KNOWN_BACKENDS.includes(id as never)) continue;
324
+ // An idle backend's missing binary is a heads-up, not a fault of this
325
+ // deployment: downgrade it and keep it out of the issue count.
326
+ for (const check of await checkOneBackend(id, config, false)) {
327
+ checks.push({
328
+ ...check,
329
+ status: check.status === "fail" ? "warn" : check.status,
330
+ issue: false,
331
+ inactive: true,
332
+ });
333
+ }
334
+ }
335
+ return checks;
336
+ }
337
+
338
+ async function checkOneBackend(
339
+ backend: string,
340
+ config: DoctorConfigSlice | undefined,
341
+ isActive: boolean,
290
342
  ): Promise<DoctorCheck[]> {
291
343
  const checks: DoctorCheck[] = [];
292
- const backend = config?.backend ?? "claude";
293
344
 
294
345
  if (backend === "claude") {
295
346
  if (config?.claudeBinary) {
@@ -313,7 +364,9 @@ async function checkBackend(
313
364
  : { label: "Claude Code not found", status: "fail" },
314
365
  );
315
366
  }
316
- checks.push(...(await checkClaudeConfiguredModels(config)));
367
+ // Model resolution spawns a probe — worth it for the backend actually
368
+ // serving chats, wasteful for one nobody is using.
369
+ if (isActive) checks.push(...(await checkClaudeConfiguredModels(config)));
317
370
  } else if (backend === "codex") {
318
371
  if (!binaryOnPath("codex")) {
319
372
  checks.push({
@@ -349,11 +402,21 @@ async function checkBackend(
349
402
  });
350
403
  }
351
404
  } else if (backend === "kilo" || backend === "opencode") {
352
- // Bundled as npm deps — no external binary to check.
353
- checks.push({
354
- label: `${backend === "kilo" ? "Kilo" : "OpenCode"} SDK bundled`,
355
- status: "ok",
356
- });
405
+ // The SDK ships as an npm dep but only talks to a server it spawns from
406
+ // the CLI of the same name (`cross-spawn` → PATH lookup). A present
407
+ // package with an absent binary fails at the first turn with a bare
408
+ // ENOENT, so check what actually gets executed.
409
+ const label = backend === "kilo" ? "Kilo" : "OpenCode";
410
+ checks.push(
411
+ binaryOnPath(backend)
412
+ ? { label: `${label} CLI installed`, status: "ok" }
413
+ : {
414
+ label: `${label} CLI not found`,
415
+ status: "fail",
416
+ detail: `the ${label} SDK spawns \`${backend}\` — install it or this backend cannot start`,
417
+ issue: true,
418
+ },
419
+ );
357
420
  } else if (backend === "openai-agents") {
358
421
  checks.push({ label: "OpenAI Agents SDK bundled", status: "ok" });
359
422
  const hasEnvKey = Boolean(process.env.OPENAI_API_KEY);
@@ -29,6 +29,14 @@ import {
29
29
  } from "discord.js";
30
30
  import type { TalonConfig } from "../../../util/config.js";
31
31
  import type { Gateway } from "../../../core/engine/gateway.js";
32
+ import {
33
+ buildModelPickerView,
34
+ buildBackendPickerView,
35
+ decodeModelNav,
36
+ MODEL_NAV_PREFIX,
37
+ MODEL_PAGE_SIZE,
38
+ } from "../model-picker.js";
39
+ import { metricsViewRow, renderMetricsView } from "../commands/admin.js";
32
40
  import {
33
41
  getChatSettings,
34
42
  setChatModelForBackend,
@@ -52,7 +60,12 @@ import { execute } from "../../../core/engine/dispatcher.js";
52
60
  import {
53
61
  getBackendIdForChat,
54
62
  resolveChatBackend,
63
+ listAvailableBackends,
64
+ rebindChat,
55
65
  } from "../../../core/engine/backend-controller/index.js";
66
+ import { resetSession } from "../../../storage/sessions.js";
67
+ import { clearHistory } from "../../../storage/history.js";
68
+ import { resetPulseCheckpoint } from "../../../core/background/pulse.js";
56
69
  import { resolveActiveModelForChat } from "../../../core/models/active-model.js";
57
70
  import {
58
71
  displayReasoningEffort,
@@ -286,6 +299,38 @@ export async function handleComponentInteraction(
286
299
  const be = resolveChatBackend(chatId, gateway?.backend);
287
300
  const beId = getBackendIdForChat(chatId);
288
301
 
302
+ // In the provider view the select carries chips, not models — picking
303
+ // one drills into that provider rather than choosing anything.
304
+ if (value?.startsWith("provider:") && be?.models?.getSettingsPresentation) {
305
+ const providerId = value.slice("provider:".length);
306
+ const { model: activeNow } = await resolveActiveModelForChat(
307
+ chatId,
308
+ be,
309
+ beId,
310
+ config,
311
+ );
312
+ const pres = await be.models.getSettingsPresentation(activeNow ?? "", {
313
+ callbackPrefix: "model:",
314
+ navCallbackPrefix: MODEL_NAV_PREFIX,
315
+ pageSize: MODEL_PAGE_SIZE,
316
+ provider: providerId,
317
+ });
318
+ const view = buildModelPickerView(
319
+ pres,
320
+ activeNow ?? "_No model selected_",
321
+ beId,
322
+ );
323
+ try {
324
+ await interaction.update({
325
+ content: view.content,
326
+ components: view.components,
327
+ });
328
+ } catch {
329
+ /* ignore */
330
+ }
331
+ return;
332
+ }
333
+
289
334
  if (value === "reset") {
290
335
  // Clear THIS backend's slot only — other backends stay intact.
291
336
  setChatModelForBackend(chatId, beId, undefined);
@@ -319,35 +364,234 @@ export async function handleComponentInteraction(
319
364
  const current = resolvedCurrent ?? "";
320
365
  const pres = await be.models?.getSettingsPresentation(current, {
321
366
  callbackPrefix: "model:",
367
+ navCallbackPrefix: MODEL_NAV_PREFIX,
368
+ pageSize: MODEL_PAGE_SIZE,
322
369
  });
323
370
  const modelInfo = resolvedCurrent
324
371
  ? await be.models?.getRawModelInfo?.(resolvedCurrent)
325
372
  : undefined;
326
373
  const displayName =
327
374
  modelInfo?.displayName ?? resolvedCurrent ?? "_No model selected_";
328
- const menu = new StringSelectMenuBuilder()
329
- .setCustomId("model:select")
330
- .setPlaceholder("Pick a model")
331
- .addOptions(
332
- pres.modelButtons.slice(0, 25).map((b) => ({
333
- label: safeSlice(b.text.replace(/^✓ /, ""), 100),
334
- value: safeSlice(b.callback_data.replace(/^model:/, ""), 100),
335
- default: b.text.startsWith("✓"),
336
- })),
337
- );
338
- const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
339
- menu,
375
+ const view = buildModelPickerView(pres, displayName, beId);
376
+ try {
377
+ await interaction.update({
378
+ content: view.content,
379
+ components: view.components,
380
+ });
381
+ } catch {
382
+ /* ignore */
383
+ }
384
+ }
385
+ return;
386
+ }
387
+
388
+ // ── /model pager — page arrows, free-tier toggle, provider drill ────────
389
+ // Pure navigation: re-render the same panel at the requested page without
390
+ // touching the chat's selected model.
391
+ if (customId.startsWith(`${MODEL_NAV_PREFIX}:`)) {
392
+ // Backend submenu — the one nav action that isn't a re-page.
393
+ if (customId === `${MODEL_NAV_PREFIX}:backends`) {
394
+ const view = buildBackendPickerView(
395
+ listAvailableBackends(config),
396
+ getBackendIdForChat(chatId),
340
397
  );
341
398
  try {
342
399
  await interaction.update({
343
- content: [`**Model:** \`${displayName}\``, ...pres.modelDetails].join(
344
- "\n",
345
- ),
346
- components: [row],
400
+ content: view.content,
401
+ components: view.components,
347
402
  });
348
403
  } catch {
349
404
  /* ignore */
350
405
  }
406
+ return;
407
+ }
408
+
409
+ const target = decodeModelNav(customId);
410
+ if (!target) {
411
+ try {
412
+ await interaction.deferUpdate();
413
+ } catch {
414
+ /* ignore */
415
+ }
416
+ return;
417
+ }
418
+ const be = resolveChatBackend(chatId, gateway?.backend);
419
+ const beId = getBackendIdForChat(chatId);
420
+ const { model: current } = await resolveActiveModelForChat(
421
+ chatId,
422
+ be,
423
+ beId,
424
+ config,
425
+ );
426
+ if (!be?.models?.getSettingsPresentation) {
427
+ try {
428
+ await interaction.deferUpdate();
429
+ } catch {
430
+ /* ignore */
431
+ }
432
+ return;
433
+ }
434
+ const pres = await be.models.getSettingsPresentation(current ?? "", {
435
+ callbackPrefix: "model:",
436
+ navCallbackPrefix: MODEL_NAV_PREFIX,
437
+ pageSize: MODEL_PAGE_SIZE,
438
+ page: target.page,
439
+ filter: target.filter,
440
+ ...(target.provider ? { provider: target.provider } : {}),
441
+ });
442
+ const modelInfo = current
443
+ ? await be.models?.getRawModelInfo?.(current)
444
+ : undefined;
445
+ const view = buildModelPickerView(
446
+ pres,
447
+ modelInfo?.displayName ?? current ?? "_No model selected_",
448
+ beId,
449
+ );
450
+ try {
451
+ await interaction.update({
452
+ content: view.content,
453
+ components: view.components,
454
+ });
455
+ } catch {
456
+ /* ignore */
457
+ }
458
+ return;
459
+ }
460
+
461
+ // ── /model backend switch ───────────────────────────────────────────────
462
+ // Rebinding drops this chat's session (it isn't portable across backends)
463
+ // but keeps each backend's remembered model pick, so switching back
464
+ // restores what was selected there before.
465
+ if (customId === "model:backend-select" && interaction.isStringSelectMenu()) {
466
+ const backendId = interaction.values[0] ?? "";
467
+ const available = listAvailableBackends(config);
468
+ if (!available.some((b) => b.id === backendId)) {
469
+ await interaction.reply({
470
+ content: "Backend not available.",
471
+ flags: MessageFlags.Ephemeral,
472
+ });
473
+ return;
474
+ }
475
+
476
+ // Booting a cold backend takes seconds — Kilo and OpenCode spawn a server
477
+ // and sweep MCP registrations — and Discord drops an interaction that
478
+ // isn't acknowledged within three. Ack first, edit the panel after.
479
+ try {
480
+ await interaction.deferUpdate();
481
+ } catch {
482
+ /* already acknowledged */
483
+ }
484
+ const fail = async (text: string): Promise<void> => {
485
+ try {
486
+ await interaction.followUp({
487
+ content: text,
488
+ flags: MessageFlags.Ephemeral,
489
+ });
490
+ } catch {
491
+ /* ignore */
492
+ }
493
+ };
494
+
495
+ // Resolve the outgoing backend before rebinding — afterwards the chat
496
+ // already points at the new one and the old instance is unreachable.
497
+ const previousId = getBackendIdForChat(chatId);
498
+ const previous = resolveChatBackend(chatId, gateway?.backend);
499
+ const alreadyThere = previousId === backendId;
500
+ const result = alreadyThere
501
+ ? { ok: true as const }
502
+ : await rebindChat(chatId, backendId, config);
503
+ if (!result.ok) {
504
+ await fail(result.error?.slice(0, 200) ?? "Rebind failed.");
505
+ return;
506
+ }
507
+ setChatBackend(chatId, backendId);
508
+
509
+ // The pool accepts a backend whose SDK is installed, but the SDK may only
510
+ // be a client for a CLI that isn't (OpenCode and Kilo spawn `opencode` /
511
+ // `kilo`). That surfaces here, on the first real call. Reading the
512
+ // catalog is the cheapest way to find out, and it is needed for the panel
513
+ // anyway — so treat a throw as "this backend can't serve" and put the
514
+ // chat back where it was rather than stranding it somewhere unusable.
515
+ let next = resolveChatBackend(chatId, gateway?.backend);
516
+ let newModel: string | undefined;
517
+ let pres;
518
+ try {
519
+ newModel =
520
+ (await resolveActiveModelForChat(chatId, next, backendId, config))
521
+ .model ?? undefined;
522
+ if (next?.models?.getSettingsPresentation) {
523
+ pres = await next.models.getSettingsPresentation(newModel ?? "", {
524
+ callbackPrefix: "model:",
525
+ navCallbackPrefix: MODEL_NAV_PREFIX,
526
+ pageSize: MODEL_PAGE_SIZE,
527
+ });
528
+ }
529
+ } catch (err) {
530
+ const reason = err instanceof Error ? err.message : String(err);
531
+ logError("discord", `Backend switch to ${backendId} failed`, err);
532
+ if (!alreadyThere) {
533
+ await rebindChat(chatId, previousId, config).catch(() => undefined);
534
+ setChatBackend(chatId, previousId);
535
+ }
536
+ await fail(
537
+ `⚠️ \`${backendId}\` could not start — staying on \`${previousId}\`.\n` +
538
+ `\`\`\`\n${safeSlice(reason, 300)}\n\`\`\``,
539
+ );
540
+ return;
541
+ }
542
+
543
+ // Only now is the switch known to hold. Session state doesn't port across
544
+ // backends, so it goes — but each backend's remembered model pick stays.
545
+ // A re-pick of the backend already in use clears nothing: the retry after
546
+ // a timed-out interaction must not cost the session a second time.
547
+ if (!alreadyThere) {
548
+ resetSession(chatId);
549
+ clearHistory(chatId);
550
+ resetPulseCheckpoint(chatId);
551
+ previous?.sessions?.resetChat?.(chatId);
552
+ }
553
+
554
+ next = resolveChatBackend(chatId, gateway?.backend);
555
+ // Warming is fire-and-forget: it can take seconds on OpenCode/Kilo and
556
+ // the interaction still has a panel to redraw.
557
+ if (next && next !== previous) {
558
+ void Promise.resolve(next.sessions?.warmSession?.(chatId)).catch(
559
+ () => undefined,
560
+ );
561
+ }
562
+
563
+ try {
564
+ await interaction.editReply(
565
+ pres
566
+ ? {
567
+ ...buildModelPickerView(
568
+ pres,
569
+ newModel ?? "_No model selected_",
570
+ backendId,
571
+ ),
572
+ }
573
+ : {
574
+ content: `**Backend:** \`${backendId}\` · model \`${newModel ?? "none"}\``,
575
+ components: [],
576
+ },
577
+ );
578
+ } catch {
579
+ /* ignore */
580
+ }
581
+ return;
582
+ }
583
+
584
+ // ── /metrics today ↔ all-time toggle ────────────────────────────────────
585
+ if (customId === "metrics:today" || customId === "metrics:all") {
586
+ const view = customId === "metrics:all" ? "all" : "today";
587
+ const messages = renderMetricsView(view);
588
+ try {
589
+ await interaction.update({
590
+ content: messages[0]!,
591
+ components: [metricsViewRow(view).toJSON()],
592
+ });
593
+ } catch {
594
+ /* ignore */
351
595
  }
352
596
  return;
353
597
  }
@@ -3,16 +3,37 @@
3
3
  * All gate on `isAdmin`.
4
4
  */
5
5
 
6
- import { type ChatInputCommandInteraction, MessageFlags } from "discord.js";
6
+ import {
7
+ type ChatInputCommandInteraction,
8
+ ActionRowBuilder,
9
+ ButtonBuilder,
10
+ ButtonStyle,
11
+ MessageFlags,
12
+ } from "discord.js";
7
13
  import type { TalonConfig } from "../../../util/config.js";
8
14
  import type { Gateway } from "../../../core/engine/gateway.js";
9
15
  import { respawnSelf } from "../../../util/respawn.js";
10
16
  import { forceDream } from "../../../core/background/dream.js";
11
- import { formatDuration, renderMetricsMessages } from "../helpers.js";
17
+ import {
18
+ formatDuration,
19
+ renderMetricsMessages,
20
+ renderDoctorMessages,
21
+ } from "../helpers.js";
22
+ import { collectDoctorReport } from "../../../core/doctor.js";
23
+ import { getSoul } from "../../../core/soul/service.js";
12
24
  import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
13
25
  import { handleAdminSubcommand } from "../admin.js";
14
26
  import { isAdmin } from "../handlers/index.js";
15
- import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
27
+ import {
28
+ suppressMentions,
29
+ DISCORD_MAX_TEXT,
30
+ safeSlice,
31
+ escapeForCodeBlock,
32
+ } from "../formatting.js";
33
+ import {
34
+ getRepoRoot,
35
+ runSelfUpdate,
36
+ } from "../../../core/update/self-update.js";
16
37
  import { reply } from "./shared.js";
17
38
 
18
39
  export async function handleRestart(
@@ -34,18 +55,112 @@ export async function handleMetrics(
34
55
  return;
35
56
  }
36
57
  // Ephemeral — admin counters (token usage, latencies, errors) shouldn't leak
37
- // into a public channel where non-admins can read them.
38
- const messages = [
39
- ...renderMetricsMessages(getMetrics()),
40
- ...renderMetricsMessages(
41
- getTodayMetrics(),
42
- undefined,
43
- "📊 Metrics — today (UTC)",
44
- ),
45
- ];
46
- for (const m of messages) {
47
- await reply(i, m, true);
58
+ // into a public channel where non-admins can read them. Both views are one
59
+ // button apart rather than two bursts of messages.
60
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
61
+ const messages = renderMetricsMessages(
62
+ getTodayMetrics(),
63
+ undefined,
64
+ "📊 Metrics — today (UTC)",
65
+ );
66
+ await i.editReply({
67
+ content: messages[0]!,
68
+ components: [metricsViewRow("today").toJSON()],
69
+ });
70
+ for (const extra of messages.slice(1)) {
71
+ await i.followUp({ content: extra, flags: MessageFlags.Ephemeral });
72
+ }
73
+ }
74
+
75
+ /** Today / all-time toggle under the metrics panel. */
76
+ export function metricsViewRow(
77
+ view: MetricsView,
78
+ ): ActionRowBuilder<ButtonBuilder> {
79
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
80
+ new ButtonBuilder()
81
+ .setCustomId("metrics:today")
82
+ .setLabel("Today")
83
+ .setStyle(view === "today" ? ButtonStyle.Primary : ButtonStyle.Secondary)
84
+ .setDisabled(view === "today"),
85
+ new ButtonBuilder()
86
+ .setCustomId("metrics:all")
87
+ .setLabel("All time")
88
+ .setStyle(view === "all" ? ButtonStyle.Primary : ButtonStyle.Secondary)
89
+ .setDisabled(view === "all"),
90
+ );
91
+ }
92
+
93
+ export type MetricsView = "today" | "all";
94
+
95
+ /** Panel body for one view — shared by the command and the toggle. */
96
+ export function renderMetricsView(view: MetricsView): string[] {
97
+ return view === "today"
98
+ ? renderMetricsMessages(
99
+ getTodayMetrics(),
100
+ undefined,
101
+ "📊 Metrics — today (UTC)",
102
+ )
103
+ : renderMetricsMessages(getMetrics(), undefined, "📊 Metrics — all time");
104
+ }
105
+
106
+ export async function handleDoctor(
107
+ i: ChatInputCommandInteraction,
108
+ config: TalonConfig,
109
+ ): Promise<void> {
110
+ if (!isAdmin(i.user.id)) {
111
+ await reply(i, "Not authorized.", true);
112
+ return;
113
+ }
114
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
115
+ await i.editReply("🩺 Running checks...");
116
+ try {
117
+ // Same checks as `talon doctor` — config exists by definition when the
118
+ // bot is processing this command.
119
+ const report = await collectDoctorReport({ config, hasConfigFile: true });
120
+ const messages = renderDoctorMessages(report);
121
+ await i.editReply(messages[0]!);
122
+ for (const extra of messages.slice(1))
123
+ await i.followUp({
124
+ content: extra,
125
+ flags: MessageFlags.Ephemeral,
126
+ });
127
+ } catch (err) {
128
+ const msg = err instanceof Error ? err.message : String(err);
129
+ await i.editReply(`🩺 Doctor failed: ${msg}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * /soul — read-only introspection of the compiled identity. `action:dream`
135
+ * runs the organic maintenance pass and is admin-only. Inert while the soul
136
+ * is disabled, so it is safe to ship dormant.
137
+ */
138
+ export async function handleSoul(
139
+ i: ChatInputCommandInteraction,
140
+ ): Promise<void> {
141
+ const soul = getSoul();
142
+ if (!soul.enabled) {
143
+ await reply(
144
+ i,
145
+ "Soul is disabled (set TALON_SOUL_ENABLED to enable).",
146
+ true,
147
+ );
148
+ return;
149
+ }
150
+ if (i.options.getString("action") === "dream") {
151
+ if (!isAdmin(i.user.id)) {
152
+ await reply(i, "Not authorized.", true);
153
+ return;
154
+ }
155
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
156
+ await i.editReply("🧠 Soul dreaming...");
157
+ soul
158
+ .dream()
159
+ .then(() => i.editReply("🧠 Soul dream complete."))
160
+ .catch(() => undefined);
161
+ return;
48
162
  }
163
+ await reply(i, suppressMentions(soul.introspect()), true);
49
164
  }
50
165
 
51
166
  export async function handleDream(
@@ -71,6 +186,65 @@ export async function handleDream(
71
186
  });
72
187
  }
73
188
 
189
+ /**
190
+ * /update — pull, reinstall, run setup, restart. Only reachable on developer
191
+ * builds running from a git checkout; the command is not registered at all
192
+ * otherwise (see buildCommandDefinitions).
193
+ */
194
+ export async function handleUpdate(
195
+ i: ChatInputCommandInteraction,
196
+ config: TalonConfig,
197
+ ): Promise<void> {
198
+ if (!isAdmin(i.user.id)) {
199
+ await reply(i, "Not authorized.", true);
200
+ return;
201
+ }
202
+ const repoRoot = config.devBuild ? getRepoRoot() : null;
203
+ if (!repoRoot) {
204
+ await reply(i, "Update is only available on developer builds.", true);
205
+ return;
206
+ }
207
+ const remote = config.update?.remote ?? "origin";
208
+ const branch = config.update?.branch ?? "main";
209
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
210
+ await i.editReply(`⏳ Updating from \`${remote}/${branch}\`…`);
211
+ const edit = (text: string) => i.editReply(safeSlice(text, DISCORD_MAX_TEXT));
212
+
213
+ // Fire-and-forget so the gateway keeps processing other interactions.
214
+ runSelfUpdate({
215
+ remote,
216
+ branch,
217
+ setup: config.update?.setup,
218
+ repoRoot,
219
+ })
220
+ .then(async (res) => {
221
+ if (!res.ok) {
222
+ const tail = res.steps[res.steps.length - 1]?.output ?? "";
223
+ await edit(
224
+ `⚠️ Update failed: ${res.error ?? "unknown error"}` +
225
+ (tail
226
+ ? `\n\`\`\`\n${escapeForCodeBlock(tail.slice(-1200))}\n\`\`\``
227
+ : ""),
228
+ );
229
+ return;
230
+ }
231
+ if (!res.changed) {
232
+ await edit(
233
+ `✅ Already up to date at \`${res.before ?? "?"}\` — no restart needed.`,
234
+ );
235
+ return;
236
+ }
237
+ await edit(
238
+ `✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`. ♻️ Restarting…`,
239
+ );
240
+ respawnSelf("discord /update");
241
+ })
242
+ .catch(async (err: unknown) => {
243
+ const msg = err instanceof Error ? err.message : String(err);
244
+ await edit(`⚠️ Update crashed: ${msg}`);
245
+ });
246
+ }
247
+
74
248
  export async function handleAdmin(
75
249
  i: ChatInputCommandInteraction,
76
250
  config: TalonConfig,