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.
- package/package.json +2 -2
- package/src/app.ts +94 -1
- package/src/backend/codex/mcp-config.ts +1 -1
- package/src/backend/openai-agents/mcp-pool.ts +1 -1
- package/src/backend/runtime/index.ts +1 -1
- package/src/cli/commands/backup.ts +396 -0
- package/src/cli/events.ts +14 -0
- package/src/cli/index.ts +64 -45
- package/src/core/backup/archive/digest.ts +77 -0
- package/src/core/backup/archive/tar.ts +567 -0
- package/src/core/backup/archive/zstd.ts +31 -0
- package/src/core/backup/index.ts +54 -0
- package/src/core/backup/plan.ts +273 -0
- package/src/core/backup/restore.ts +410 -0
- package/src/core/backup/scheduler.ts +357 -0
- package/src/core/backup/snapshot.ts +408 -0
- package/src/core/backup/status.ts +194 -0
- package/src/core/backup/store.ts +312 -0
- package/src/core/backup/targets.ts +281 -0
- package/src/core/backup/types.ts +96 -0
- package/src/core/backup/upload.ts +172 -0
- package/src/core/bus/events.ts +45 -1
- package/src/core/config/index.ts +52 -0
- package/src/core/daemon/handoff.ts +192 -0
- package/src/core/daemon/respawn.ts +127 -52
- package/src/core/engine/gateway-actions/backup/index.ts +129 -0
- package/src/core/engine/gateway-actions/index.ts +4 -0
- package/src/core/mcp-hub/talon-server.ts +1 -1
- package/src/core/plugin/actions.ts +34 -0
- package/src/core/plugin/index.ts +5 -1
- package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/ops/backup.ts +67 -0
- package/src/core/tools/types.ts +2 -1
- package/src/core/update/self-update.ts +47 -0
- package/src/frontend/discord/callbacks/components/index.ts +3 -0
- package/src/frontend/discord/commands/backup.ts +203 -0
- package/src/frontend/discord/commands/definitions.ts +35 -0
- package/src/frontend/discord/commands/router.ts +3 -0
- package/src/frontend/telegram/callbacks/backup.ts +55 -0
- package/src/frontend/telegram/callbacks/index.ts +8 -0
- package/src/frontend/telegram/commands/backup.ts +209 -0
- package/src/frontend/telegram/commands/definitions.ts +4 -0
- package/src/frontend/telegram/commands/index.ts +2 -0
- package/src/index.ts +13 -5
- package/src/plugins/playwright/index.ts +39 -3
- package/src/plugins/playwright/provision.ts +21 -0
- package/src/plugins/playwright/version-coupling.ts +195 -0
- package/src/storage/backup/index.ts +82 -0
- package/src/storage/backup/repo.ts +164 -0
- package/src/storage/db.ts +20 -0
- package/src/storage/sql/backups.sql +46 -0
- package/src/storage/sql/db.sql +8 -0
- package/src/storage/sql/schema.sql +30 -0
- package/src/storage/sql/statements.generated.ts +60 -1
- package/src/util/log.ts +129 -81
- package/src/util/paths.ts +5 -0
- /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
|
|
2
|
+
* Self-respawn helper for /restart and /update across frontends.
|
|
3
3
|
*
|
|
4
|
-
* Spawns a fresh copy of the current process — same
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
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 =
|
|
77
|
-
process.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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/
|
|
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
|
+
}
|
package/src/core/plugin/index.ts
CHANGED
|
@@ -23,5 +23,9 @@ export {
|
|
|
23
23
|
getPluginPromptAdditions,
|
|
24
24
|
} from "./loader.js";
|
|
25
25
|
export { loadBuiltinPlugins, reloadPlugins } from "./builtins.js";
|
|
26
|
-
export {
|
|
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 "
|
|
10
|
-
import type { BridgeFunction } from "
|
|
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;
|
package/src/core/tools/index.ts
CHANGED
|
@@ -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
|
+
];
|
package/src/core/tools/types.ts
CHANGED
|
@@ -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
|
);
|