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
@@ -1,38 +1,88 @@
1
1
  /**
2
- * Self-respawn helper for /restart commands across frontends.
2
+ * Self-respawn helper for /restart and /update across frontends.
3
3
  *
4
- * Spawns a fresh copy of the current process — same Node binary,
5
- * same `execArgv` (preserving the tsx loader so `.ts` entrypoints
6
- * still resolve), same script + user args, same cwd + env. The new
7
- * child is detached with stdio:"ignore" so it survives the parent's
8
- * exit; calling `unref()` lets the parent exit without waiting on
9
- * it.
4
+ * Spawns a fresh copy of the current process — same runtime binary, same
5
+ * `execArgv` (preserving a loader so `.ts` entrypoints still resolve),
6
+ * same script + user args, same cwd + env. The new child is detached so
7
+ * it survives the parent's exit; `unref()` lets the parent exit without
8
+ * waiting on it.
10
9
  *
11
- * Why not call the daemon's `talon restart` CLI? That path assumed
12
- * the bot was started via the daemon (talon.pid managed by
13
- * `daemonStart()`) and broke for anything else — `npm start`, `npx
14
- * tsx src/index.ts`, systemd, foreman, pm2, or running under a
15
- * debugger. Respawning from our own `process.argv` works regardless
16
- * of launch method.
10
+ * Why not call the daemon's `talon restart` CLI? That path assumed the
11
+ * bot was started via the daemon (talon.pid managed by `daemonStart()`)
12
+ * and broke for anything else — `npm start`, `npx tsx src/index.ts`,
13
+ * systemd, foreman, pm2, or running under a debugger. Respawning from
14
+ * our own `process.argv` works regardless of launch method.
17
15
  *
18
- * Ordering matters. `respawnSelf()` only *arms* the handoff and
19
- * raises SIGTERM; the successor is spawned by `spawnSuccessor()` at
20
- * the tail of graceful shutdown, once the frontends have stopped.
21
- * Spawning up-front (the previous behaviour) left the successor
22
- * long-polling `getUpdates` while the outgoing process was still
23
- * draining in-flight queries — up to DRAIN_TIMEOUT_MS of two live
24
- * pollers. Telegram answers only one of them and re-delivers the
25
- * unconfirmed updates to the other, so a restart mid-turn produced
26
- * a 409 Conflict on the way out and duplicate replies on the way in.
27
- * Releasing the poll before the successor binds it removes the
28
- * overlap rather than relying on grammy's 409 retry to paper over it.
16
+ * Ordering matters. `respawnSelf()` only *arms* the handoff and raises
17
+ * SIGTERM; the successor is spawned by `spawnSuccessor()` at the tail of
18
+ * graceful shutdown, once the frontends have stopped. Spawning up-front
19
+ * (the original behaviour) left the successor long-polling `getUpdates`
20
+ * while the outgoing process was still draining in-flight queries — up
21
+ * to DRAIN_TIMEOUT_MS of two live pollers. Telegram answers only one of
22
+ * them and re-delivers the unconfirmed updates to the other, so a
23
+ * restart mid-turn produced a 409 Conflict on the way out and duplicate
24
+ * replies on the way in. Releasing the poll before the successor binds
25
+ * it removes the overlap rather than relying on grammy's 409 retry to
26
+ * paper over it.
27
+ *
28
+ * Two things the 2026-09-18 outage added, both about the fact that the
29
+ * outgoing process is dying and cannot be the one responsible for the
30
+ * outcome:
31
+ *
32
+ * - The successor's stdout and stderr go to ~/.talon/respawn.log, not
33
+ * to "ignore". A successor that dies before its own logger exists —
34
+ * a broken import after a dependency install, a fatal bind, a
35
+ * runtime that aborts — used to leave no trace in any file, on any
36
+ * process. That is precisely what happened: a successor was spawned,
37
+ * lived ~20s, never bound its gateway, and vanished without a line.
38
+ * - A watcher process is spawned alongside it (./handoff.ts). It
39
+ * outlives us, verifies the successor over identity-checked /health
40
+ * within a bounded window, and starts the daemon the way `talon
41
+ * start` does if the successor never comes up. Nothing in the
42
+ * handoff depends on a process that is about to call process.exit().
29
43
  */
30
44
 
31
45
  import { spawn } from "node:child_process";
