talon-agent 5.2.2 → 5.3.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 (51) hide show
  1. package/package.json +1 -1
  2. package/src/app.ts +78 -0
  3. package/src/backend/codex/mcp-config.ts +1 -1
  4. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  5. package/src/backend/runtime/index.ts +1 -1
  6. package/src/cli/commands/backup.ts +396 -0
  7. package/src/cli/events.ts +14 -0
  8. package/src/cli/index.ts +64 -45
  9. package/src/core/backup/archive/digest.ts +77 -0
  10. package/src/core/backup/archive/tar.ts +567 -0
  11. package/src/core/backup/archive/zstd.ts +31 -0
  12. package/src/core/backup/index.ts +54 -0
  13. package/src/core/backup/plan.ts +273 -0
  14. package/src/core/backup/restore.ts +410 -0
  15. package/src/core/backup/scheduler.ts +357 -0
  16. package/src/core/backup/snapshot.ts +408 -0
  17. package/src/core/backup/status.ts +194 -0
  18. package/src/core/backup/store.ts +312 -0
  19. package/src/core/backup/targets.ts +281 -0
  20. package/src/core/backup/types.ts +96 -0
  21. package/src/core/backup/upload.ts +172 -0
  22. package/src/core/bus/events.ts +45 -1
  23. package/src/core/config/index.ts +52 -0
  24. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  25. package/src/core/engine/gateway-actions/index.ts +4 -0
  26. package/src/core/mcp-hub/talon-server.ts +1 -1
  27. package/src/core/plugin/actions.ts +34 -0
  28. package/src/core/plugin/index.ts +5 -1
  29. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  30. package/src/core/tools/index.ts +2 -0
  31. package/src/core/tools/ops/backup.ts +67 -0
  32. package/src/core/tools/types.ts +2 -1
  33. package/src/core/update/self-update.ts +3 -0
  34. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  35. package/src/frontend/discord/commands/backup.ts +203 -0
  36. package/src/frontend/discord/commands/definitions.ts +35 -0
  37. package/src/frontend/discord/commands/router.ts +3 -0
  38. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  39. package/src/frontend/telegram/callbacks/index.ts +8 -0
  40. package/src/frontend/telegram/commands/backup.ts +209 -0
  41. package/src/frontend/telegram/commands/definitions.ts +4 -0
  42. package/src/frontend/telegram/commands/index.ts +2 -0
  43. package/src/storage/backup/index.ts +82 -0
  44. package/src/storage/backup/repo.ts +164 -0
  45. package/src/storage/db.ts +20 -0
  46. package/src/storage/sql/backups.sql +46 -0
  47. package/src/storage/sql/db.sql +8 -0
  48. package/src/storage/sql/schema.sql +30 -0
  49. package/src/storage/sql/statements.generated.ts +60 -1
  50. package/src/util/log.ts +1 -0
  51. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.2.2",
3
+ "version": "5.3.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
package/src/app.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "./core/background/cron/scheduler.js";
28
28
  import { shutdownTriggers } from "./core/background/triggers/index.js";
29
29
  import { shutdownAgents } from "./core/agents/index.js";
30
+ import { stopBackupScheduler } from "./core/backup/index.js";
30
31
  import { pruneSettledTriggers } from "./storage/triggers.js";
31
32
  import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
