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
@@ -0,0 +1,203 @@
1
+ /**
2
+ * /backup — snapshots and checkpoints (admin only), the Discord half.
3
+ *
4
+ * Same surface as Telegram's /backup: status, now, checkpoint, list,
5
+ * pin/unpin, restore. Restore is behind a button (`backup:restore:<id>`)
6
+ * because it replaces the database, memory and identity of a running
7
+ * agent — and even then it does not restore in place: the request is
8
+ * staged to ~/.talon/restore-pending.json and applied by the next boot,
9
+ * before anything opens the database.
10
+ *
11
+ * The button handler lives here rather than under callbacks/components/
12
+ * so the whole command — prompt, confirmation and staging — reads as one
13
+ * file.
14
+ */
15
+
16
+ import {
17
+ ActionRowBuilder,
18
+ ButtonBuilder,
19
+ ButtonStyle,
20
+ MessageFlags,
21
+ type ChatInputCommandInteraction,
22
+ } from "discord.js";
23
+ import {
24
+ collectBackupStatus,
25
+ formatBackupStatus,
26
+ formatSnapshotList,
27
+ isSnapshotId,
28
+ listSnapshots,
29
+ readManifest,
30
+ runBackup,
31
+ setSnapshotPinned,
32
+ writeRestorePending,
33
+ } from "../../../core/backup/index.js";
34
+ import { respawnSelf } from "../../../core/daemon/respawn.js";
35
+ import { logError } from "../../../util/log.js";
36
+ import { escapeForCodeBlock } from "../formatting.js";
37
+ import { isAdmin } from "../handlers/index.js";
38
+ import type { ComponentInteraction } from "../callbacks/components/types.js";
39
+ import { reply } from "./interaction.js";
40
+
41
+ function block(text: string): string {
42
+ return `\`\`\`\n${escapeForCodeBlock(text)}\n\`\`\``;
43
+ }
44
+
45
+ function confirmRow(id: string): ActionRowBuilder<ButtonBuilder> {
46
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
47
+ new ButtonBuilder()
48
+ .setCustomId(`backup:restore:${id}`)
49
+ .setLabel("Restore and restart")
50
+ .setStyle(ButtonStyle.Danger),
51
+ new ButtonBuilder()
52
+ .setCustomId("backup:cancel")
53
+ .setLabel("Cancel")
54
+ .setStyle(ButtonStyle.Secondary),
55
+ );
56
+ }
57
+
58
+ async function takeSnapshot(
59
+ i: ChatInputCommandInteraction,
60
+ label?: string,
61
+ ): Promise<void> {
62
+ 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
+ }
81
+ }
82
+
83
+ async function askToRestore(
84
+ i: ChatInputCommandInteraction,
85
+ id: string,
86
+ ): Promise<void> {
87
+ if (!isSnapshotId(id)) {
88
+ await reply(i, "That is not a snapshot id.", true);
89
+ return;
90
+ }
91
+ const manifest = await readManifest(id);
92
+ if (!manifest) {
93
+ await reply(i, `No snapshot \`${id}\` on this machine.`, true);
94
+ return;
95
+ }
96
+ 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.",
103
+ components: [confirmRow(id).toJSON()],
104
+ flags: MessageFlags.Ephemeral,
105
+ });
106
+ }
107
+
108
+ /** `/backup <subcommand> [arg]`. */
109
+ export async function handleBackup(
110
+ i: ChatInputCommandInteraction,
111
+ ): Promise<void> {
112
+ if (!isAdmin(i.user.id)) {
113
+ await reply(i, "Not authorized.", true);
114
+ return;
115
+ }
116
+ const subcommand = i.options.getString("command") ?? "status";
117
+ const argument = (i.options.getString("arg") ?? "").trim();
118
+ switch (subcommand) {
119
+ case "now":
120
+ await takeSnapshot(i);
121
+ return;
122
+ case "checkpoint":
123
+ if (!argument) {
124
+ await reply(i, "Give the checkpoint a label.", true);
125
+ return;
126
+ }
127
+ await takeSnapshot(i, argument);
128
+ return;
129
+ case "list":
130
+ await reply(i, block(formatSnapshotList(await listSnapshots())), true);
131
+ return;
132
+ case "pin":
133
+ case "unpin": {
134
+ if (!isSnapshotId(argument)) {
135
+ await reply(i, "That is not a snapshot id.", true);
136
+ return;
137
+ }
138
+ const ok = await setSnapshotPinned(argument, subcommand === "pin");
139
+ await reply(
140
+ i,
141
+ ok
142
+ ? `${subcommand === "pin" ? "📌 Pinned" : "Unpinned"} \`${argument}\``
143
+ : `No snapshot \`${argument}\``,
144
+ true,
145
+ );
146
+ return;
147
+ }
148
+ case "restore":
149
+ await askToRestore(i, argument);
150
+ return;
151
+ default:
152
+ await reply(
153
+ i,
154
+ block(formatBackupStatus(await collectBackupStatus())),
155
+ true,
156
+ );
157
+ }
158
+ }
159
+
160
+ /**
161
+ * `backup:*` buttons. Returns false for ids this does not own, the way
162
+ * every component handler does, so a stale button is acked not answered.
163
+ */
164
+ export async function handleBackupComponent(
165
+ interaction: ComponentInteraction,
166
+ ): Promise<boolean> {
167
+ const [prefix, action, id] = interaction.customId.split(":");
168
+ if (prefix !== "backup") return false;
169
+ if (!isAdmin(interaction.user.id)) {
170
+ await interaction.update({ content: "Not authorized.", components: [] });
171
+ return true;
172
+ }
173
+ if (action === "cancel") {
174
+ await interaction.update({ content: "Restore cancelled.", components: [] });
175
+ return true;
176
+ }
177
+ if (action !== "restore" || !id || !isSnapshotId(id)) return false;
178
+ await interaction.update({
179
+ content:
180
+ `♻️ Restoring \`${id}\` — restarting now. The restore is applied during ` +
181
+ "boot; I will report back when I am up.",
182
+ components: [],
183
+ });
184
+ try {
185
+ const chatId = interaction.guildId
186
+ ? `discord_guild_${interaction.guildId}_${interaction.channelId}`
187
+ : `discord_dm_${interaction.user.id}`;
188
+ await writeRestorePending({
189
+ id,
190
+ requestedAt: Date.now(),
191
+ requestedBy: chatId,
192
+ });
193
+ respawnSelf(`discord /backup restore ${id}`);
194
+ } catch (err) {
195
+ logError("backup", "Staging the restore failed", err);
196
+ await interaction
197
+ .editReply({
198
+ content: `⚠️ Could not stage the restore: ${err instanceof Error ? err.message : String(err)}`,
199
+ })
200
+ .catch(() => {});
201
+ }
202
+ return true;
203
+ }
@@ -17,6 +17,40 @@ import type { TalonConfig } from "../../../core/config/index.js";
17
17
  import { log, logError, logWarn } from "../../../util/log.js";