32
- import { log, logError } from "../../util/log.js";
46
+ import { log, logError, openRespawnLog } from "../../util/log.js";
47
+ import { HANDOFF_WATCH_SUBCOMMAND } from "./handoff.js";
33
48
 
34
49
  let pendingReason: string | null = null;
35
50
 
51
+ /**
52
+ * Argv flag that makes the daemon entry resolve its whole import graph
53
+ * and exit 0 without booting (src/app.ts acts on it before the first
54
+ * bootstrap step). `/update` runs the freshly installed tree with this
55
+ * flag before handing off — see core/update/self-update.ts.
56
+ */
57
+ export const BOOT_SMOKE_FLAG = "--boot-smoke";
58
+
59
+ /** Printed by a successful smoke run; the update step matches on it. */
60
+ export const BOOT_SMOKE_OK = "talon boot-smoke ok";
61
+
62
+ /**
63
+ * A bun-compiled binary embeds its source tree: `process.argv[1]` points
64
+ * into the virtual FS ($bunfs / ~BUN) and has no path on disk, so the
65
+ * binary is re-invoked with no script argument at all.
66
+ */
67
+ function isEmbeddedEntry(entry: string): boolean {
68
+ return entry.includes("$bunfs") || entry.includes("~BUN");
69
+ }
70
+
71
+ /** The exact command that re-runs this process. */
72
+ export function successorCommand(): { cmd: string; args: string[] } {
73
+ return {
74
+ cmd: process.argv[0],
75
+ args: [...process.execArgv, ...process.argv.slice(1)],
76
+ };
77
+ }
78
+
79
+ /** The same runtime + entry, re-invoked with one of our own subcommands. */
80
+ function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
81
+ const entry = process.argv[1] ?? "";
82
+ const prefix = isEmbeddedEntry(entry) ? [] : [...process.execArgv, entry];
83
+ return { cmd: process.argv[0], args: [...prefix, ...extra] };
84
+ }
85
+
36
86
  /**
37
87
  * Arm a respawn and raise SIGTERM on ourselves so the existing
38
88
  * graceful-shutdown path cleanly stops the frontends, flushes state,
@@ -57,46 +107,71 @@ export function respawnRequested(): boolean {
57
107
  return pendingReason !== null;
58
108
  }
59
109
 
110
+ export type SpawnFn = typeof spawn;
111
+
112
+ /**
113
+ * Start the watcher that outlives this process and answers the only
114
+ * question that matters: did the successor actually come up? Never
115
+ * throws — a missing watcher must not cost us the successor itself.
116
+ */
117
+ function spawnHandoffWatcher(
118
+ childPid: number,
119
+ fd: number | null,
120
+ spawnFn: SpawnFn,
121
+ ): void {
122
+ try {
123
+ const { cmd, args } = selfInvocation([
124
+ HANDOFF_WATCH_SUBCOMMAND,
125
+ String(childPid),
126
+ ]);
127
+ const watcher = spawnFn(cmd, args, {
128
+ cwd: process.cwd(),
129
+ detached: true,
130
+ stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
131
+ env: { ...process.env },
132
+ });
133
+ watcher.once("error", (err) => {
134
+ logError("shutdown", "Handoff watcher failed to start", err);
135
+ });
136
+ watcher.unref();
137
+ log("shutdown", `Handoff watcher started (pid ${watcher.pid})`);
138
+ } catch (err) {
139
+ logError("shutdown", "Handoff watcher failed to start", err);
140
+ }
141
+ }
142
+
60
143
  /**
61
- * Spawn the successor process. Called at the end of graceful
62
- * shutdown, after the frontends have stopped — so the incoming
63
- * process binds Telegram's long-poll only once this one has let go
64
- * of it. No-op unless `respawnSelf()` armed a handoff.
144
+ * Spawn the successor process. Called at the end of graceful shutdown,
145
+ * after the frontends have stopped — so the incoming process binds
146
+ * Telegram's long-poll only once this one has let go of it. No-op unless
147
+ * `respawnSelf()` armed a handoff.
65
148
  *
66
149
  * Never throws: a failed handoff must not prevent this process from
67
- * exiting. An external supervisor (systemd, pm2, the user's
68
- * terminal) can pick things up.
150
+ * exiting. The watcher is the safety net, not an external supervisor.
69
151
  */
