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
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * /backup — snapshots and checkpoints (admin only), the Discord half.
3
3
  *
4
- * Same surface as Telegram's /backup: status, now, checkpoint, list,
5
- * pin/unpin, restore. Restore is behind a button (`backup:restore:<id>`)
4
+ * Same surface as Telegram's /backup: the panel (bare `/backup` — status
5
+ * with Back up now / Snapshots / How restore works / Refresh buttons, see
6
+ * backup-panel.ts), now, checkpoint, list, pin/unpin, restore. Restore is behind a button (`backup:restore:<id>`)
6
7
  * because it replaces the database, memory and identity of a running
7
8
  * agent — and even then it does not restore in place: the request is
8
9
  * staged to ~/.talon/restore-pending.json and applied by the next boot,
@@ -21,16 +22,23 @@ import {
21
22
  type ChatInputCommandInteraction,
22
23
  } from "discord.js";
23
24
  import {
24
- collectBackupStatus,
25
- formatBackupStatus,
26
25
  formatSnapshotList,
27
26
  isSnapshotId,
28
27
  listSnapshots,
29
28
  readManifest,
30
- runBackup,
31
29
  setSnapshotPinned,
32
30
  writeRestorePending,
33
31
  } from "../../../core/backup/index.js";
32
+ import {
33
+ parseBackupAction,
34
+ renderRestoreConfirm,
35
+ } from "../../presentation/backup-panel.js";
36
+ import { DISCORD_REPORTS } from "../render.js";
37
+ import {
38
+ handleBackupPanelAction,
39
+ replyWithPanel,
40
+ runSnapshotLine,
41
+ } from "./backup-panel.js";
34
42
  import { respawnSelf } from "../../../core/daemon/respawn.js";
35
43
  import { logError } from "../../../util/log.js";
36
44
  import { escapeForCodeBlock } from "../formatting.js";
