talon-agent 5.25.1 → 5.26.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.
Files changed (66) 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/codex/auth.ts +31 -2
  5. package/src/backend/codex/constants.ts +21 -7
  6. package/src/backend/codex/discovery.ts +32 -1
  7. package/src/backend/codex/factory.ts +3 -5
  8. package/src/backend/codex/handler/message.ts +7 -7
  9. package/src/backend/codex/models.ts +53 -11
  10. package/src/backend/codex/one-shot.ts +139 -37
  11. package/src/backend/codex/state.ts +11 -0
  12. package/src/core/agents/abort-reason.ts +46 -0
  13. package/src/core/agents/registry.ts +3 -2
  14. package/src/core/background/dream/index.ts +2 -2
  15. package/src/core/background/heartbeat/agent.ts +1 -1
  16. package/src/core/background/isolated-agent.ts +1 -1
  17. package/src/core/backup/index.ts +1 -0
  18. package/src/core/backup/status.ts +30 -1
  19. package/src/core/config/index.ts +8 -0
  20. package/src/core/engine/gateway.ts +5 -0
  21. package/src/core/mesh/credentials/store.ts +60 -9
  22. package/src/core/tools/bridge.ts +58 -4
  23. package/src/frontend/discord/callbacks/components/effort.ts +4 -4
  24. package/src/frontend/discord/callbacks/components/index.ts +2 -0
  25. package/src/frontend/discord/commands/backup-panel.ts +219 -0
  26. package/src/frontend/discord/commands/backup.ts +25 -34
  27. package/src/frontend/discord/commands/info.ts +17 -5
  28. package/src/frontend/discord/commands/settings.ts +12 -9
  29. package/src/frontend/discord/render.ts +1 -1
  30. package/src/frontend/native/bridge/credentials/claims.ts +5 -5
  31. package/src/frontend/native/bridge/routes/chats.ts +40 -20
  32. package/src/frontend/native/bridge/routes/host.ts +15 -2
  33. package/src/frontend/native/bridge/routes/table.ts +4 -0
  34. package/src/frontend/native/commands/admin.ts +64 -0
  35. package/src/frontend/native/commands/backup.ts +191 -0
  36. package/src/frontend/native/commands/definitions.ts +113 -0
  37. package/src/frontend/native/commands/format.ts +24 -0
  38. package/src/frontend/native/commands/index.ts +106 -0
  39. package/src/frontend/native/commands/info.ts +97 -0
  40. package/src/frontend/native/commands/session.ts +130 -0
  41. package/src/frontend/native/commands/types.ts +26 -0
  42. package/src/frontend/native/protocol.ts +18 -0
  43. package/src/frontend/native/surface/handlers.ts +14 -1
  44. package/src/frontend/native/surface/status.ts +9 -1
  45. package/src/frontend/native/turn/emit.ts +32 -1
  46. package/src/frontend/presentation/backup-panel.ts +425 -0
  47. package/src/frontend/presentation/memory-report.ts +109 -0
  48. package/src/frontend/presentation/text-commands.ts +243 -0
  49. package/src/frontend/telegram/admin/sessions.ts +20 -5
  50. package/src/frontend/telegram/admin.ts +9 -2
  51. package/src/frontend/telegram/callbacks/backup.ts +153 -22
  52. package/src/frontend/telegram/callbacks/effort.ts +5 -5
  53. package/src/frontend/telegram/callbacks/index.ts +2 -2
  54. package/src/frontend/telegram/callbacks/settings.ts +3 -40
  55. package/src/frontend/telegram/commands/admin.ts +8 -7
  56. package/src/frontend/telegram/commands/backup.ts +56 -46
  57. package/src/frontend/telegram/commands/index.ts +3 -2
  58. package/src/frontend/telegram/commands/info.ts +45 -26
  59. package/src/frontend/telegram/commands/memory.ts +8 -92
  60. package/src/frontend/telegram/commands/settings.ts +13 -45
  61. package/src/frontend/telegram/commands/whatsapp-pairing.ts +19 -15
  62. package/src/frontend/telegram/render/backup-panel.ts +47 -0
  63. package/src/frontend/telegram/render/menu.ts +21 -30
  64. package/src/frontend/terminal/builtins/model.ts +20 -11
  65. package/src/frontend/whatsapp/commands.ts +16 -216
  66. package/src/frontend/whatsapp/messages/inbound.ts +33 -2
@@ -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
  }
@@ -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);
@@ -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
+ }