70
- export function spawnSuccessor(): void {
152
+ export function spawnSuccessor(spawnFn: SpawnFn = spawn): void {
71
153
  if (pendingReason === null) return;
72
154
  const reason = pendingReason;
73
155
  pendingReason = null;
74
156
 
157
+ // One fd for both streams, shared with the watcher: the successor's
158
+ // boot output and the watcher's verdict land in the same file, in
159
+ // order, even when the successor never gets far enough to log.
160
+ const fd = openRespawnLog();
75
161
  try {
76
- const child = spawn(
77
- process.argv[0],
78
- [...process.execArgv, ...process.argv.slice(1)],
79
- {
80
- cwd: process.cwd(),
81
- detached: true,
82
- stdio: "ignore",
83
- env: { ...process.env },
84
- },
85
- );
162
+ const child = spawnFn(process.argv[0], successorCommand().args, {
163
+ cwd: process.cwd(),
164
+ detached: true,
165
+ stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
166
+ env: { ...process.env },
167
+ });
86
168
  child.once("error", (err) => {
87
- logError(
88
- "shutdown",
89
- `Respawn failed; exiting without a successor — restart manually`,
90
- err,
91
- );
169
+ logError("shutdown", `Respawn failed (${reason}) — see respawn.log`, err);
92
170
  });
93
171
  child.unref();
94
172
  log("shutdown", `Respawn child started (pid ${child.pid}) — ${reason}`);
173
+ if (child.pid) spawnHandoffWatcher(child.pid, fd, spawnFn);
95
174
  } catch (err) {
96
- logError(
97
- "shutdown",
98
- `Respawn failed; exiting without a successor — restart manually`,
99
- err,
100
- );
175
+ logError("shutdown", `Respawn failed (${reason}) — see respawn.log`, err);
101
176
  }
102
177
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Backup actions — the agent tools and the companion app's read surface.
3
+ *
4
+ * Two vocabularies over one subsystem. `create_checkpoint`,
5
+ * `list_checkpoints` and `backup_status` are the model's tools and answer
6
+ * in prose; `backup.status`, `backup.now` and `backup.list` are the
7
+ * companion app's and answer with structured fields beside the text.
8
+ *
9
+ * All of them are chat-free: a snapshot belongs to the daemon, not to a
10
+ * conversation, so the heartbeat and background runs can take one too.
11
+ *
12
+ * There is no restore action. Restoring replaces memory, database and
13
+ * identity underneath a running daemon — that is a decision for a human
14
+ * at a CLI or behind a confirmation button, never a tool call.
15
+ */
16
+
17
+ import {
18
+ collectBackupStatus,
19
+ formatBackupStatus,
20
+ formatSnapshotList,
21
+ listSnapshots,
22
+ runBackup,
23
+ } from "../../../backup/index.js";
24
+ import { log } from "../../../../util/log.js";
25
+ import type { SharedActionHandlers } from "../types.js";
26
+
27
+ const DEFAULT_LIST_LIMIT = 20;
28
+
29
+ function limitOf(
30
+ body: Record<string, unknown>,
31
+ fallback = DEFAULT_LIST_LIMIT,
32
+ ): number {
33
+ const raw = Number(body.limit);
34
+ return Number.isInteger(raw) && raw > 0 ? Math.min(raw, 100) : fallback;
35
+ }
36
+
37
+ async function takeCheckpoint(
38
+ label: string,
39
+ pinned: boolean,
40
+ trigger: string,
41
+ ): Promise<ReturnType<SharedActionHandlers[string]>> {
42
+ try {
43
+ const manifest = await runBackup({
44
+ kind: "checkpoint",
45
+ label,
46
+ pinned,
47
+ trigger,
48
+ });
49
+ log("gateway", `create_checkpoint: ${manifest.id} "${label}"`);
50
+ return {
51
+ ok: true,
52
+ id: manifest.id,
53
+ text:
54
+ `Checkpoint ${manifest.id} taken — "${label}"` +
55
+ (pinned ? " (pinned, never pruned)" : "") +
56
+ `\n${manifest.parts.length} part(s), ${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB.` +
57
+ `\nRestore it with: talon backup restore ${manifest.id}`,
58
+ };
59
+ } catch (err) {
60
+ return {
61
+ ok: false,
62
+ error: `Checkpoint failed: ${err instanceof Error ? err.message : String(err)}`,
63
+ };
64
+ }
65
+ }
66
+
67
+ export const backupHandlers: SharedActionHandlers = {
68
+ create_checkpoint: async (body) => {
69
+ const label = String(body.label ?? "").trim();
70
+ if (!label)
71
+ return { ok: false, error: "create_checkpoint: label is required" };
72
+ return takeCheckpoint(label, body.pin === true, "tool");
73
+ },
74
+
75
+ list_checkpoints: async (body) => {
76
+ const snapshots = await listSnapshots();
77
+ if (snapshots.length === 0) {
78
+ return {
79
+ ok: true,
80
+ text: "No snapshots yet. create_checkpoint takes one now.",
81
+ snapshots: [],
82
+ };
83
+ }
84
+ const limited = snapshots.slice(0, limitOf(body));
85
+ return {
86
+ ok: true,
87
+ text: formatSnapshotList(limited, limited.length),
88
+ snapshots: limited,
89
+ };
90
+ },
91
+
92
+ backup_status: async () => {
93
+ const status = await collectBackupStatus({ withTargets: false });
94
+ return { ok: true, text: formatBackupStatus(status) };
95
+ },
96
+
97
+ "backup.status": async () => {
98
+ const status = await collectBackupStatus();
99
+ return { ok: true, text: formatBackupStatus(status), status };
100
+ },
101
+
102
+ "backup.list": async (body) => {
103
+ const snapshots = (await listSnapshots()).slice(0, limitOf(body, 50));
104
+ return { ok: true, text: formatSnapshotList(snapshots), snapshots };
105
+ },
106
+
107
+ "backup.now": async (body) => {
108
+ const label = String(body.label ?? "").trim();
109
+ if (label) return takeCheckpoint(label, body.pin === true, "manual");
110
+ try {
111
+ const manifest = await runBackup({ kind: "backup", trigger: "manual" });
112
+ return {
113
+ ok: true,
114
+ id: manifest.id,
115
+ text: `Snapshot ${manifest.id} taken (${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB).`,
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ ok: false,
120
+ error: `Backup failed: ${err instanceof Error ? err.message : String(err)}`,
121
+ };
122
+ }
123
+ },
124
+ };
125
+
126
+ /** None of these need a chat — backups belong to the daemon. */
127
+ export const backupChatFreeActions: ReadonlySet<string> = new Set(
128
+ Object.keys(backupHandlers),
129
+ );
@@ -21,6 +21,7 @@
21
21
  * - `models` — model / backend discovery
22
22
  * - `mesh` — companion device mesh (presence + location)
23
23
  * - `cross-send` — explicit-target sends through any enabled frontend
24
+ * - `backup` — snapshots and checkpoints (chat-free; no restore)
24
25
  */
25
26
 
26
27
  import type { ActionResult } from "../../types.js";
@@ -47,6 +48,7 @@ import {
47
48
  whatsappAccountChatFreeActions,
48
49
  } from "./whatsapp-account.js";
49
50
  import { nativeHandlers } from "./native/index.js";
51
+ import { backupChatFreeActions, backupHandlers } from "./backup/index.js";
50
52
 
51
53
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
52
54
  // can't resolve an inherited Object.prototype method — `handlers[action]` only
@@ -67,6 +69,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
67
69
  ...crossSendHandlers,
68
70
  ...whatsappAccountHandlers,
69
71
  ...nativeHandlers,
72
+ ...backupHandlers,
70
73
  });