@@ -60,24 +68,7 @@ async function takeSnapshot(
60
68
  label?: string,
61
69
  ): Promise<void> {
62
70
  await i.deferReply({ flags: MessageFlags.Ephemeral });
63
- try {
64
- const manifest = await runBackup({
65
- kind: label ? "checkpoint" : "backup",
66
- label,
67
- pinned: Boolean(label),
68
- trigger: "command",
69
- });
70
- await i.editReply(
71
- `✅ \`${manifest.id}\` — ${manifest.parts.length} part(s), ` +
72
- `${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
73
- (label ? " (pinned)" : ""),
74
- );
75
- } catch (err) {
76
- logError("backup", "/backup now failed", err);
77
- await i.editReply(
78
- `⚠️ Backup failed: ${err instanceof Error ? err.message : String(err)}`,
79
- );
80
- }
71
+ await i.editReply(await runSnapshotLine(label));
81
72
  }
82
73
 
83
74
  async function askToRestore(
@@ -94,12 +85,7 @@ async function askToRestore(
94
85
  return;
95
86
  }
96
87
  await i.reply({
97
- content:
98
- `♻️ **Restore \`${id}\`?**\n` +
99
- (manifest.label ? `“${manifest.label}”\n` : "") +
100
- `Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
101
- "This replaces config, prompts, keys, sessions, the database and memory, " +
102
- "then restarts. A pinned checkpoint of the current state is taken first.",
88
+ content: renderRestoreConfirm(DISCORD_REPORTS, manifest),
103
89
  components: [confirmRow(id).toJSON()],
104
90
  flags: MessageFlags.Ephemeral,
105
91
  });
@@ -149,11 +135,7 @@ export async function handleBackup(
149
135
  await askToRestore(i, argument);
150
136
  return;
151
137
  default:
152
- await reply(
153
- i,
154
- block(formatBackupStatus(await collectBackupStatus())),
155
- true,
156
- );
138
+ await replyWithPanel(i);
157
139
  }
158
140
  }
159
141
 
@@ -174,6 +156,15 @@ export async function handleBackupComponent(
174
156
  await interaction.update({ content: "Restore cancelled.", components: [] });
175
157
  return true;
176
158
  }
159
+ const panelAction = parseBackupAction(interaction.customId);
160
+ if (
161
+ panelAction &&
162
+ panelAction.kind !== "restore" &&
163
+ panelAction.kind !== "cancel"
164
+ ) {
165
+ await handleBackupPanelAction(interaction, panelAction);
166
+ return true;
167
+ }
177
168
  if (action !== "restore" || !id || !isSnapshotId(id)) return false;
178
169
  await interaction.update({
179
170
  content:
@@ -6,6 +6,7 @@ import {
6
6
  type ChatInputCommandInteraction,
7
7
  type Client,
8
8
  MessageFlags,
9
+ Status,
9
10
  } from "discord.js";
10
11
  import {
11
12
  formatDuration,
@@ -111,12 +112,23 @@ export async function handlePing(
111
112
  const start = Date.now();
112
113
  await i.deferReply({ flags: MessageFlags.Ephemeral });
113
114
  const apiLatency = Date.now() - start;
114
- // client.ws.ping is the WebSocket heartbeat RTT (the real gateway latency).
115
- const wsPing = i.client.ws.ping;
115
+ await i.editReply(renderPingReply(i.client.ws, apiLatency));
116
+ }
117
+
118
+ /**
119
+ * The /ping body. `ws.ping` is the gateway heartbeat RTT (-1 until the
120
+ * first heartbeat is acknowledged); `ws.status` is the live connection
121
+ * state, reported by name when it isn't Ready.
122
+ */
123
+ export function renderPingReply(
124
+ ws: { ping: number; status: Status },
125
+ restLatencyMs: number,
126
+ ): string {
127
+ const wsPing = ws.ping >= 0 ? `${Math.round(ws.ping)}ms` : "n/a";
128
+ const gateway =
129
+ ws.status === Status.Ready ? "✓" : `✗ (${Status[ws.status] ?? ws.status})`;
116
130
  const uptime = formatDuration(process.uptime() * 1000);
117
- await i.editReply(
118
- `Pong! WS: ${wsPing}ms · REST: ${apiLatency}ms\nGateway: ✓ | Uptime: ${uptime}`,
119
- );
131
+ return `Pong! WS: ${wsPing} · REST: ${restLatencyMs}ms\nGateway: ${gateway} | Uptime: ${uptime}`;
120
132
  }
121
133
 
122
134
  export async function handlePlugins(
@@ -169,6 +169,18 @@ export async function handleEffort(
169
169
  config,
170
170
  });
171
171
 
172
+ // Reset first: adaptive needs no model levels, so it must stay
173
+ // reachable on a model that registers none.
174
+ if (level === "adaptive" || level === "reset" || level === "default") {
175
+ setChatEffort(chatId, undefined);
176
+ await reply(
177
+ i,
178
+ "Effort reset to **adaptive** (model decides when to think)",
179
+ true,
180
+ );
181
+ return;
182
+ }
183
+
172
184
  if (reasoning.levels.length === 0) {
173
185
  await reply(
174
186
  i,
@@ -202,15 +214,6 @@ export async function handleEffort(
202
214
  return;
203
215
  }
204
216
 
205
- if (level === "adaptive") {
206
- setChatEffort(chatId, undefined);
207
- await reply(
208
- i,
209
- "Effort reset to **adaptive** (model decides when to think)",
210
- true,
211
- );
212
- return;
213
- }
214
217
  if (supportsReasoningLevel(level, reasoning.levels)) {
215
218
  setChatEffort(chatId, level as EffortLevel);
216
219
  await reply(i, `Effort set to **${level}**`, true);
@@ -46,7 +46,7 @@ export {
46
46
  const DEFAULT_METRICS_MESSAGE_MAX = DISCORD_MAX_TEXT - DISCORD_SAFE_RESERVE;
47
47
 
48
48
  /** Discord markdown: bold/italic/code markers, no escaping, 2000-char messages. */
49
- const DISCORD_REPORTS: ReportFormatter = {
49
+ export const DISCORD_REPORTS: ReportFormatter = {
50
50
  bold: (s) => `**${s}**`,
51
51
  italic: (s) => `_${s}_`,
52
52
  emphasis: (s) => `*${s}*`,
@@ -5,7 +5,8 @@
5
5
  * `id` on /devices/register, `deviceId` on /location and
6
6
  * /devices/command-result. A per-device credential may only ever name its
7
7
  * own device; an unbound pairing/installer credential is bound by the first
8
- * id it names (and refused one another credential already holds). The
8
+ * id it names — re-pairing (revoking) a device that already holds one when
9
+ * the store allows it, see DeviceCredentialStore.bind. The
9
10
  * shared token and an open bridge name whatever they like — that is the
10
11
  * legacy trust model — but a remote shared-token claim is recorded so the
11
12
  * operator can see which devices still need upgrading.
@@ -44,10 +45,9 @@ export function claimDevice(
44
45
  const bind = credentials?.authority.bind(principal.credentialId, claimed);
45
46
  if (!bind || !bind.ok) {
46
47
  const error = bind?.error ?? "Credential cannot be bound";
47
- // The companion only sees a 403; without this line a pairing that
48
- // authenticated fine but could not bind (typically: the device kept its
49
- // id through a reinstall/wipe and its old credential is still live)
50
- // leaves no trace on the daemon side.
48
+ // Callers answer 403 with this error (never 401: the token itself is
49
+ // fine). Without this line a pairing that authenticated but could not
50
+ // bind leaves no trace on the daemon side.
51
51
  logWarn(
52
52
  "native",
53
53
  `bridge.auth event=bind_refused credential=${principal.credentialId} device=${claimed}: ${error}`,
@@ -1,5 +1,6 @@
1
1
  import type { RouteHost } from "./host.js";
2
2
  import { claimDevice } from "../credentials/claims.js";
3
+ import { hasScope } from "../credentials/principal.js";
3
4
  import type { BridgeRoutes, RouteContext } from "./table.js";
4
5
  import {
5
6
  asAttachmentRefs,
@@ -26,6 +27,41 @@ function openEvents(host: RouteHost, ctx: RouteContext): void {
26
27
  host.openStream(res, claim.deviceId, principal);
27
28
  }
28
29
 
30
+ /**
31
+ * POST /send. Text naming one of the daemon's slash commands is answered
32
+ * by the daemon; the operator-only ones need the same scope as
33
+ * POST /control, so the caller's scope rides along.
34
+ */
35
+ async function postSend(host: RouteHost, ctx: RouteContext): Promise<void> {
36
+ const { req, res, principal } = ctx;
37
+ const body = await host.readJson(req);
38
+ const id = asString(body.chatId) ?? "";
39
+ const text = asString(body.text) ?? "";
40
+ // Multi-file clients send `attachments`; the single-image shape older
41
+ // clients send is folded into the same list by the handler.
42
+ const attachments = asAttachmentRefs(body.attachments);
43
+ const imagePath = asString(body.imagePath);
44
+ const attachmentPath = asString(body.attachmentPath);
45
+ const hasAttachment =
46
+ attachments.length > 0 || Boolean(attachmentPath || imagePath);
47
+ // Text may be empty when a file is attached; require one or the other.
48
+ if (!id || (!text.trim() && !hasAttachment)) {
49
+ host.json(res, 400, {
50
+ ok: false,
51
+ error: "chatId and text (or an attachment) required",
52
+ });
53
+ return;
54
+ }
55
+ const operator = principal !== null && hasScope(principal, "operator");
56
+ host.handlers.send(
57
+ id,
58
+ text,
59
+ { attachments, imagePath, attachmentPath },
60
+ { operator },
61
+ );
62
+ host.json(res, 202, { ok: true });
63
+ }
64
+
29
65
  export function chatRoutes(
30
66
  host: RouteHost,
31
67
  ): Pick<
@@ -42,6 +78,7 @@ export function chatRoutes(
42
78
  | "GET /history"
43
79
  | "GET /search"
44
80
  | "POST /send"
81
+ | "GET /commands"
45
82
  | "POST /upload"
46
83
  | "GET /media"
47
84
  > {
@@ -105,26 +142,9 @@ export function chatRoutes(
105
142
  const chatId = url.searchParams.get("chatId") ?? undefined;
106
143
  json(res, 200, { results: h.search(q, chatId) });
107
144
  },
108
- "POST /send": async ({ req, res }) => {
109
- const body = await readJson(req);
110
- const id = asString(body.chatId) ?? "";
111
- const text = asString(body.text) ?? "";
112
- // Multi-file clients send `attachments`; the single-image shape older
113
- // clients send is folded into the same list by the handler.
114
- const attachments = asAttachmentRefs(body.attachments);
115
- const imagePath = asString(body.imagePath);
116
- const attachmentPath = asString(body.attachmentPath);
117
- const hasAttachment =
118
- attachments.length > 0 || Boolean(attachmentPath || imagePath);
119
- // Text may be empty when a file is attached; require one or the other.
120
- if (!id || (!text.trim() && !hasAttachment))
121
- return json(res, 400, {
122
- ok: false,
123
- error: "chatId and text (or an attachment) required",
124
- });
125
- h.send(id, text, { attachments, imagePath, attachmentPath });
126
- json(res, 202, { ok: true });
127
- },
145
+ "POST /send": (ctx) => postSend(host, ctx),
146
+ "GET /commands": ({ res }) =>
147
+ json(res, 200, { commands: h.listCommands() }),
128
148
  "POST /upload": async ({ req, res, url }) => {
129
149
  const filename = url.searchParams.get("filename") ?? "upload";
130
150
  const contentType =
@@ -11,6 +11,7 @@ import type {
11
11
  BridgeEvent,
12
12
  BridgeStatus,
13
13
  ClientChat,
14
+ ClientCommand,
14
15
  ClientMessage,
15
16
  DeviceInfo,
16
17
  DeviceLocation,
@@ -64,8 +65,18 @@ export type BridgeServerHandlers = {
64
65
  listMemory(query: MemoryListQuery): MemoryListResult;
65
66
  /** One memory row plus its audit trail, or null when no such id. */
66
67
  memoryWhy(id: number): MemoryWhyWire | null;
67
- /** Fire-and-forget: streams its results back through `broadcast`. */
68
- send(id: string, text: string, opts?: SendOptions): void;
68
+ /**
69
+ * Fire-and-forget: streams its results back through `broadcast`. Text
70
+ * naming one of the daemon's slash commands is answered by the daemon
71
+ * instead of the model; `caller` says whether the credential may run
72
+ * the operator-only ones (absent = it may not).
73
+ */
74
+ send(
75
+ id: string,
76
+ text: string,
77
+ opts?: SendOptions,
78
+ caller?: { operator: boolean },
79
+ ): void;
69
80
  /**
70
81
  * Stream an uploaded file to disk and return its wire description. The body
71
82
  * is consumed as it arrives (never buffered whole), so the size ceiling is
@@ -112,6 +123,8 @@ export type BridgeServerHandlers = {
112
123
  setSkillEnabled(name: string, enabled: boolean): ToggleResult;
113
124
  /** Fire a daemon-level control action (e.g. "restart", "dream"). */
114
125
  control(action: string): Promise<{ ok: boolean; message: string }>;
126
+ /** The slash commands `/send` answers itself, for client autocomplete. */
127
+ listCommands(): ClientCommand[];
115
128
  /** Newest daemon log entries (for the client's log viewer). */
116
129
  logs(opts: {
117
130
  lines: number;
@@ -67,6 +67,10 @@ export const BRIDGE_ROUTE_AUTH = {
67
67
  "GET /memory/why": "client",
68
68
 
69
69
  "POST /send": "client",
70
+ // The slash commands /send answers itself — names and one-liners for
71
+ // autocomplete. Operator-only ones are listed (flagged `admin`) and
72
+ // refused at /send time for a credential without the scope.
73
+ "GET /commands": "client",
70
74
  "POST /upload": "client",
71
75
  "GET /media": "client",
72
76
  "GET /models": "client",
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Operator commands — `/metrics`, `/doctor`, `/dream`, `/restart`.
3
+ *
4
+ * Gated on the `operator` scope by the dispatcher (see definitions.ts).
5
+ * `/restart` and `/dream` are the same actions the app's Settings screen
6
+ * fires through `POST /control`, so a typed command and a tap take the
7
+ * one code path.
8
+ */
9
+
10
+ import { collectDoctorReport } from "../../../core/doctor/index.js";
11
+ import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
12
+ import {
13
+ renderDoctorReport,
14
+ renderMetricsMessages,
15
+ } from "../../presentation/reports.js";
16
+ import { control } from "../surface/control.js";
17
+ import { NATIVE_REPORTS } from "./format.js";
18
+ import type { NativeCommandContext, NativeCommandHandler } from "./types.js";
19
+
20
+ async function metrics(ctx: NativeCommandContext): Promise<void> {
21
+ const all = ctx.arg.toLowerCase() === "all";
22
+ const parts = renderMetricsMessages(
23
+ all ? getMetrics() : getTodayMetrics(),
24
+ NATIVE_REPORTS,
25
+ undefined,
26
+ all ? "📊 Metrics — all time" : "📊 Metrics — today (UTC)",
27
+ );
28
+ ctx.reply(
29
+ parts.join("\n\n") +
30
+ (all ? "" : "\n\n`/metrics all` for the all-time view."),
31
+ );
32
+ }
33
+
34
+ async function doctor(ctx: NativeCommandContext): Promise<void> {
35
+ try {
36
+ const report = await collectDoctorReport({
37
+ config: ctx.runtime.config,
38
+ hasConfigFile: true,
39
+ });
40
+ ctx.reply(renderDoctorReport(report, NATIVE_REPORTS));
41
+ } catch (err) {
42
+ ctx.reply(
43
+ `🩺 Doctor failed: ${err instanceof Error ? err.message : String(err)}`,
44
+ );
45
+ }
46
+ }
47
+
48
+ async function dream(ctx: NativeCommandContext): Promise<void> {
49
+ const result = await control("dream");
50
+ ctx.reply(result.ok ? `🌙 ${result.message}` : `⚠️ ${result.message}`);
51
+ }
52
+
53
+ async function restart(ctx: NativeCommandContext): Promise<void> {
54
+ // Reply first: the restart takes this process down with it.
55
+ ctx.reply("♻️ Restarting Talon — back online in a few seconds.");
56
+ await control("restart");
57
+ }
58
+
59
+ export const adminCommands = {
60
+ metrics,
61
+ doctor,
62
+ dream,
63
+ restart,
64
+ } satisfies Record<string, NativeCommandHandler>;
@@ -0,0 +1,191 @@
1
+ /**
2
+ * /backup — snapshots and checkpoints (operator only), the native half.
3
+ *
4
+ * /backup status: schedule, sizes, targets
5
+ * /backup now take a snapshot now
6
+ * /backup checkpoint <label> labelled, pinned snapshot
7
+ * /backup list recent snapshots
8
+ * /backup show <id> one snapshot's manifest summary
9
+ * /backup pin|unpin <id> keep past retention, or release
10
+ * /backup restore <id> describe it and ask to confirm
11
+ * /backup restore <id> confirm staged restore + restart
12
+ *
13
+ * Same surface as Telegram's and Discord's /backup, with the confirmation
14
+ * button replaced by a typed `confirm` — the bridge has no callback route
15
+ * for button data. Restore does not happen in place either: the request
16
+ * is staged to ~/.talon/restore-pending.json and applied by the next boot
17
+ * before anything opens the database (core/backup/restore.ts, and
18
+ * `applyStagedRestore` in app.ts), exactly as the other frontends do it.
19
+ */
20
+
21
+ import {
22
+ collectBackupStatus,
23
+ formatBackupStatus,
24
+ formatBytes,
25
+ formatSnapshotList,
26
+ isSnapshotId,
27
+ listSnapshots,
28
+ readManifest,
29
+ runBackup,
30
+ setSnapshotPinned,
31
+ writeRestorePending,
32
+ } from "../../../core/backup/index.js";
33
+ import { respawnSelf } from "../../../core/daemon/respawn.js";
34
+ import { logError } from "../../../util/log.js";
35
+ import { fence } from "./format.js";
36
+ import type { NativeCommandContext } from "./types.js";
37
+
38
+ const USAGE = [
39
+ "**/backup** — snapshots and checkpoints",
40
+ "",
41
+ "`/backup` — status",
42
+ "`/backup now` — take a snapshot",
43
+ "`/backup checkpoint <label>` — labelled, pinned checkpoint",
44
+ "`/backup list` — recent snapshots",
45
+ "`/backup show <id>` — one snapshot",
46
+ "`/backup pin <id>` · `/backup unpin <id>`",
47
+ "`/backup restore <id>` — restore (asks to confirm, then restarts)",
48
+ ].join("\n");
49
+
50
+ const NOT_AN_ID = "That is not a snapshot id — `/backup list` shows them.";
51
+
52
+ function errorText(err: unknown): string {
53
+ return err instanceof Error ? err.message : String(err);
54
+ }
55
+
56
+ async function takeSnapshot(
57
+ ctx: NativeCommandContext,
58
+ label?: string,
59
+ ): Promise<void> {
60
+ ctx.reply(
61
+ label ? `📸 Taking checkpoint “${label}”…` : "📸 Taking a snapshot…",
62
+ );
63
+ try {
64
+ const manifest = await runBackup({
65
+ kind: label ? "checkpoint" : "backup",
66
+ label,
67
+ pinned: Boolean(label),
68
+ trigger: "command",
69
+ });
70
+ ctx.reply(
71
+ `✅ \`${manifest.id}\` — ${manifest.parts.length} part(s), ` +
72
+ `${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
73
+ (label ? " (pinned)" : ""),
74
+ );
75
+ } catch (err) {
76
+ logError("backup", "/backup now failed", err);
77
+ ctx.reply(`⚠️ Backup failed: ${errorText(err)}`);
78
+ }
79
+ }
80
+
81
+ async function show(ctx: NativeCommandContext, id: string): Promise<void> {
82
+ if (!isSnapshotId(id)) return ctx.reply(NOT_AN_ID);
83
+ const manifest = await readManifest(id);
84
+ if (!manifest) return ctx.reply(`No snapshot \`${id}\` on this machine.`);
85
+ const remotes = Object.entries(manifest.remote ?? {});
86
+ ctx.reply(
87
+ [
88
+ `**Snapshot \`${manifest.id}\`**${manifest.pinned ? " 📌" : ""}`,
89
+ ...(manifest.label ? [`“${manifest.label}”`] : []),
90
+ `Kind: ${manifest.kind} · taken ${new Date(manifest.createdAt).toISOString()}`,
91
+ `Host: ${manifest.host} · Talon ${manifest.talonVersion}`,
92
+ `Size: ${formatBytes(manifest.sizeBytes)} in ${manifest.parts.length} part(s)`,
93
+ `Covers: ${manifest.includes.join(", ") || "—"}`,
94
+ ...(remotes.length
95
+ ? [
96
+ `Remotes: ${remotes.map(([name, r]) => `${name} (${r.status})`).join(", ")}`,
97
+ ]
98
+ : []),
99
+ "",
100
+ `\`/backup restore ${manifest.id}\` restores it.`,
101
+ ].join("\n"),
102
+ );
103
+ }
104
+
105
+ async function setPinned(
106
+ ctx: NativeCommandContext,
107
+ id: string,
108
+ pinned: boolean,
109
+ ): Promise<void> {
110
+ if (!isSnapshotId(id)) return ctx.reply(NOT_AN_ID);
111
+ const ok = await setSnapshotPinned(id, pinned);
112
+ ctx.reply(
113
+ ok
114
+ ? `${pinned ? "📌 Pinned" : "Unpinned"} \`${id}\``
115
+ : `No snapshot \`${id}\``,
116
+ );
117
+ }
118
+
119
+ /**
120
+ * `/backup restore <id>` describes the snapshot and asks for the typed
121
+ * confirmation; `/backup restore <id> confirm` stages it and hands off to
122
+ * the successor, which applies it during boot.
123
+ */
124
+ async function restore(
125
+ ctx: NativeCommandContext,
126
+ id: string,
127
+ confirmed: boolean,
128
+ ): Promise<void> {
129
+ if (!isSnapshotId(id)) return ctx.reply(NOT_AN_ID);
130
+ const manifest = await readManifest(id);
131
+ if (!manifest) return ctx.reply(`No snapshot \`${id}\` on this machine.`);
132
+ if (!confirmed) {
133
+ ctx.reply(
134
+ `♻️ **Restore \`${id}\`?**\n` +
135
+ (manifest.label ? `“${manifest.label}”\n` : "") +
136
+ `Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
137
+ "This replaces config, prompts, keys, sessions, the database and memory, " +
138
+ "then restarts. A pinned checkpoint of the current state is taken first.\n\n" +
139
+ `Send \`/backup restore ${id} confirm\` to go ahead.`,
140
+ );
141
+ return;
142
+ }
143
+ try {
144
+ await writeRestorePending({
145
+ id,
146
+ requestedAt: Date.now(),
147
+ requestedBy: ctx.entry.id,
148
+ });
149
+ } catch (err) {
150
+ logError("backup", "Staging the restore failed", err);
151
+ ctx.reply(`⚠️ Could not stage the restore: ${errorText(err)}`);
152
+ return;
153
+ }
154
+ ctx.reply(
155
+ `♻️ Restoring \`${id}\` — restarting now. The restore is applied during ` +
156
+ "boot; the result is reported once Talon is back up.",
157
+ );
158
+ respawnSelf(`native /backup restore ${id}`);
159
+ }
160
+
161
+ /** `/backup <subcommand> [arg]`. */
162
+ export async function backupCommand(ctx: NativeCommandContext): Promise<void> {
163
+ const [subcommand, ...rest] = ctx.arg.split(/\s+/).filter(Boolean);
164
+ const target = rest[0] ?? "";
165
+ switch (subcommand?.toLowerCase()) {
166
+ case undefined:
167
+ case "status":
168
+ return ctx.reply(fence(formatBackupStatus(await collectBackupStatus())));
169
+ case "now":
170
+ return takeSnapshot(ctx);
171
+ case "checkpoint": {
172
+ const label = rest.join(" ").trim();
173
+ if (!label)
174
+ return ctx.reply(
175
+ "Give the checkpoint a label: `/backup checkpoint before the rewrite`",
176
+ );
177
+ return takeSnapshot(ctx, label);
178
+ }
179
+ case "list":
180
+ return ctx.reply(fence(formatSnapshotList(await listSnapshots())));
181
+ case "show":
182
+ return show(ctx, target);
183
+ case "pin":
184
+ case "unpin":
185
+ return setPinned(ctx, target, subcommand.toLowerCase() === "pin");
186
+ case "restore":
187
+ return restore(ctx, target, rest[1]?.toLowerCase() === "confirm");
188
+ default:
189
+ return ctx.reply(USAGE);
190
+ }
191
+ }