talon-agent 5.2.1 → 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 (58) hide show
  1. package/package.json +2 -2
  2. package/src/app.ts +94 -1
  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/daemon/handoff.ts +192 -0
  25. package/src/core/daemon/respawn.ts +127 -52
  26. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  27. package/src/core/engine/gateway-actions/index.ts +4 -0
  28. package/src/core/mcp-hub/talon-server.ts +1 -1
  29. package/src/core/plugin/actions.ts +34 -0
  30. package/src/core/plugin/index.ts +5 -1
  31. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  32. package/src/core/tools/index.ts +2 -0
  33. package/src/core/tools/ops/backup.ts +67 -0
  34. package/src/core/tools/types.ts +2 -1
  35. package/src/core/update/self-update.ts +47 -0
  36. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  37. package/src/frontend/discord/commands/backup.ts +203 -0
  38. package/src/frontend/discord/commands/definitions.ts +35 -0
  39. package/src/frontend/discord/commands/router.ts +3 -0
  40. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  41. package/src/frontend/telegram/callbacks/index.ts +8 -0
  42. package/src/frontend/telegram/commands/backup.ts +209 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +2 -0
  45. package/src/index.ts +13 -5
  46. package/src/plugins/playwright/index.ts +39 -3
  47. package/src/plugins/playwright/provision.ts +21 -0
  48. package/src/plugins/playwright/version-coupling.ts +195 -0
  49. package/src/storage/backup/index.ts +82 -0
  50. package/src/storage/backup/repo.ts +164 -0
  51. package/src/storage/db.ts +20 -0
  52. package/src/storage/sql/backups.sql +46 -0
  53. package/src/storage/sql/db.sql +8 -0
  54. package/src/storage/sql/schema.sql +30 -0
  55. package/src/storage/sql/statements.generated.ts +60 -1
  56. package/src/util/log.ts +129 -81
  57. package/src/util/paths.ts +5 -0
  58. /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.1",
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",
@@ -110,7 +110,7 @@
110
110
  "@openai/agents": "^0.18.0",
111
111
  "@openai/codex-sdk": "^0.154.0",
112
112
  "@opencode-ai/sdk": "^1.17.4",
113
- "@playwright/mcp": "0.0.81",
113
+ "@playwright/mcp": "0.0.56",
114
114
  "@types/cross-spawn": "^6.0.6",
115
115
  "@types/qrcode": "^1.5.6",
116
116
  "baileys": "^7.0.0-rc14",
package/src/app.ts CHANGED
@@ -27,9 +27,14 @@ 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
- import { spawnSuccessor } from "./core/daemon/respawn.js";
33
+ import {
34
+ BOOT_SMOKE_FLAG,
35
+ BOOT_SMOKE_OK,
36
+ spawnSuccessor,
37
+ } from "./core/daemon/respawn.js";
33
38
  import {
34
39
  crashCleanup,
35
40
  crashStep,
@@ -67,6 +72,54 @@ import {
67
72
  stopResourceSampler,
68
73
  } from "./core/daemon/resource-sampler.js";
69
74
 
75
+ // `/update` runs the freshly installed tree with --boot-smoke before it
76
+ // hands off. Every static import above has just been resolved against
77
+ // the new node_modules — reaching this line is the proof that the tree
78
+ // the successor will run is importable at all. Nothing has booted yet,
79
+ // so this is both the strongest and the last harmless place to say so.
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.
85
+ if (process.argv.includes(BOOT_SMOKE_FLAG)) {
86
+ console.log(BOOT_SMOKE_OK);
87
+ process.exit(0);
88
+ }
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
+
70
123
  const { config } = await bootPhase("bootstrap", () => bootstrap());
71
124
 
72
125
  // Record this process as the daemon. The gateway port is appended once
@@ -116,6 +169,36 @@ onBackendChange((holder, newBackend, info) => {
116
169
 
117
170
  // ── Graceful shutdown ────────────────────────────────────────────────────────
118
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
+
119
202
  let shuttingDown = false;
120
203
  let triggerPruneTimer: ReturnType<typeof setInterval> | null = null;
121
204
 
@@ -215,6 +298,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
215
298
  });
216
299
  }
217
300
  await shutdownStep("fuse layer", unmountNamespaceFs);
301
+ await shutdownStep("backup scheduler", stopBackupScheduler);
218
302
  await shutdownStep("pulse timer", stopPulseTimer);
219
303
  await shutdownStep("heartbeat", async () => {
220
304
  stopHeartbeatTimer();
@@ -283,6 +367,7 @@ async function main(): Promise<void> {
283
367
  await Promise.all(frontends.map((frontend) => frontend.init()));
284
368
  log("bot", "Starting Talon...");
285
369
 
370
+ await startBackups();
286
371
  if (config.pulse) startPulseTimer(config.pulseIntervalMs);
287
372
  if (config.heartbeat) startHeartbeatTimer(config.heartbeatIntervalMinutes);
288
373
  startWatchdog(config.workspace);
@@ -330,6 +415,14 @@ async function main(): Promise<void> {
330
415
  // Phase 0 accounting (docs/ts-migration-plan.md): the boot is over the
331
416
  // moment the frontends are listening, so the totals are folded into the
332
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
+
333
426
  const bootMs = Math.round(process.uptime() * 1000);
334
427
  recordBootMetrics(bootMs);
335
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