71
74
 
72
75
  /**
@@ -77,6 +80,7 @@ const chatFreeActions: ReadonlySet<string> = new Set([
77
80
  ...meshChatFreeActions,
78
81
  ...crossSendChatFreeActions,
79
82
  ...whatsappAccountChatFreeActions,
83
+ ...backupChatFreeActions,
80
84
  ]);
81
85
 
82
86
  /**
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
18
  import { composeTools } from "../tools/index.js";
19
- import { createBridge, textResult } from "../tools/ops/bridge.js";
19
+ import { createBridge, textResult } from "../tools/bridge.js";
20
20
  import type { ToolFrontend, ToolTag } from "../tools/types.js";
21
21
 
22
22
  export const VALID_TOOL_FRONTENDS: ReadonlySet<string> = new Set([
@@ -2,6 +2,13 @@
2
2
  * Action routing — try a gateway action through every loaded plugin in load
3
3
  * order; first non-null result wins. Per-plugin errors are caught and surfaced
4
4
  * as error results rather than cascading.
5
+ *
6
+ * `handlePluginActionIn` is the addressed form: the backup subsystem asks
7
+ * each plugin in turn whether it is a remote target, and must then be able
8
+ * to send the follow-up uploads to THAT plugin. First-non-null routing
9
+ * cannot express "this one", and the wire protocol carries no plugin id —
10
+ * so the addressing lives here, on the core side, and the plugin-facing
11
+ * contract is unchanged.
5
12
  */