18
18
  import { getRepoRoot } from "../../../core/update/self-update.js";
19
19
 
20
+ /**
21
+ * /backup — its own builder: the definitions list is one long literal and
22
+ * this is the only entry with two options and seven choices.
23
+ */
24
+ function backupCommand(): unknown {
25
+ return new SlashCommandBuilder()
26
+ .setName("backup")
27
+ .setDescription("Snapshots and checkpoints (admin only)")
28
+ .addStringOption((o) =>
29
+ o
30
+ .setName("command")
31
+ .setDescription("What to do (default: status)")
32
+ .setRequired(false)
33
+ .addChoices(
34
+ { name: "status", value: "status" },
35
+ { name: "now", value: "now" },
36
+ { name: "checkpoint", value: "checkpoint" },
37
+ { name: "list", value: "list" },
38
+ { name: "pin", value: "pin" },
39
+ { name: "unpin", value: "unpin" },
40
+ { name: "restore", value: "restore" },
41
+ ),
42
+ )
43
+ .addStringOption((o) =>
44
+ o
45
+ .setName("arg")
46
+ .setDescription(
47
+ "Checkpoint label, or snapshot id for pin/unpin/restore",
48
+ )
49
+ .setRequired(false),
50
+ )
51
+ .toJSON();
52
+ }
53
+
20
54
  function buildCommandDefinitions(devBuild = false): unknown[] {
21
55
  return [
22
56
  new SlashCommandBuilder()
@@ -125,6 +159,7 @@ function buildCommandDefinitions(devBuild = false): unknown[] {
125
159
  .toJSON(),
126
160
  ]
127
161
  : []),
162
+ backupCommand(),
128
163
  new SlashCommandBuilder()
129
164
  .setName("admin")
130
165
  .setDescription("Admin operations (admin only)")
@@ -37,6 +37,7 @@ import {
37
37
  handlePulse,
38
38
  handleSettings,
39
39
  } from "./settings.js";
40
+ import { handleBackup } from "./backup.js";
40
41
  import {
41
42
  handleRestart,
42
43
  handleMetrics,
@@ -160,6 +161,8 @@ async function routeSlashCommand(
160
161
  return handleMesh(interaction);
161
162
  case "update":
162
163
  return handleUpdate(interaction, config);
164
+ case "backup":
165
+ return handleBackup(interaction);
163
166
  case "admin":
164
167
  return handleAdmin(interaction, config, gateway);
165
168
  default:
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `backup:*` callbacks — the /backup restore confirmation.
3
+ *
4
+ * backup:restore:<id> stage the restore and restart
5
+ * backup:cancel do nothing, say so
6
+ *
7
+ * The button is the whole safety mechanism: a restore replaces the
8
+ * database, the memory and the identity of a running agent, so nothing
9
+ * here happens without a second, explicit admin tap.
10
+ */
11
+
12
+ import type { Context } from "grammy";
13
+ import { isSnapshotId } from "../../../core/backup/index.js";
14
+ import { stageRestore } from "../commands/backup.js";
15
+ import { isAuthorizedAdmin } from "../commands/state.js";
16
+ import { logError } from "../../../util/log.js";
17
+ import { answerCallbackQuerySafe } from "./query.js";
18
+
19
+ export async function handleBackupCallback(
20
+ ctx: Context,
21
+ data: string,
22
+ ): Promise<void> {
23
+ if (!isAuthorizedAdmin(ctx)) {
24
+ await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
25
+ return;
26
+ }
27
+ const [, action, id] = data.split(":");
28
+ if (action === "cancel") {
29
+ await answerCallbackQuerySafe(ctx, { text: "Cancelled." });
30
+ await ctx.editMessageText("Restore cancelled.").catch(() => {});
31
+ return;
32
+ }
33
+ if (action !== "restore" || !id || !isSnapshotId(id)) {
34
+ await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
35
+ return;
36
+ }
37
+ await answerCallbackQuerySafe(ctx, { text: "Restoring…" });
38
+ await ctx
39
+ .editMessageText(
40
+ `♻️ Restoring <code>${id}</code> — restarting now. ` +
41
+ "The restore is applied during boot; I will report back when I am up.",
42
+ { parse_mode: "HTML" },
43
+ )
44
+ .catch(() => {});
45
+ try {
46
+ await stageRestore(String(ctx.chat?.id ?? ctx.from?.id ?? ""), id);
47
+ } catch (err) {
48
+ logError("backup", "Staging the restore failed", err);
49
+ await ctx
50
+ .editMessageText(
51
+ `⚠️ Could not stage the restore: ${err instanceof Error ? err.message : String(err)}`,
52
+ )
53
+ .catch(() => {});
54
+ }
55
+ }
@@ -11,6 +11,7 @@
11
11
  * - `model` — `model:*` (menu / backend / browse controller)
12
12
  * - `auth` — `auth:*` (backend sign-in panel, admin only)
13
13
  * - `whatsapp` — `whatsapp:*` (WhatsApp link panel, admin only)
14
+ * - `backup` — `backup:*` (restore confirmation, admin only)
14
15
  *
15
16
  * `registerCallbacks` installs one `callback_query:data` listener that
16
17
  * dispatches on the data prefix, preserving the original order and the
@@ -29,6 +30,7 @@ import { handleMetricsCallback } from "./metrics.js";
29
30
  import { handleModelCallback } from "./model.js";
30
31
  import { handleAuthCallback } from "./auth.js";
31
32
  import { handleWhatsAppCallback } from "./whatsapp.js";
33
+ import { handleBackupCallback } from "./backup.js";
32
34
 
33
35
  export { answerCallbackQuerySafe } from "./query.js";
34
36
 
@@ -50,6 +52,12 @@ export function registerCallbacks(
50
52
  return;
51
53
  }
52
54
 
55
+ // Restore confirmation — destructive, so it is behind its own tap.
56
+ if (data.startsWith("backup:")) {
57
+ await handleBackupCallback(ctx, data);
58
+ return;
59
+ }
60
+
53
61
  // Handle pulse callbacks
54
62
  if (data.startsWith("pulse:")) {
55
63
  await handlePulseCallback(ctx, data, cid);
@@ -0,0 +1,209 @@
1
+ /**
2
+ * /backup — the snapshot panel (admin only).
3
+ *
4
+ * /backup status: schedule, sizes, targets
5
+ * /backup now take a snapshot now
6
+ * /backup checkpoint <label> take a labelled, pinned-on-request snapshot
7
+ * /backup list recent snapshots
8
+ * /backup pin|unpin <id> keep past retention, or release
9
+ * /backup restore <id> confirm button → staged restore + restart
10
+ *
11
+ * Restore is the only destructive one, so it is the only one behind a
12
+ * button. It does not restore in place: a running daemon holds the
13
+ * database it would replace, so the request is staged to
14
+ * ~/.talon/restore-pending.json and applied by the next boot before
15
+ * anything opens the database (see core/backup/restore.ts).
16
+ */
17
+
18
+ import type { Bot, Context } from "grammy";
19
+ import {
20
+ collectBackupStatus,
21
+ formatBackupStatus,
22
+ formatSnapshotList,
23
+ isSnapshotId,
24
+ listSnapshots,
25
+ readManifest,
26
+ runBackup,
27
+ setSnapshotPinned,
28
+ writeRestorePending,
29
+ } from "../../../core/backup/index.js";
30
+ import { respawnSelf } from "../../../core/daemon/respawn.js";
31
+ import { escapeHtml } from "../formatting.js";
32
+ import { logError } from "../../../util/log.js";
33
+ import { isAuthorizedAdmin } from "./state.js";
34
+
35
+ const HELP = [
36
+ "<b>/backup</b> — snapshots and checkpoints",
37
+ "",
38
+ "<code>/backup</code> — status",
39
+ "<code>/backup now</code> — take a snapshot",
40
+ "<code>/backup checkpoint &lt;label&gt;</code> — labelled checkpoint",
41
+ "<code>/backup list</code> — recent snapshots",
42
+ "<code>/backup pin &lt;id&gt;</code> · <code>/backup unpin &lt;id&gt;</code>",
43
+ "<code>/backup restore &lt;id&gt;</code> — restore (asks to confirm, then restarts)",
44
+ ].join("\n");
45
+
46
+ function pre(text: string): string {
47
+ return `<pre>${escapeHtml(text)}</pre>`;
48
+ }
49
+
50
+ async function sendStatus(ctx: Context): Promise<void> {
51
+ const status = await collectBackupStatus();
52
+ await ctx.reply(pre(formatBackupStatus(status)), { parse_mode: "HTML" });
53
+ }
54
+
55
+ async function sendList(ctx: Context): Promise<void> {
56
+ const snapshots = await listSnapshots();
57
+ await ctx.reply(pre(formatSnapshotList(snapshots)), { parse_mode: "HTML" });
58
+ }
59
+
60
+ async function takeSnapshot(ctx: Context, label?: string): Promise<void> {
61
+ const sent = await ctx.reply(
62
+ label
63
+ ? `📸 Taking checkpoint “${escapeHtml(label)}”…`
64
+ : "📸 Taking a snapshot…",
65
+ { parse_mode: "HTML" },
66
+ );
67
+ try {
68
+ const manifest = await runBackup({
69
+ kind: label ? "checkpoint" : "backup",
70
+ label,
71
+ pinned: Boolean(label),
72
+ trigger: "command",
73
+ });
74
+ await ctx.api.editMessageText(
75
+ ctx.chat!.id,
76
+ sent.message_id,
77
+ `✅ <code>${escapeHtml(manifest.id)}</code> — ${manifest.parts.length} part(s), ` +
78
+ `${(manifest.sizeBytes / 1024 / 1024).toFixed(1)} MB` +
79
+ (label ? " (pinned)" : ""),
80
+ { parse_mode: "HTML" },
81
+ );
82
+ } catch (err) {
83
+ logError("backup", "/backup now failed", err);
84
+ await ctx.api.editMessageText(
85
+ ctx.chat!.id,
86
+ sent.message_id,
87
+ `⚠️ Backup failed: ${escapeHtml(err instanceof Error ? err.message : String(err))}`,
88
+ { parse_mode: "HTML" },
89
+ );
90
+ }
91
+ }
92
+
93
+ async function setPinned(
94
+ ctx: Context,
95
+ id: string,
96
+ pinned: boolean,
97
+ ): Promise<void> {
98
+ if (!isSnapshotId(id)) {
99
+ await ctx.reply("That is not a snapshot id.");
100
+ return;
101
+ }
102
+ const ok = await setSnapshotPinned(id, pinned);
103
+ await ctx.reply(
104
+ ok
105
+ ? `${pinned ? "📌 Pinned" : "Unpinned"} <code>${escapeHtml(id)}</code>`
106
+ : `No snapshot <code>${escapeHtml(id)}</code>`,
107
+ { parse_mode: "HTML" },
108
+ );
109
+ }
110
+
111
+ /** The confirmation prompt. The button carries the id; nothing happens yet. */
112
+ async function askToRestore(ctx: Context, id: string): Promise<void> {
113
+ if (!isSnapshotId(id)) {
114
+ await ctx.reply("That is not a snapshot id.");
115
+ return;
116
+ }
117
+ const manifest = await readManifest(id);
118
+ if (!manifest) {
119
+ await ctx.reply(
120
+ `No snapshot <code>${escapeHtml(id)}</code> on this machine.`,
121
+ {
122
+ parse_mode: "HTML",
123
+ },
124
+ );
125
+ return;
126
+ }
127
+ await ctx.reply(
128
+ `♻️ <b>Restore <code>${escapeHtml(id)}</code>?</b>\n` +
129
+ (manifest.label ? `“${escapeHtml(manifest.label)}”\n` : "") +
130
+ `Taken ${new Date(manifest.createdAt).toISOString()}\n\n` +
131
+ "This replaces config, prompts, keys, sessions, the database and memory, " +
132
+ "then restarts. A pinned checkpoint of the current state is taken first.",
133
+ {
134
+ parse_mode: "HTML",
135
+ reply_markup: {
136
+ inline_keyboard: [
137
+ [
138
+ {
139
+ text: "♻️ Restore and restart",
140
+ callback_data: `backup:restore:${id}`,
141
+ },
142
+ { text: "Cancel", callback_data: "backup:cancel" },
143
+ ],
144
+ ],
145
+ },
146
+ },
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Stage the restore and hand off to the successor. Called by the
152
+ * confirmation button — see callbacks/backup.ts.
153
+ */
154
+ export async function stageRestore(chatId: string, id: string): Promise<void> {
155
+ await writeRestorePending({
156
+ id,
157
+ requestedAt: Date.now(),
158
+ requestedBy: chatId,
159
+ });
160
+ respawnSelf(`telegram /backup restore ${id}`);
161
+ }
162
+
163
+ export function registerBackupCommand(bot: Bot): void {
164
+ bot.command("backup", async (ctx) => {
165
+ if (!isAuthorizedAdmin(ctx)) {
166
+ await ctx.reply("Not authorized.");
167
+ return;
168
+ }
169
+ const [subcommand, ...rest] = (ctx.match ?? "")
170
+ .trim()
171
+ .split(/\s+/)
172
+ .filter(Boolean);
173
+ switch (subcommand) {
174
+ case undefined:
175
+ case "status":
176
+ await sendStatus(ctx);
177
+ return;
178
+ case "now":
179
+ await takeSnapshot(ctx);
180
+ return;
181
+ case "checkpoint": {
182
+ const label = rest.join(" ").trim();
183
+ if (!label) {
184
+ await ctx.reply(
185
+ "Give the checkpoint a label: <code>/backup checkpoint before the rewrite</code>",
186
+ {
187
+ parse_mode: "HTML",
188
+ },
189
+ );
190
+ return;
191
+ }
192
+ await takeSnapshot(ctx, label);
193
+ return;
194
+ }
195
+ case "list":
196
+ await sendList(ctx);
197
+ return;
198
+ case "pin":
199
+ case "unpin":
200
+ await setPinned(ctx, rest[0] ?? "", subcommand === "pin");
201
+ return;
202
+ case "restore":
203
+ await askToRestore(ctx, rest[0] ?? "");
204
+ return;
205
+ default:
206
+ await ctx.reply(HELP, { parse_mode: "HTML" });
207
+ }
208
+ });
209
+ }
@@ -48,6 +48,10 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
48
48
  command: "memory",
49
49
  description: "What Talon remembers — list, search, why <id>",
50
50
  },
51
+ {
52
+ command: "backup",
53
+ description: "Snapshots and checkpoints; /backup restore <id> (admin)",
54
+ },
51
55
  { command: "plugins", description: "List loaded plugins" },
52
56
  { command: "help", description: "All commands and features" },
53
57
  ];
@@ -26,6 +26,7 @@ import { registerSettingsCommands } from "./settings.js";
26
26
  import { registerAdminCommands } from "./admin.js";
27
27
  import { registerWhatsAppPairingCommand } from "./whatsapp-pairing.js";
28
28
  import { registerAuthCommand } from "./auth.js";
29
+ import { registerBackupCommand } from "./backup.js";
29
30
 
30
31
  export { telegramCommandMenu } from "./definitions.js";
31
32
  export { setAdminUserId } from "./state.js";
@@ -42,6 +43,7 @@ export function registerCommands(
42
43
  registerSettingsCommands(bot, deps);
43
44
  registerWhatsAppPairingCommand(bot);
44
45
  registerAuthCommand(bot);
46
+ registerBackupCommand(bot);
45
47
  // admin LAST: it owns the unknown-command catch-all, which must only
46
48
  // be reached after every real command has had its chance to match.
47
49
  // Registering anything after it makes that command look unknown
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Talon entry shim.
3
3
  *
4
- * Dispatches the hidden `_mcp-launch` (MCP supervisor) and `_lua-run`
5
- * (WASM Lua trigger runner) subcommands BEFORE the app graph loads —
6
- * both are Talon re-invoking itself (see core/mcp-hub/launcher.ts). The
7
- * dynamic import keeps these helper processes light: they evaluate this
8
- * shim and their own module, never the backends/frontends/plugins.
4
+ * Dispatches the hidden `_mcp-launch` (MCP supervisor), `_lua-run`
5
+ * (WASM Lua trigger runner) and `_handoff-watch` (restart/update
6
+ * witness) subcommands BEFORE the app graph loads — all three are Talon
7
+ * re-invoking itself (see core/mcp-hub/launcher.ts, core/daemon/
8
+ * handoff.ts). The dynamic import keeps these helper processes light:
9
+ * they evaluate this shim and their own module, never the
10
+ * backends/frontends/plugins.
9
11
  */
10
12
 
11
13
  import {
@@ -13,11 +15,17 @@ import {
13
15
  runSupervisor,
14
16
  } from "./core/mcp-hub/launcher.js";
15
17
  import { LUA_RUN_SUBCOMMAND, runLuaMain } from "./core/scripts/lua.js";
18
+ import {
19
+ HANDOFF_WATCH_SUBCOMMAND,
20
+ runHandoffWatch,
21
+ } from "./core/daemon/handoff.js";
16
22
 
17
23
  if (process.argv[2] === MCP_LAUNCH_SUBCOMMAND) {
18
24
  await runSupervisor(process.argv.slice(3));
19
25
  } else if (process.argv[2] === LUA_RUN_SUBCOMMAND) {
20
26
  await runLuaMain(process.argv.slice(3));
27
+ } else if (process.argv[2] === HANDOFF_WATCH_SUBCOMMAND) {
28
+ await runHandoffWatch(process.argv.slice(3));
21
29
  } else {
22
30
  await import("./app.js");
23
31
  }