32
33
  import {
@@ -77,11 +78,48 @@ import {
77
78
  // the successor will run is importable at all. Nothing has booted yet,
78
79
  // so this is both the strongest and the last harmless place to say so.
79
80
  // See core/update/self-update.ts for what a failure does instead.
81
+ //
82
+ // This stays ahead of the staged restore below: the smoke run must not
83
+ // touch state, and a restore is the single most destructive thing this
84
+ // file does.
80
85
  if (process.argv.includes(BOOT_SMOKE_FLAG)) {
81
86
  console.log(BOOT_SMOKE_OK);
82
87
  process.exit(0);
83
88
  }
84
89
 
90
+ /**
91
+ * A `/backup restore <id>` from chat writes ~/.talon/restore-pending.json
92
+ * and restarts. It is applied HERE, before anything else: every store
93
+ * below opens the database, and the database is one of the files this is
94
+ * about to replace. The pre-restore checkpoint inside needs the current
95
+ * database, so the handle is closed between the two — which is why the
96
+ * composition root, and not core/backup, owns that call.
97
+ *
98
+ * Never throws: a failed restore still boots the daemon (with the reason
99
+ * in the log and the request deleted, so the next boot is normal).
100
+ */
101
+ async function applyStagedRestore(): Promise<string | null> {
102
+ const { applyPendingRestore, readRestorePending } =
103
+ await import("./core/backup/index.js");
104
+ if (!(await readRestorePending())) return null;
105
+ const { loadConfig } = await import("./core/config/index.js");
106
+ const { resolveBackupSettings } = await import("./core/backup/plan.js");
107
+ const { closeDatabase } = await import("./storage/db.js");
108
+ const report = await applyPendingRestore({
109
+ settings: resolveBackupSettings(loadConfig().backup),
110
+ beforeApply: closeDatabase,
111
+ });
112
+ if (!report) return null;
113
+ return (
114
+ `♻️ Restored snapshot ${report.id}` +
115
+ (report.checkpointId
116
+ ? ` (previous state saved as checkpoint ${report.checkpointId})`
117
+ : "")
118
+ );
119
+ }
120
+
121
+ const restoreReport = await bootPhase("staged restore", applyStagedRestore);
122
+
85
123
  const { config } = await bootPhase("bootstrap", () => bootstrap());
86
124
 
87
125
  // Record this process as the daemon. The gateway port is appended once
@@ -131,6 +169,36 @@ onBackendChange((holder, newBackend, info) => {
131
169
 
132
170
  // ── Graceful shutdown ────────────────────────────────────────────────────────
133
171
 
172
+ /**
173
+ * Arm the backup scheduler. Here rather than in bootstrap because this is
174
+ * where the process lifecycle lives — `stopBackupScheduler` is two screens
175
+ * down in `gracefulShutdown`, and the two belong together. Failure
176
+ * notifications go to `backup.notifyChatId` when set, otherwise to the
177
+ * admin, over the same route as the plan alerts.
178
+ */
179
+ async function startBackups(): Promise<void> {
180
+ const { initBackup } = await import("./core/backup/index.js");
181
+ const { resolveBackupSettings } = await import("./core/backup/plan.js");
182
+ const notifyChatId = config.backup?.notifyChatId;
183
+ await initBackup({
184
+ settings: resolveBackupSettings(config.backup),
185
+ notify: notifyChatId
186
+ ? async (text: string) => {
187
+ const { resolveFrontendIdAmong } =
188
+ await import("./core/frontend-runtime/routing.js");
189
+ const name = resolveFrontendIdAmong(
190
+ notifyChatId,
191
+ frontends.map((frontend) => frontend.name),
192
+ );
193
+ const target =
194
+ frontends.find((frontend) => frontend.name === name) ??
195
+ frontends[0];
196
+ if (target) await target.sendMessage(Number(notifyChatId), text);
197
+ }
198
+ : undefined,
199
+ });
200
+ }
201
+
134
202
  let shuttingDown = false;
135
203
  let triggerPruneTimer: ReturnType<typeof setInterval> | null = null;
136
204
 
@@ -230,6 +298,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
230
298
  });
231
299
  }
232
300
  await shutdownStep("fuse layer", unmountNamespaceFs);
301
+ await shutdownStep("backup scheduler", stopBackupScheduler);
233
302
  await shutdownStep("pulse timer", stopPulseTimer);
234
303
  await shutdownStep("heartbeat", async () => {
235
304
  stopHeartbeatTimer();
@@ -298,6 +367,7 @@ async function main(): Promise<void> {
298
367
  await Promise.all(frontends.map((frontend) => frontend.init()));
299
368
  log("bot", "Starting Talon...");
300
369
 
370
+ await startBackups();
301
371
  if (config.pulse) startPulseTimer(config.pulseIntervalMs);
302
372
  if (config.heartbeat) startHeartbeatTimer(config.heartbeatIntervalMinutes);
303
373
  startWatchdog(config.workspace);
@@ -345,6 +415,14 @@ async function main(): Promise<void> {
345
415
  // Phase 0 accounting (docs/ts-migration-plan.md): the boot is over the
346
416
  // moment the frontends are listening, so the totals are folded into the
347
417
  // metrics store here, from the same uptime figure the log line prints.
418
+ // A restore applied at boot happened before any frontend existed, so the
419
+ // operator hears about it here, on the first channel that can carry it.
420
+ if (restoreReport) {
421
+ const { notifyAdmin } =
422
+ await import("./core/frontend-runtime/admin-notify.js");
423
+ await notifyAdmin(restoreReport);
424
+ }
425
+
348
426
  const bootMs = Math.round(process.uptime() * 1000);
349
427
  recordBootMetrics(bootMs);
350
428
  startResourceSampler();
@@ -29,7 +29,7 @@ import {
29
29
  pluginHubUrl,
30
30
  hubPluginServerNames,
31
31
  } from "../../core/mcp-hub/index.js";
32
- import type { ToolExclusionConfig } from "../../core/tools/ops/mcp-env.js";
32
+ import type { ToolExclusionConfig } from "../../core/tools/mcp-env.js";
33
33
  import { frontendsForChat } from "../runtime/frontends.js";
34
34
 
35
35
  /**
@@ -30,7 +30,7 @@ import {
30
30
  pluginHubUrl,
31
31
  hubPluginServerNames,
32
32
  } from "../../core/mcp-hub/index.js";
33
- import type { ToolExclusionConfig } from "../../core/tools/ops/mcp-env.js";
33
+ import type { ToolExclusionConfig } from "../../core/tools/mcp-env.js";
34
34
  import { frontendsForChat } from "../runtime/frontends.js";
35
35
  import { log, logWarn } from "../../util/log.js";
36
36
 
@@ -37,7 +37,7 @@
37
37
  * - SDK-specific event types — those live in each backend.
38
38
  * - Session storage — that's `src/storage/sessions.ts`.
39
39
  * - MCP server registration — backend-specific transport details
40
- * (the spawn/env contract they share is `core/tools/ops/mcp-env.ts`).
40
+ * (the spawn/env contract they share is `core/tools/mcp-env.ts`).
41
41
  */
42
42
 
43
43
  export { captureDeliveredText } from "./turn/delivered-text.js";
@@ -0,0 +1,396 @@
1
+ /**
2
+ * `talon backup` — snapshots and checkpoints from the outside.
3
+ *
4
+ * Two execution modes, chosen per subcommand:
5
+ *
6
+ * - Through the daemon (`now`, `targets`, `status`) when one is
7
+ * running. The daemon has the plugins loaded, so it is the only
8
+ * process that can see the remote targets — and routing through it
9
+ * also means one writer, not two racing `VACUUM INTO`s.
10
+ * - In this process (`list`, `show`, `pin`, `unpin`, `prune`, and
11
+ * `now` with no daemon) — they read the manifests on disk and the
12
+ * index in talon.db, both of which are safe to touch concurrently.
13
+ *
14
+ * `restore` refuses while the daemon is running, full stop: it replaces
15
+ * the database and the memory under a live process. Stop Talon first, or
16
+ * use `/backup restore <id>` from chat, which stages the request and
17
+ * lets the next boot apply it before anything opens the database.
18
+ *
19
+ * Lives in `commands/` because that is where docs/structure.md's worklist
20
+ * item 8 puts every CLI command; `src/cli/` itself is at its file ceiling.
21
+ */
22
+
23
+ import pc from "picocolors";
24
+ import { createInterface } from "node:readline/promises";
25
+ import { getFrontends, loadConfig } from "../../core/config/index.js";
26
+ import { findRunningInstance } from "../../core/daemon/discovery.js";
27
+ import {
28
+ collectBackupStatus,
29
+ formatBackupStatus,
30
+ formatBytes,
31
+ formatRelative,
32
+ isSnapshotId,
33
+ listSnapshots,
34
+ discoverTargets,
35
+ readManifest,
36
+ restoreSnapshot,
37
+ setSnapshotPinned,
38
+ type BackupTarget,
39
+ type SnapshotSummary,
40
+ } from "../../core/backup/index.js";
41
+ import { resolveBackupSettings } from "../../core/backup/plan.js";
42
+ import { buildSnapshot } from "../../core/backup/snapshot.js";
43
+ import { pruneLocal } from "../../core/backup/store.js";
44
+ import { fetchGateway } from "../daemon-api.js";
45
+
46
+ const USAGE = `
47
+ ${pc.bold("talon backup")} — snapshots and checkpoints
48
+
49
+ ${pc.cyan("now")} [--checkpoint <label>] [--pin] take a snapshot now
50
+ ${pc.cyan("list")} [--remote <targetId>] list snapshots
51
+ ${pc.cyan("show")} <id> everything about one snapshot
52
+ ${pc.cyan("pin")} <id> | ${pc.cyan("unpin")} <id> keep past retention, or release
53
+ ${pc.cyan("restore")} <id> [--from <target>] [--yes] restore (daemon must be stopped)
54
+ ${pc.cyan("prune")} apply the local retention policy
55
+ ${pc.cyan("targets")} remote targets and their readiness
56
+ ${pc.cyan("status")} schedule, sizes, targets
57
+ `;
58
+
59
+ type Flags = { values: string[]; flags: Map<string, string | true> };
60
+
61
+ /** `--from drive --yes x` → { values: ["x"], flags: { from: "drive", yes: true } } */
62
+ function parseArgs(argv: readonly string[]): Flags {
63
+ const values: string[] = [];
64
+ const flags = new Map<string, string | true>();
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const arg = argv[i];
67
+ if (!arg.startsWith("--")) {
68
+ values.push(arg);
69
+ continue;
70
+ }
71
+ const name = arg.slice(2);
72
+ const next = argv[i + 1];
73
+ if (next && !next.startsWith("--")) {
74
+ flags.set(name, next);
75
+ i++;
76
+ } else {
77
+ flags.set(name, true);
78
+ }
79
+ }
80
+ return { values, flags };
81
+ }
82
+
83
+ async function daemonPort(): Promise<number | null> {
84
+ const instance = await findRunningInstance();
85
+ return instance?.port ?? null;
86
+ }
87
+
88
+ /** Run one action inside the daemon. Returns null when there is no daemon. */
89
+ async function viaDaemon(
90
+ body: Record<string, unknown>,
91
+ ): Promise<{ ok: boolean; text?: string; error?: string } | null> {
92
+ const port = await daemonPort();
93
+ if (port === null) return null;
94
+ // A snapshot of a large workspace can take minutes; the default CLI
95
+ // budget is three seconds.
96
+ return (await fetchGateway(
97
+ port,
98
+ "/action",
99
+ {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify(body),
103
+ },
104
+ 15 * 60_000,
105
+ )) as { ok: boolean; text?: string; error?: string };
106
+ }
107
+
108
+ function renderResult(result: {
109
+ ok: boolean;
110
+ text?: string;
111
+ error?: string;
112
+ }): void {
113
+ if (result.ok) console.log(` ${result.text ?? "Done."}\n`);
114
+ else console.log(` ${pc.red("●")} ${result.error ?? "Failed."}\n`);
115
+ }
116
+
117
+ function renderRow(snapshot: SnapshotSummary): string {
118
+ const marks = [
119
+ snapshot.kind === "checkpoint"
120
+ ? pc.magenta("checkpoint")
121
+ : pc.dim("backup"),
122
+ snapshot.pinned ? pc.yellow("pinned") : "",
123
+ snapshot.local ? "" : pc.dim("(remote only)"),
124
+ ].filter(Boolean);
125
+ const remote = Object.entries(snapshot.remote)
126
+ .map(([id, entry]) =>
127
+ entry.status === "uploaded"
128
+ ? pc.green(id)
129
+ : pc.yellow(`${id}:${entry.status}`),
130
+ )
131
+ .join(" ");
132
+ return (
133
+ ` ${pc.bold(snapshot.id)} ${marks.join(" ")} ${formatBytes(snapshot.sizeBytes).padStart(9)}` +
134
+ ` ${pc.dim(formatRelative(snapshot.createdAt))}` +
135
+ (snapshot.label ? ` ${snapshot.label}` : "") +
136
+ (remote ? ` → ${remote}` : "")
137
+ );
138
+ }
139
+
140
+ // ── Subcommands ─────────────────────────────────────────────────────────────
141
+
142
+ async function backupNow(flags: Flags): Promise<void> {
143
+ const label =
144
+ typeof flags.flags.get("checkpoint") === "string"
145
+ ? String(flags.flags.get("checkpoint"))
146
+ : undefined;
147
+ const pin = flags.flags.get("pin") === true;
148
+ const remote = await viaDaemon({ action: "backup.now", label, pin });
149
+ if (remote) {
150
+ renderResult(remote);
151
+ return;
152
+ }
153
+ console.log(
154
+ ` ${pc.dim("Talon is not running — taking a local snapshot (no remote upload).")}`,
155
+ );
156
+ const settings = resolveBackupSettings(loadConfig().backup);
157
+ const manifest = await buildSnapshot({
158
+ kind: label ? "checkpoint" : "backup",
159
+ label,
160
+ pinned: pin,
161
+ settings,
162
+ });
163
+ await pruneLocal(settings.keepLocal);
164
+ console.log(
165
+ ` ${pc.green("●")} ${manifest.id} — ${manifest.parts.length} part(s), ` +
166
+ `${formatBytes(manifest.sizeBytes)}\n`,
167
+ );
168
+ }
169
+
170
+ async function backupList(flags: Flags): Promise<void> {
171
+ const target = flags.flags.get("remote");
172
+ const snapshots = await listSnapshots();
173
+ const filtered =
174
+ typeof target === "string"
175
+ ? snapshots.filter(
176
+ (snapshot) => snapshot.remote[target]?.status === "uploaded",
177
+ )
178
+ : snapshots;
179
+ if (filtered.length === 0) {
180
+ console.log(` ${pc.dim("No snapshots.")}\n`);
181
+ return;
182
+ }
183
+ console.log("");
184
+ for (const snapshot of filtered) console.log(renderRow(snapshot));
185
+ const bytes = filtered.reduce((sum, snapshot) => sum + snapshot.sizeBytes, 0);
186
+ console.log(
187
+ `\n ${pc.dim(`${filtered.length} snapshot(s), ${formatBytes(bytes)}`)}\n`,
188
+ );
189
+ }
190
+
191
+ async function backupShow(id: string): Promise<void> {
192
+ const manifest = await readManifest(id);
193
+ if (!manifest) {
194
+ console.log(` ${pc.red("●")} No snapshot ${id} on this machine.\n`);
195
+ return;
196
+ }
197
+ console.log(
198
+ `\n ${pc.bold(manifest.id)} ${manifest.kind}${manifest.pinned ? pc.yellow(" pinned") : ""}`,
199
+ );
200
+ if (manifest.label) console.log(` ${manifest.label}`);
201
+ console.log(
202
+ ` taken ${new Date(manifest.createdAt).toISOString()} (${formatRelative(manifest.createdAt)})`,
203
+ );
204
+ console.log(
205
+ ` host ${manifest.host} talon ${manifest.talonVersion}${manifest.gitHead ? ` @ ${manifest.gitHead}` : ""}`,
206
+ );
207
+ console.log(` size ${formatBytes(manifest.sizeBytes)}`);
208
+ console.log(
209
+ ` parts ${manifest.parts.map((part) => `${part.name} (${formatBytes(part.bytes)})`).join(", ")}`,
210
+ );
211
+ console.log(` includes ${manifest.includes.join(", ")}`);
212
+ if (manifest.extras?.length) {
213
+ console.log(
214
+ ` extras ${manifest.extras.map((extra) => `extra/${extra.n} ← ${extra.source}`).join(", ")}`,
215
+ );
216
+ }
217
+ for (const [targetId, state] of Object.entries(manifest.remote)) {
218
+ console.log(
219
+ ` remote ${targetId}: ${state.status}${state.error ? ` — ${state.error}` : ""}`,
220
+ );
221
+ }
222
+ console.log("");
223
+ }
224
+
225
+ async function backupPin(id: string, pinned: boolean): Promise<void> {
226
+ const ok = await setSnapshotPinned(id, pinned);
227
+ console.log(
228
+ ok
229
+ ? ` ${pc.green("●")} ${id} is ${pinned ? "pinned — retention will never prune it" : "unpinned"}\n`
230
+ : ` ${pc.red("●")} No snapshot ${id}\n`,
231
+ );
232
+ }
233
+
234
+ async function backupPrune(): Promise<void> {
235
+ const settings = resolveBackupSettings(loadConfig().backup);
236
+ const removed = await pruneLocal(settings.keepLocal);
237
+ console.log(
238
+ removed.length === 0
239
+ ? ` ${pc.dim(`Nothing to prune (keepLocal=${settings.keepLocal}).`)}\n`
240
+ : ` ${pc.green("●")} Pruned ${removed.length}: ${removed.join(", ")}\n`,
241
+ );
242
+ }
243
+
244
+ async function backupTargets(): Promise<void> {
245
+ const result = await viaDaemon({ action: "backup.status" });
246
+ if (!result) {
247
+ console.log(
248
+ ` ${pc.yellow("●")} Talon is not running — remote targets live in the daemon's plugins.\n`,
249
+ );
250
+ return;
251
+ }
252
+ renderResult(result);
253
+ }
254
+
255
+ async function backupStatus(): Promise<void> {
256
+ const result = await viaDaemon({ action: "backup.status" });
257
+ if (result) {
258
+ renderResult(result);
259
+ return;
260
+ }
261
+ const status = await collectBackupStatus({ withTargets: false });
262
+ console.log(
263
+ `\n${formatBackupStatus(status)
264
+ .split("\n")
265
+ .map((line) => ` ${line}`)
266
+ .join("\n")}\n`,
267
+ );
268
+ }
269
+
270
+ async function confirm(question: string): Promise<boolean> {
271
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
272
+ try {
273
+ return (await rl.question(question)).trim().toLowerCase() === "yes";
274
+ } finally {
275
+ rl.close();
276
+ }
277
+ }
278
+
279
+ async function backupRestore(id: string, flags: Flags): Promise<void> {
280
+ if (!isSnapshotId(id)) {
281
+ console.log(` ${pc.red("●")} "${id}" is not a snapshot id.\n`);
282
+ return;
283
+ }
284
+ const instance = await findRunningInstance();
285
+ if (instance) {
286
+ console.log(
287
+ ` ${pc.red("●")} Talon is running (PID ${instance.pid}). A restore replaces the\n` +
288
+ ` database and memory underneath it. Run ${pc.cyan("talon stop")} first, or use\n` +
289
+ ` ${pc.cyan(`/backup restore ${id}`)} from chat — that stages the restore and applies\n` +
290
+ ` it during the next boot.\n`,
291
+ );
292
+ process.exitCode = 1;
293
+ return;
294
+ }
295
+ const manifest = await readManifest(id);
296
+ const from = flags.flags.get("from");
297
+ if (!manifest && typeof from !== "string") {
298
+ console.log(
299
+ ` ${pc.red("●")} No snapshot ${id} on this machine (use --from <target> to fetch it).\n`,
300
+ );
301
+ process.exitCode = 1;
302
+ return;
303
+ }
304
+ if (flags.flags.get("yes") !== true) {
305
+ console.log(
306
+ `\n This replaces config, prompts, keys, sessions, the database and memory\n` +
307
+ ` with the contents of ${pc.bold(id)}${manifest?.label ? ` ("${manifest.label}")` : ""}.\n` +
308
+ ` A pinned checkpoint of the current state is taken first.\n`,
309
+ );
310
+ if (!(await confirm(` Type ${pc.bold("yes")} to continue: `))) {
311
+ console.log(` ${pc.dim("Cancelled.")}\n`);
312
+ return;
313
+ }
314
+ }
315
+ const settings = resolveBackupSettings(loadConfig().backup);
316
+ const target =
317
+ typeof from === "string" ? await resolveTarget(from) : undefined;
318
+ const report = await restoreSnapshot({ id, settings, target });
319
+ const written = Object.entries(report.written)
320
+ .map(([root, count]) => `${root} (${count})`)
321
+ .join(", ");
322
+ console.log(`\n ${pc.green("●")} Restored ${report.id}`);
323
+ if (report.checkpointId) {
324
+ console.log(
325
+ ` Previous state saved as ${pc.bold(report.checkpointId)} (pinned).`,
326
+ );
327
+ }
328
+ console.log(` Written: ${written || "nothing"}`);
329
+ console.log(` Removed: ${report.removed} file(s)`);
330
+ console.log(
331
+ ` Database: ${report.databaseReplaced ? "replaced" : "left in place"}\n`,
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Find a target by id. Targets are plugins, and a restore runs with the
337
+ * daemon stopped, so this process loads them itself — the same call
338
+ * bootstrap makes, minus the frontends.
339
+ */
340
+ async function resolveTarget(id: string): Promise<BackupTarget | undefined> {
341
+ const config = loadConfig();
342
+ const { loadPlugins } = await import("../../core/plugin/index.js");
343
+ await loadPlugins(config.plugins, getFrontends(config));
344
+ const targets = await discoverTargets();
345
+ const target = targets.find((candidate) => candidate.id === id);
346
+ if (!target) {
347
+ console.log(
348
+ ` ${pc.red("●")} No backup target "${id}"` +
349
+ (targets.length > 0
350
+ ? ` — loaded: ${targets.map((t) => t.id).join(", ")}\n`
351
+ : " — no target plugins are configured.\n"),
352
+ );
353
+ }
354
+ return target;
355
+ }
356
+
357
+ // ── Entry ───────────────────────────────────────────────────────────────────
358
+
359
+ export async function runBackupCommand(argv: readonly string[]): Promise<void> {
360
+ const { values, flags } = parseArgs(argv);
361
+ const [subcommand, id] = values;
362
+ const args: Flags = { values, flags };
363
+ switch (subcommand) {
364
+ case "now":
365
+ await backupNow(args);
366
+ return;
367
+ case "list":
368
+ await backupList(args);
369
+ return;
370
+ case "show":
371
+ if (!id) return void console.log(USAGE);
372
+ await backupShow(id);
373
+ return;
374
+ case "pin":
375
+ case "unpin":
376
+ if (!id) return void console.log(USAGE);
377
+ await backupPin(id, subcommand === "pin");
378
+ return;
379
+ case "restore":
380
+ if (!id) return void console.log(USAGE);
381
+ await backupRestore(id, args);
382
+ return;
383
+ case "prune":
384
+ await backupPrune();
385
+ return;
386
+ case "targets":
387
+ await backupTargets();
388
+ return;
389
+ case "status":
390
+ case undefined:
391
+ await backupStatus();
392
+ return;
393
+ default:
394
+ console.log(USAGE);
395
+ }
396
+ }
package/src/cli/events.ts CHANGED
@@ -44,6 +44,20 @@ function describe(event: TalonEvent): string {
44
44
  return `${event.agentId} "${event.label}" → ${event.state} (${event.durationMs}ms)`;
45
45
  case "agent.message":
46
46
  return `${event.from} → ${event.to} (${event.kind})`;
47
+ case "backup.started":
48
+ return `${event.kind} (${event.trigger})`;
49
+ case "backup.completed":
50
+ return (
51
+ `${event.snapshotId} ${event.kind} ${event.parts} part(s) ` +
52
+ `${(event.sizeBytes / 1024 / 1024).toFixed(1)}MB (${event.durationMs}ms)`
53
+ );
54
+ case "backup.failed":
55
+ return `${event.trigger} failed (streak ${event.consecutiveFailures}): ${event.error}`;
56
+ case "backup.uploaded":
57
+ return (
58
+ `${event.snapshotId} → ${event.targetId} ` +
59
+ `${(event.bytes / 1024 / 1024).toFixed(1)}MB${event.deduplicated ? " (deduplicated)" : ""}`
60
+ );
47
61
  }
48
62
  }
49
63