6
13
 
7
14
  import { logError } from "../../util/log.js";
@@ -30,3 +37,30 @@ export async function handlePluginAction(
30
37
  }
31
38
  return null;
32
39
  }
40
+
41
+ /** Names of the loaded plugins that can answer gateway actions, in load order. */
42
+ export function pluginsWithActions(): string[] {
43
+ return registry.all
44
+ .filter(({ plugin }) => plugin.handleAction !== undefined)
45
+ .map(({ plugin }) => plugin.name);
46
+ }
47
+
48
+ /**
49
+ * Run an action against one named plugin. Returns null when no plugin by
50
+ * that name is loaded or it does not recognise the action.
51
+ */
52
+ export async function handlePluginActionIn(
53
+ name: string,
54
+ body: Record<string, unknown>,
55
+ chatId: string,
56
+ ): Promise<ActionResult | null> {
57
+ const entry = registry.all.find(({ plugin }) => plugin.name === name);
58
+ if (!entry?.plugin.handleAction) return null;
59
+ try {
60
+ return await entry.plugin.handleAction(body, chatId);
61
+ } catch (err) {
62
+ const detail = err instanceof Error ? err.message : String(err);
63
+ logError("plugin", `${name} action error: ${detail}`);
64
+ return { ok: false, error: `Plugin ${name}: ${detail}` };
65
+ }
66
+ }
@@ -23,5 +23,9 @@ export {
23
23
  getPluginPromptAdditions,
24
24
  } from "./loader.js";
25
25
  export { loadBuiltinPlugins, reloadPlugins } from "./builtins.js";
26
- export { handlePluginAction } from "./actions.js";
26
+ export {
27
+ handlePluginAction,
28
+ handlePluginActionIn,
29
+ pluginsWithActions,
30
+ } from "./actions.js";
27
31
  export { getPluginMcpServers } from "./mcp.js";
@@ -3,11 +3,16 @@
3
3
  *
4
4
  * Extracted from the old per-backend tools.ts files so there's
5
5
  * exactly one copy of callBridge / textResult.
6
+ *
7
+ * At the tools root, not in `ops/`: the group directories hold tool
8
+ * DOMAINS (one file per family of tool definitions), and this is the
9
+ * transport they all answer over — the same kind of module as
10
+ * `types.ts` and `schemas.ts` beside it.
6
11
  */
7
12
 
8
13
  import { Agent, fetch as undiciFetch } from "undici";
9
- import { isBunRuntime } from "../../../util/runtime.js";
10
- import type { BridgeFunction } from "../types.js";
14
+ import { isBunRuntime } from "../../util/runtime.js";
15
+ import type { BridgeFunction } from "./types.js";
11
16
 
12
17
  /** Default wall-clock budget for a bridge action. */
13
18
  const DEFAULT_TIMEOUT_MS = 120_000;
@@ -28,6 +28,7 @@ import { crossSendTools } from "./chat/cross-send.js";
28
28
  import { whatsappTools } from "./chat/whatsapp.js";
29
29
  import { moderationTools } from "./chat/moderation.js";
30
30
  import { nativeTools } from "./ops/native.js";
31
+ import { backupTools } from "./ops/backup.js";
31
32
 
32
33
  /** All built-in tool definitions. */
33
34
  export const ALL_TOOLS: readonly ToolDefinition[] = [
@@ -54,6 +55,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
54
55
  // prefix, so a new family goes on the end or every live chat re-bills its
55
56
  // system prompt (see compose-tools.test.ts).
56
57
  ...agentTools,
58
+ ...backupTools,
57
59
  ];
58
60
 
