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
@@ -69,6 +69,12 @@ export type BindResult =
69
69
 
70
70
  const PERSIST_ALERT = "mesh.credentials.persist";
71
71
 
72
+ /** Operator-minted, bind-on-first-use origins allowed to re-pair a device. */
73
+ const REPAIR_ORIGINS: ReadonlySet<CredentialOrigin> = new Set([
74
+ "pair",
75
+ "install",
76
+ ]);
77
+
72
78
  export class DeviceCredentialStore {
73
79
  private records = new Map<string, DeviceCredentialRecord>();
74
80
  private loading: Promise<void> | null = null;
@@ -146,9 +152,21 @@ export class DeviceCredentialStore {
146
152
  }
147
153
 
148
154
  /**
149
- * Bind an unbound credential to the device id it just named. Refused when
150
- * another live credential already holds that id — a pairing link can add
151
- * a device, never take one over.
155
+ * Bind an unbound credential to the device id it just named.
156
+ *
157
+ * When that id already holds live credentials, an operator-minted pairing
158
+ * or installer credential takes the device over (a re-pair): the old
159
+ * credentials are revoked and their live sessions dropped. This is the
160
+ * "the Mac's app was reset but kept its device id" case, which otherwise
161
+ * needs a `talon mesh revoke` before the new link can work.
162
+ *
163
+ * Trust: every unbound credential is operator-minted (`pair`/`install`,
164
+ * see MeshService → BridgeLinks) and binds exactly once — after this call
165
+ * it is bound, so a replayed link cannot bind again. Takeover is further
166
+ * limited to devices whose live credentials carry no scope the new one
167
+ * lacks: a device-only installer link can re-provision a node, but can
168
+ * never evict (and then impersonate) a companion holding client/operator.
169
+ * A credential of any other origin keeps the old rule — refused.
152
170
  */
153
171
  bind(credentialId: string, deviceId: string): BindResult {
154
172
  const record = this.records.get(credentialId);
@@ -164,19 +182,52 @@ export class DeviceCredentialStore {
164
182
  if (!id || id.length > MAX_DEVICE_ID_CHARS) {
165
183
  return { ok: false, error: "Invalid device id" };
166
184
  }
167
- if (this.activeRecordsFor(id).length > 0) {
168
- return {
169
- ok: false,
170
- error: `Device ${id} already has a credential — revoke it first (talon mesh revoke ${id})`,
171
- };
185
+ const existing = this.activeRecordsFor(id);
186
+ if (existing.length > 0) {
187
+ const refusal = this.takeoverRefusal(record, id, existing);
188
+ if (refusal) return { ok: false, error: refusal };
172
189
  }
173
190
  record.deviceId = id;
174
191
  delete record.expiresAt;
175
192
  log("mesh", `Credential ${record.id} bound to device ${id}`);
176
- this.persistSoon();
193
+ if (existing.length > 0) {
194
+ logWarn(
195
+ "mesh",
196
+ `Credential ${record.id} (${record.origin}) re-paired device ${id}; revoking ${existing.map((r) => r.id).join(", ")}`,
197
+ );
198
+ // Mark revoked synchronously (authenticate must reject them from this
199
+ // instant); revokeRecords drops their sessions and persists.
200
+ void this.revokeRecords(existing, `re-paired by ${record.id}`).catch(
201
+ (err: unknown) =>
202
+ logWarn("mesh", `Could not persist re-pair revocation: ${err}`),
203
+ );
204
+ } else {
205
+ this.persistSoon();
206
+ }
177
207
  return { ok: true, credential: publicView(record) };
178
208
  }
179
209
 
210
+ /** Why `record` may not take over `deviceId` from `existing`, if it may not. */
211
+ private takeoverRefusal(
212
+ record: DeviceCredentialRecord,
213
+ deviceId: string,
214
+ existing: readonly DeviceCredentialRecord[],
215
+ ): string | null {
216
+ const revokeHint = `revoke it first (talon mesh revoke ${deviceId})`;
217
+ if (!REPAIR_ORIGINS.has(record.origin)) {
218
+ return `Device ${deviceId} already has a credential — ${revokeHint}`;
219
+ }
220
+ const held = new Set(record.scopes);
221
+ const wider = existing.flatMap((r) => r.scopes).filter((s) => !held.has(s));
222
+ if (wider.length > 0) {
223
+ return (
224
+ `Device ${deviceId} already has a credential with scopes this link does not carry ` +
225
+ `(${[...new Set(wider)].join(", ")}) — ${revokeHint}`
226
+ );
227
+ }
228
+ return null;
229
+ }
230
+
180
231
  /** True when the operator asked for this credential to be replaced. */
181
232
  rotationDue(credentialId: string): boolean {
182
233
  const record = this.records.get(credentialId);
@@ -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.`,
@@ -11,6 +11,8 @@
11
11
  import { Agent, fetch as undiciFetch } from "undici";
12
12
  import { isBunRuntime } from "../../util/runtime.js";
13
13
  import { gatewayAuthHeaders } from "../engine/gateway-auth.js";
14
+ import { logWarn } from "../../util/log.js";
15
+ import { TalonError } from "../errors.js";
14
16
  import type { BridgeFunction } from "./types.js";
15
17
 
16
18
  /** Default wall-clock budget for a bridge action. */
@@ -64,6 +66,26 @@ function dispatcher(): Agent {
64
66
  /** Built-in fetch's default dispatcher fails headers slower than this. */
65
67
  const BUILTIN_FETCH_HEADERS_CEILING_MS = 300_000;
66
68
 
69
+ /** Best-effort one-line description of an abort's reason / error cause. */
70
+ function describeAbort(err: Error, signal: AbortSignal): string {
71
+ const parts: string[] = [];
72
+ if (err.message) parts.push(err.message);
73
+ const cause = (err as { cause?: unknown }).cause;
74
+ if (cause instanceof Error && cause.message)
75
+ parts.push(`cause: ${cause.name}: ${cause.message}`);
76
+ else if (cause !== undefined && cause !== null)
77
+ parts.push(`cause: ${String(cause)}`);
78
+ if (signal.aborted && signal.reason !== undefined) {
79
+ const r = signal.reason as { name?: string; message?: string };
80
+ const reason =
81
+ r instanceof Error || (r && typeof r === "object" && "name" in r)
82
+ ? `${r.name}: ${r.message ?? ""}`
83
+ : String(signal.reason);
84
+ if (!parts.some((p) => p.includes(reason))) parts.push(`reason: ${reason}`);
85
+ }
86
+ return parts.join("; ") || err.name || "unknown abort";
87
+ }
88
+
67
89
  /**
68
90
  * Create a bridge caller bound to a default URL and chat.
69
91
  *
@@ -93,13 +115,15 @@ export function createBridge(
93
115
  : null;
94
116
  const effectiveChatId = explicitChatId ?? chatId;
95
117
  const timeoutMs = LONG_ACTION_TIMEOUTS_MS[action] ?? DEFAULT_TIMEOUT_MS;
118
+ const signal = AbortSignal.timeout(timeoutMs);
119
+ const startedAt = Date.now();
96
120
  const init = {
97
121
  method: "POST",
98
122
  headers: { "Content-Type": "application/json", ...gatewayAuthHeaders() },
99
123
  // chat_id stays in body when set — gateway uses its presence as the
100
124
  // "explicit routing" signal. _chatId is the routing key either way.
101
125
  body: JSON.stringify({ action, ...params, _chatId: effectiveChatId }),
102
- signal: AbortSignal.timeout(timeoutMs),
126
+ signal,
103
127
  };
104
128
  // Minimal common surface of DOM Response and undici's Response.
105
129
  let resp: {
@@ -123,11 +147,41 @@ export function createBridge(
123
147
  : await fetch(`${bridgeUrl}/action`, init);
124
148
  } catch (err) {
125
149
  const cause = err as Error & { name?: string };
150
+ const elapsedMs = Date.now() - startedAt;
126
151
  if (cause.name === "TimeoutError" || cause.name === "AbortError") {
127
- throw new Error(
128
- `"${action}" did not complete within ${Math.round(timeoutMs / 1000)}s. ` +
152
+ // Only call it a timeout when the budget actually ran out: the
153
+ // deadline signal fired (a TimeoutError, thrown or as the signal's
154
+ // reason) or the full budget elapsed. Anything earlier — a dropped socket, a daemon
155
+ // restart, a cancelled request — is an abort with its own cause,
156
+ // and mislabelling it as "did not complete within 3600s" after
157
+ // 780s sends the diagnosis in the wrong direction.
158
+ const reasonName = (signal.reason as { name?: string } | undefined)
159
+ ?.name;
160
+ const timedOut =
161
+ cause.name === "TimeoutError" ||
162
+ (signal.aborted && reasonName === "TimeoutError") ||
163
+ elapsedMs >= timeoutMs;
164
+ const detail = describeAbort(cause, signal);
165
+ logWarn(
166
+ "bridge",
167
+ `tool bridge "${action}" ${timedOut ? "timed out" : "aborted"} after ${elapsedMs}ms (budget ${timeoutMs}ms): ${detail}`,
168
+ );
169
+ if (timedOut) {
170
+ throw new Error(
171
+ `"${action}" did not complete within ${Math.round(timeoutMs / 1000)}s. ` +
172
+ `The operation may still be running on the daemon — check its ` +
173
+ `outcome (e.g. list the target directory) before retrying.`,
174
+ );
175
+ }
176
+ // Classified as a non-retryable network fault: the transport
177
+ // dropped mid-call, but the daemon may already have acted, so a
178
+ // blind retry could run the action twice.
179
+ throw new TalonError(
180
+ `"${action}" was aborted after ${Math.round(elapsedMs / 1000)}s ` +
181
+ `(${elapsedMs}ms; before its ${Math.round(timeoutMs / 1000)}s budget): ${detail}. ` +
129
182
  `The operation may still be running on the daemon — check its ` +
130
- `outcome (e.g. list the target directory) before retrying.`,
183
+ `outcome before retrying.`,
184
+ { reason: "network", retryable: false, cause },
131
185
  );
132
186
  }
133
187
  throw new Error(
@@ -30,15 +30,15 @@ async function selectEffort(
30
30
  backendId: beId,
31
31
  config,
32
32
  });
33
- if (reasoning.levels.length === 0) {
33
+ // Adaptive needs no model levels — reachable even when the model has none.
34
+ if (level === "adaptive") setChatEffort(chatId, undefined);
35
+ else if (reasoning.levels.length === 0) {
34
36
  await interaction.update({
35
37
  content: "No valid reasoning levels found for this model.",
36
38
  components: [],
37
39
  });
38
40
  return;
39
- }
40
- if (level === "adaptive") setChatEffort(chatId, undefined);
41
- else if (supportsReasoningLevel(level, reasoning.levels))
41
+ } else if (supportsReasoningLevel(level, reasoning.levels))
42
42
  setChatEffort(chatId, level as EffortLevel);
43
43
  else {
44
44
  await interaction.update({
@@ -18,6 +18,8 @@
18
18
  * model:nav:* (pager)
19
19
  * model:backend-select (select menu)
20
20
  * metrics:today | metrics:all
21
+ * backup:panel|now|guide|list:<p>|pin:<p>:<id>|unpin:<p>:<id>|ask:<p>:<id>
22
+ * (the /backup panel, admin only)
21
23
  * backup:restore:<id> | backup:cancel (restore confirmation, admin only)
22
24
  * ai:<id> (AI-generated buttons — forwarded to agent)
23
25
  */
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The /backup panel on Discord — the same views and custom-id grammar as
3
+ * Telegram (frontend/presentation/backup-panel.ts), spelled in Discord
4
+ * markdown and mapped onto button rows.
5
+ *
6
+ * Discord allows five action rows per message, so snapshot pages hold
7
+ * three snapshots (three rows, navigation, Back). The staged restore
8
+ * (`backup:restore:<id>`) and the confirmation's Cancel stay in
9
+ * backup.ts; this module owns every other `backup:*` button, and every
10
+ * one of them re-checks the admin before it runs (see
11
+ * `handleBackupComponent`).
12
+ */
13
+
14
+ import {
15
+ ActionRowBuilder,
16
+ ButtonBuilder,
17
+ ButtonStyle,
18
+ MessageFlags,
19
+ type ChatInputCommandInteraction,
20
+ } from "discord.js";
21
+ import {
22
+ collectBackupStatus,
23
+ listSnapshots,
24
+ readManifest,
25
+ runBackup,
26
+ setSnapshotPinned,
27
+ } from "../../../core/backup/index.js";
28
+ import { logError } from "../../../util/log.js";
29
+ import {
30
+ GUIDE_BUTTONS,
31
+ PANEL_BUTTONS,
32
+ backupPanelView,
33
+ renderRestoreGuide,
34
+ restoreConfirmView,
35
+ snapshotPageView,
36
+ type BackupAction,
37
+ type PanelButton,
38
+ type PanelView,
39
+ } from "../../presentation/backup-panel.js";
40
+ import { DISCORD_REPORTS } from "../render.js";
41
+ import type { ComponentInteraction } from "../callbacks/components/types.js";
42
+
43
+ const DISCORD_PAGE_SIZE = 3;
44
+
45
+ function buttonRows(
46
+ rows: readonly PanelButton[][],
47
+ ): ActionRowBuilder<ButtonBuilder>[] {
48
+ return rows.map((row) =>
49
+ new ActionRowBuilder<ButtonBuilder>().addComponents(
50
+ row.map((button) =>
51
+ new ButtonBuilder()
52
+ .setCustomId(button.data)
53
+ .setLabel(button.label)
54
+ .setStyle(
55
+ button.data.startsWith("backup:restore:")
56
+ ? ButtonStyle.Danger
57
+ : ButtonStyle.Secondary,
58
+ ),
59
+ ),
60
+ ),
61
+ );
62
+ }
63
+
64
+ function payload(view: PanelView) {
65
+ return {
66
+ content: view.text,
67
+ components: buttonRows(view.buttons).map((row) => row.toJSON()),
68
+ allowedMentions: { parse: [] as never[] },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Run one snapshot; the outcome as one markdown line. Never throws. Shared
74
+ * by `/backup now|checkpoint` and the panel's 📸 button.
75
+ */
76
+ export async function runSnapshotLine(label?: string): Promise<string> {
77
+ try {
78
+ const manifest = await runBackup({
79
+ kind: label ? "checkpoint" : "backup",
80
+ label,
81
+ pinned: Boolean(label),
82
+ trigger: "command",
83
+ });
84
+ return (
85
+ `✅ \`${manifest.id}\` — ${manifest.parts.length} part(s), ` +
86
+ `${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
87
+ (label ? " (pinned)" : "")
88
+ );
89
+ } catch (err) {
90
+ logError("backup", "/backup now failed", err);
91
+ return `⚠️ Backup failed: ${err instanceof Error ? err.message : String(err)}`;
92
+ }
93
+ }
94
+
95
+ async function statusView(notice?: string): Promise<PanelView> {
96
+ return backupPanelView(
97
+ DISCORD_REPORTS,
98
+ await collectBackupStatus(),
99
+ Date.now(),
100
+ notice,
101
+ );
102
+ }
103
+
104
+ async function pageView(page: number): Promise<PanelView> {
105
+ return snapshotPageView(
106
+ DISCORD_REPORTS,
107
+ await listSnapshots(),
108
+ page,
109
+ Date.now(),
110
+ DISCORD_PAGE_SIZE,
111
+ );
112
+ }
113
+
114
+ /** Bare `/backup`: the panel, ephemeral, with its buttons. */
115
+ export async function replyWithPanel(
116
+ i: ChatInputCommandInteraction,
117
+ ): Promise<void> {
118
+ await i.reply({
119
+ ...payload(await statusView()),
120
+ flags: MessageFlags.Ephemeral,
121
+ });
122
+ }
123
+
124
+ /** One panel snapshot at a time: a double tap must not take two. */
125
+ let panelSnapshotRunning = false;
126
+
127
+ /** Tests only. */
128
+ export function _resetDiscordBackupPanel(): void {
129
+ panelSnapshotRunning = false;
130
+ }
131
+
132
+ async function backUpNow(interaction: ComponentInteraction): Promise<void> {
133
+ if (panelSnapshotRunning) {
134
+ await interaction.reply({
135
+ content: "A snapshot is already running.",
136
+ flags: MessageFlags.Ephemeral,
137
+ });
138
+ return;
139
+ }
140
+ panelSnapshotRunning = true;
141
+ try {
142
+ await interaction.update({
143
+ content:
144
+ "📸 **Taking a snapshot…**\nBuilding it, then pruning and uploading to any " +
145
+ "targets. This can take a few minutes — this message updates when it is done.",
146
+ components: [],
147
+ });
148
+ const line = await runSnapshotLine();
149
+ const view = await statusView(line).catch((err: unknown) => {
150
+ logError("backup", "Backup panel status read failed", err);
151
+ return { text: line, buttons: PANEL_BUTTONS };
152
+ });
153
+ await interaction.editReply(payload(view));
154
+ } finally {
155
+ panelSnapshotRunning = false;
156
+ }
157
+ }
158
+
159
+ async function viewFor(
160
+ action: Exclude<BackupAction, { kind: "now" | "restore" | "cancel" }>,
161
+ ): Promise<PanelView | null> {
162
+ switch (action.kind) {
163
+ case "panel":
164
+ return statusView();
165
+ case "list":
166
+ return pageView(action.page);
167
+ case "guide":
168
+ return {
169
+ text: renderRestoreGuide(DISCORD_REPORTS),
170
+ buttons: GUIDE_BUTTONS,
171
+ };
172
+ case "pin":
173
+ case "unpin":
174
+ await setSnapshotPinned(action.id, action.kind === "pin");
175
+ return pageView(action.page);
176
+ case "ask": {
177
+ // The confirmation only — the restore is its own, second tap.
178
+ const manifest = await readManifest(action.id);
179
+ return manifest
180
+ ? restoreConfirmView(DISCORD_REPORTS, manifest, action.page)
181
+ : null;
182
+ }
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Every panel button except the staged restore and Cancel. The caller has
188
+ * already checked the admin.
189
+ */
190
+ export async function handleBackupPanelAction(
191
+ interaction: ComponentInteraction,
192
+ action: Exclude<BackupAction, { kind: "restore" | "cancel" }>,
193
+ ): Promise<void> {
194
+ try {
195
+ if (action.kind === "now") {
196
+ await backUpNow(interaction);
197
+ return;
198
+ }
199
+ const view = await viewFor(action);
200
+ if (!view) {
201
+ await interaction.reply({
202
+ content: "That snapshot has no copy on this machine.",
203
+ flags: MessageFlags.Ephemeral,
204
+ });
205
+ return;
206
+ }
207
+ await interaction.update(payload(view));
208
+ } catch (err) {
209
+ logError("backup", `Backup panel action ${action.kind} failed`, err);
210
+ if (!interaction.replied && !interaction.deferred) {
211
+ await interaction
212
+ .reply({
213
+ content: "⚠️ That did not work — see the log.",
214
+ flags: MessageFlags.Ephemeral,
215
+ })
216
+ .catch(() => {});
217
+ }
218
+ }
219
+ }