59
61
  /**
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Backup tools — the agent's own access to its safety net.
3
+ *
4
+ * Three tools, and deliberately no fourth: there is no restore tool.
5
+ * Restoring replaces the agent's memory, database and identity while it
6
+ * is running, which is a decision for a human at a CLI or behind a
7
+ * confirmation button, not something a model should be able to reach for
8
+ * mid-turn. Taking a checkpoint, on the other hand, is cheap and always
9
+ * safe — so the agent is encouraged to do it before it changes itself.
10
+ */
11
+
12
+ import { z } from "zod";
13
+ import type { ToolDefinition } from "../types.js";
14
+
15
+ export const backupTools: ToolDefinition[] = [
16
+ {
17
+ name: "create_checkpoint",
18
+ description: `Take a labelled snapshot of your whole state right now — config, prompts, keys, sessions, the database, your memory and skills, and the memory palace.
19
+
20
+ Reach for this BEFORE you change yourself in a way you might want undone: editing identity.md, a big memory rewrite, a risky config change, installing or reconfiguring a plugin. It takes seconds and costs no tokens.
21
+
22
+ Pin a checkpoint you want kept past the retention window (12 scheduled backups by default). Restoring is a human operation — 'talon backup restore <id>' or /backup restore <id> — so say the id in your reply if the checkpoint is the point of the turn.`,
23
+ schema: {
24
+ label: z
25
+ .string()
26
+ .min(1)
27
+ .max(80)
28
+ .describe(
29
+ "Why this checkpoint exists, in a few words ('before identity rewrite'). Shown in every listing.",
30
+ ),
31
+ pin: z
32
+ .boolean()
33
+ .optional()
34
+ .describe(
35
+ "Keep it forever — pinned checkpoints are never pruned by retention. Default false.",
36
+ ),
37
+ },
38
+ execute: (params, bridge) => bridge("create_checkpoint", params),
39
+ tag: "backup",
40
+ },
41
+
42
+ {
43
+ name: "list_checkpoints",
44
+ description:
45
+ "List recent snapshots — id, kind, label, size, whether pinned, and which remote targets hold a copy. Newest first. Use it to find the id of something you or the schedule took earlier.",
46
+ schema: {
47
+ limit: z
48
+ .number()
49
+ .int()
50
+ .positive()
51
+ .max(100)
52
+ .optional()
53
+ .describe("How many to return (default 20, max 100)."),
54
+ },
55
+ execute: (params, bridge) => bridge("list_checkpoints", params),
56
+ tag: "backup",
57
+ },
58
+
59
+ {
60
+ name: "backup_status",
61
+ description:
62
+ "Health of the backup subsystem: when the last snapshot ran and when the next one is due, how many snapshots exist locally and how big they are, which remote targets are registered and whether they are ready, and whether recent runs have been failing.",
63
+ schema: {},
64
+ execute: (_params, bridge) => bridge("backup_status", {}),
65
+ tag: "backup",
66
+ },
67
+ ];
@@ -31,7 +31,8 @@ export type ToolTag =
31
31
  | "models"
32
32
  | "mesh"
33
33
  | "moderation"
34
- | "native";
34
+ | "native"
35
+ | "backup";
35
36
 
36
37
  /** The bridge caller signature — injected into execute(). */
37
38
  export type BridgeFunction = (
@@ -8,6 +8,17 @@
8
8
  * source tree on disk, so {@link getRepoRoot} returns `null` and the
9
9
  * `/update` command is never registered.
10
10
  *
11
+ * The install is verified before anyone hands off to it. `npm install`
12
+ * rewrites node_modules underneath the still-running process, so a bad
13
+ * dependency resolution does not surface until the *successor* imports
14
+ * the tree — detached, with its output going nowhere, at the one moment
15
+ * the daemon has no one left to report to. On 2026-09-18 that cost a
16
+ * 45-minute outage. The last step of an update therefore runs the new
17
+ * tree in a child with {@link BOOT_SMOKE_FLAG}: it resolves the daemon's
18
+ * entire import graph and exits without booting. If it fails, the update
19
+ * fails — the caller reports it to the chat and the current process, the
20
+ * one that still works, keeps running.
21
+ *
11
22
  * The update force-syncs the checkout to the remote branch with
12
23
  * `git reset --hard` (plus `git clean -fd`), discarding any local edits
13
24
  * or diverged commits. A bot host is meant to mirror the remote exactly,
@@ -19,9 +30,15 @@
19
30
  */
20
31
 
21
32
  import { execFile } from "node:child_process";
33
+ import { checkpointBeforeUpdate } from "../backup/index.js";
22
34
  import { existsSync } from "node:fs";
23
35
  import { dirname, join } from "node:path";
24
36
  import { fileURLToPath } from "node:url";
37
+ import {
38
+ BOOT_SMOKE_FLAG,
39
+ BOOT_SMOKE_OK,
40
+ successorCommand,
41
+ } from "../daemon/respawn.js";
25
42
 
26
43
  /** Tuning knobs for {@link runSelfUpdate}. */
27
44
  export interface UpdateOptions {
@@ -36,6 +53,11 @@ export interface UpdateOptions {
36
53
  setup?: readonly string[];
37
54
  /** Override the repo root (tests). Defaults to {@link getRepoRoot}. */
38
55
  repoRoot?: string;
56
+ /**
57
+ * The command that re-runs this process, used for the post-install
58
+ * import check. Defaults to our own argv (tests override it).
59
+ */
60
+ entry?: { cmd: string; args: readonly string[] };
39
61
  /** Injectable command runner (tests). */
40
62
  runner?: CommandRunner;
41
63
  }
@@ -70,6 +92,8 @@ export type CommandRunner = (
70
92
  ) => Promise<{ ok: boolean; output: string }>;
71
93
 
72
94
  const GIT_TIMEOUT_MS = 60_000;
95
+ /** A cold import of the whole daemon graph, on a busy host. */
96
+ const VERIFY_TIMEOUT_MS = 180_000;
73
97
  const INSTALL_TIMEOUT_MS = 300_000;
74
98
  const SETUP_TIMEOUT_MS = 300_000;
75
99
  const MAX_OUTPUT_BUFFER = 16 * 1024 * 1024;
@@ -225,6 +249,8 @@ export async function runSelfUpdate(
225
249
  return { ok: true, repoRoot, steps, before, after, changed: false };
226
250
  }
227
251
 
252
+ await checkpointBeforeUpdate(before, after);
253
+
228
254
  const install = await record(
229
255
  "npm install",
230
256
  "npm",
@@ -265,5 +291,26 @@ export async function runSelfUpdate(
265
291
  }
266
292
  }
267
293
 
294
+ const entry = opts.entry ?? successorCommand();
295
+ const verify = await record(
296
+ "verify import",
297
+ entry.cmd,
298
+ [...entry.args, BOOT_SMOKE_FLAG],
299
+ VERIFY_TIMEOUT_MS,
300
+ );
301
+ if (!verify.ok || !verify.output.includes(BOOT_SMOKE_OK)) {
302
+ return {
303
+ ok: false,
304
+ repoRoot,
305
+ steps,
306
+ before,
307
+ after,
308
+ changed,
309
+ error:
310
+ `the updated tree does not import — not restarting into it. ` +
311
+ `Still running ${before}; the checkout is at ${after}.`,
312
+ };
313
+ }
314
+
268
315
  return { ok: true, repoRoot, steps, before, after, changed: true };
269
316
  }
@@ -18,6 +18,7 @@
18
18
  * model:nav:* (pager)
19
19
  * model:backend-select (select menu)
20
20
  * metrics:today | metrics:all
21
+ * backup:restore:<id> | backup:cancel (restore confirmation, admin only)
21
22
  * ai:<id> (AI-generated buttons — forwarded to agent)
22
23
  */
23
24
 
@@ -36,6 +37,7 @@ import { handleMetricsComponent } from "./metrics.js";
36
37
  import { handleModelComponent } from "./model.js";
37
38
  import { handlePulseComponent } from "./pulse.js";
38
39
  import { handleSettingsComponent } from "./settings.js";
40
+ import { handleBackupComponent } from "../../commands/backup.js";
39
41
  import type { ComponentHandlers, ComponentInteraction } from "./types.js";
40
42
 
41
43
  // Null-prototype so a custom id of "toString:" / "constructor:" / etc.
@@ -48,6 +50,7 @@ export const COMPONENT_HANDLERS: ComponentHandlers = Object.assign(
48
50
  "effort:": handleEffortComponent,
49
51
  "model:": handleModelComponent,
50
52
  "metrics:": handleMetricsComponent,
53
+ "backup:": handleBackupComponent,
51
54
  "ai:": forwardToAgent,
52
55
  } satisfies ComponentHandlers,
53
56
  );