talon-agent 5.14.0 → 5.18.2

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 (116) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +14 -7
  5. package/package.json +6 -4
  6. package/prompts/system/heartbeat-agent.md +1 -1
  7. package/src/backend/agy/factory.ts +3 -0
  8. package/src/backend/agy/mcp/config.ts +14 -2
  9. package/src/backend/claude-sdk/factory.ts +3 -0
  10. package/src/backend/claude-sdk/options.ts +24 -3
  11. package/src/backend/codex/factory.ts +3 -0
  12. package/src/backend/codex/init.ts +4 -0
  13. package/src/backend/codex/mcp-config.ts +10 -0
  14. package/src/backend/codex/oauth-incompat.ts +1 -1
  15. package/src/backend/codex/token-usage.ts +2 -2
  16. package/src/backend/openai-agents/factory.ts +3 -0
  17. package/src/backend/openai-agents/mcp-pool.ts +4 -0
  18. package/src/backend/remote-server/factory.ts +3 -0
  19. package/src/backend/remote-server/mcp.ts +3 -0
  20. package/src/backend/runtime/prompt/prompt-format.ts +3 -3
  21. package/src/bootstrap.ts +8 -0
  22. package/src/cli/commands/backup.ts +61 -5
  23. package/src/cli/commands/mesh.ts +133 -0
  24. package/src/cli/config.ts +3 -1
  25. package/src/cli/daemon-api.ts +22 -0
  26. package/src/cli/index.ts +6 -0
  27. package/src/cli/install-sources.ts +40 -5
  28. package/src/cli/plugin.ts +10 -0
  29. package/src/cli/setup.ts +45 -4
  30. package/src/cli/skill.ts +3 -0
  31. package/src/core/agent-runtime/backend-registry.ts +16 -0
  32. package/src/core/backup/archive/crypt.ts +429 -0
  33. package/src/core/backup/archive/manifest-auth.ts +98 -0
  34. package/src/core/backup/passphrase.ts +130 -0
  35. package/src/core/backup/plan.ts +66 -13
  36. package/src/core/backup/restore-guard.ts +101 -0
  37. package/src/core/backup/restore.ts +249 -32
  38. package/src/core/backup/snapshot.ts +418 -60
  39. package/src/core/backup/sources/plugins.ts +223 -0
  40. package/src/core/backup/sources/relocate.ts +136 -0
  41. package/src/core/backup/sources/sessions.ts +198 -0
  42. package/src/core/backup/store.ts +3 -1
  43. package/src/core/backup/types.ts +66 -0
  44. package/src/core/backup/upload.ts +66 -6
  45. package/src/core/config/index.ts +140 -8
  46. package/src/core/daemon/control.ts +9 -0
  47. package/src/core/daemon/discovery.ts +7 -0
  48. package/src/core/engine/backend-router/headroom.ts +29 -5
  49. package/src/core/engine/backend-router/usage.ts +6 -2
  50. package/src/core/engine/gateway-actions/fetch-url/guard.ts +201 -0
  51. package/src/core/engine/gateway-actions/{fetch-url.ts → fetch-url/index.ts} +60 -32
  52. package/src/core/engine/gateway-actions/index.ts +4 -2
  53. package/src/core/engine/gateway-actions/native/index.ts +24 -0
  54. package/src/core/engine/gateway-actions/whatsapp-account.ts +1 -1
  55. package/src/core/engine/gateway-auth.ts +164 -0
  56. package/src/core/engine/gateway-routes.ts +100 -5
  57. package/src/core/engine/gateway.ts +12 -4
  58. package/src/core/mcp-hub/guest-scope.ts +170 -29
  59. package/src/core/mcp-hub/index.ts +33 -16
  60. package/src/core/mcp-hub/talon-server.ts +71 -14
  61. package/src/core/mesh/credentials/admin.ts +146 -0
  62. package/src/core/mesh/credentials/index.ts +19 -0
  63. package/src/core/mesh/credentials/store.ts +443 -0
  64. package/src/core/mesh/credentials/token.ts +45 -0
  65. package/src/core/mesh/credentials/types.ts +83 -0
  66. package/src/core/mesh/devices/service.ts +58 -6
  67. package/src/core/mesh/links/bridge-links.ts +46 -4
  68. package/src/core/mesh/links/node-binaries.ts +1 -1
  69. package/src/core/mesh/links/node-provision.ts +8 -1
  70. package/src/core/models/active-model.ts +1 -1
  71. package/src/core/plugin/loader.ts +4 -0
  72. package/src/core/plugin/mcp.ts +4 -0
  73. package/src/core/tools/bridge.ts +2 -1
  74. package/src/core/types.ts +13 -0
  75. package/src/core/weaver/weaver.ts +47 -15
  76. package/src/frontend/discord/callbacks/components/agent-buttons.ts +1 -0
  77. package/src/frontend/discord/handlers/delivery.ts +3 -0
  78. package/src/frontend/discord/handlers/queue.ts +1 -0
  79. package/src/frontend/native/bridge/auth-guard.ts +277 -0
  80. package/src/frontend/native/bridge/auth.ts +84 -1
  81. package/src/frontend/native/bridge/credentials/claims.ts +51 -0
  82. package/src/frontend/native/bridge/credentials/principal.ts +200 -0
  83. package/src/frontend/native/bridge/credentials/upgrade.ts +129 -0
  84. package/src/frontend/native/bridge/routes/auth.ts +37 -0
  85. package/src/frontend/native/bridge/routes/chats.ts +20 -2
  86. package/src/frontend/native/bridge/routes/host.ts +11 -1
  87. package/src/frontend/native/bridge/routes/index.ts +2 -0
  88. package/src/frontend/native/bridge/routes/mesh.ts +72 -15
  89. package/src/frontend/native/bridge/routes/table.ts +73 -51
  90. package/src/frontend/native/bridge/server.ts +266 -83
  91. package/src/frontend/native/index.ts +54 -3
  92. package/src/frontend/native/turn/turn.ts +2 -0
  93. package/src/frontend/teams/turn.ts +1 -0
  94. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  95. package/src/frontend/telegram/actions/send.ts +4 -0
  96. package/src/frontend/telegram/admin.ts +20 -0
  97. package/src/frontend/telegram/commands/admin.ts +1 -1
  98. package/src/frontend/telegram/commands/state.ts +7 -9
  99. package/src/frontend/telegram/handlers/access.ts +31 -10
  100. package/src/frontend/telegram/handlers/delivery.ts +14 -1
  101. package/src/frontend/telegram/handlers/group-access.ts +50 -0
  102. package/src/frontend/telegram/handlers/messages.ts +1 -0
  103. package/src/frontend/telegram/handlers/queue.ts +14 -0
  104. package/src/frontend/telegram/handlers/state.ts +2 -2
  105. package/src/frontend/telegram/index.ts +32 -9
  106. package/src/frontend/telegram/middleware.ts +2 -2
  107. package/src/frontend/telegram/polling/poll-deadline.ts +52 -0
  108. package/src/frontend/telegram/{stale-command.ts → polling/stale-command.ts} +1 -1
  109. package/src/frontend/telegram/{update-offset.ts → polling/update-offset.ts} +1 -1
  110. package/src/frontend/telegram/userbot.ts +103 -10
  111. package/src/frontend/terminal/index.ts +8 -1
  112. package/src/frontend/whatsapp/commands.ts +3 -3
  113. package/src/frontend/whatsapp/messages/inbound.ts +1 -0
  114. package/src/plugins/playwright/index.ts +1 -1
  115. package/src/storage/backup/index.ts +1 -1
  116. package/src/storage/db.ts +23 -0
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Plugins — what a clone needs to run the same tools.
3
+ *
4
+ * Two kinds of plugin entry in config.json, two ways to carry them:
5
+ *
6
+ * - Local code (`path`, or an MCP `command` whose argument is a path on
7
+ * disk): the checkout itself goes into the snapshot as
8
+ * `plugin-src/<n>-<name>`, minus what an install or build recreates
9
+ * (node_modules, venvs, dist/, build/, caches — see plan.ts). Lockfiles
10
+ * and package.json stay, so `npm ci` gets the same tree back.
11
+ * - Fetched packages (`npx`/`bunx`/`pnpm dlx`, `uvx`/`pipx run`,
12
+ * `docker run`): nothing to copy, so `plugins-manifest.json` records
13
+ * the exact spec — package@version or image:tag — to reinstall from.
14
+ * An unpinned npm package is resolved against the local npx cache and
15
+ * the version found is written down, marked `pinned: false`.
16
+ *
17
+ * Plugins under the Talon home (`talon plugin install` puts them in
18
+ * ~/.talon/plugins) are already captured with the home and are listed in
19
+ * the manifest only.
20
+ */
21
+
22
+ import { readFile, readdir, stat } from "node:fs/promises";
23
+ import { basename, dirname, isAbsolute, join } from "node:path";
24
+ import { isInside } from "../plan.js";
25
+ import type { ExternalRoot } from "../types.js";
26
+ import { pathExists, type SourceContext } from "./sessions.js";
27
+
28
+ type PluginEntry = {
29
+ path?: string;
30
+ name?: string;
31
+ command?: string;
32
+ args?: string[];
33
+ enabled?: boolean;
34
+ };
35
+
36
+ /** One line of plugins-manifest.json. */
37
+ type PluginRecord = {
38
+ name: string;
39
+ enabled: boolean;
40
+ source:
41
+ | { type: "local"; path: string; archiveRoot?: string; version?: string }
42
+ | { type: "npm"; spec: string; version?: string; pinned: boolean }
43
+ | { type: "python"; spec: string; pinned: boolean }
44
+ | { type: "docker"; image: string; pinned: boolean }
45
+ | { type: "command"; command: string };
46
+ };
47
+
48
+ export type PluginsManifest = { schema: 1; plugins: PluginRecord[] };
49
+
50
+ const NPM_RUNNERS = new Set(["npx", "bunx"]);
51
+ const PY_RUNNERS = new Set(["uvx", "pipx"]);
52
+ const DOCKER_RUNNERS = new Set(["docker", "podman"]);
53
+ const PACKAGE_MARKERS = ["package.json", "pyproject.toml", "setup.py"];
54
+
55
+ function pluginEntries(config: Record<string, unknown>): PluginEntry[] {
56
+ const list = config.plugins;
57
+ return Array.isArray(list)
58
+ ? list.filter((e): e is PluginEntry => typeof e === "object" && e !== null)
59
+ : [];
60
+ }
61
+
62
+ function displayName(entry: PluginEntry): string {
63
+ if (entry.name) return entry.name;
64
+ if (entry.path) return basename(entry.path.replace(/[\\/]+$/, ""));
65
+ return entry.command ?? "(invalid entry)";
66
+ }
67
+
68
+ /** Non-flag arguments, in order. */
69
+ function positional(args: readonly string[]): string[] {
70
+ return args.filter((arg) => !arg.startsWith("-"));
71
+ }
72
+
73
+ async function isFile(path: string): Promise<boolean> {
74
+ try {
75
+ return (await stat(path)).isFile();
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The package root of a plugin path: the nearest directory at or above
83
+ * it that holds a package.json/pyproject.toml, never climbing past the
84
+ * user's home. A path pointing into `src/` still captures the lockfile.
85
+ */
86
+ async function packageRoot(
87
+ path: string,
88
+ userHome: string | null,
89
+ ): Promise<string> {
90
+ let dir = (await isFile(path)) ? dirname(path) : path;
91
+ const start = dir;
92
+ for (let depth = 0; depth < 4; depth++) {
93
+ for (const marker of PACKAGE_MARKERS) {
94
+ if (await isFile(join(dir, marker))) return dir;
95
+ }
96
+ const parent = dirname(dir);
97
+ if (parent === dir || (userHome && parent === userHome)) break;
98
+ dir = parent;
99
+ }
100
+ return start;
101
+ }
102
+
103
+ async function readVersion(dir: string): Promise<string | undefined> {
104
+ try {
105
+ const pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
106
+ return typeof pkg.version === "string" ? pkg.version : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /** `pkg@1.2.3` → pinned; `pkg`, `pkg@latest`, `pkg@^1` → not. */
113
+ function npmPinned(spec: string): boolean {
114
+ const at = spec.lastIndexOf("@");
115
+ return at > 0 && /^\d+\.\d+\.\d+/.test(spec.slice(at + 1));
116
+ }
117
+
118
+ function compareVersions(a: string, b: string): number {
119
+ const pa = a.split(/[.-]/).map((n) => Number.parseInt(n, 10) || 0);
120
+ const pb = b.split(/[.-]/).map((n) => Number.parseInt(n, 10) || 0);
121
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
122
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
123
+ if (diff !== 0) return diff;
124
+ }
125
+ return 0;
126
+ }
127
+
128
+ /** Highest version of `name` in the npx cache — what `npx` would run. */
129
+ async function npxCachedVersion(
130
+ name: string,
131
+ userHome: string | null,
132
+ ): Promise<string | undefined> {
133
+ if (!userHome) return undefined;
134
+ const cache = join(userHome, ".npm", "_npx");
135
+ let hashes: string[];
136
+ try {
137
+ hashes = await readdir(cache);
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ const versions: string[] = [];
142
+ for (const hash of hashes) {
143
+ const version = await readVersion(join(cache, hash, "node_modules", name));
144
+ if (version) versions.push(version);
145
+ }
146
+ return versions.sort(compareVersions).pop();
147
+ }
148
+
149
+ function npmName(spec: string): string {
150
+ const at = spec.lastIndexOf("@");
151
+ return at > 0 ? spec.slice(0, at) : spec;
152
+ }
153
+
154
+ async function commandSource(
155
+ entry: PluginEntry,
156
+ userHome: string | null,
157
+ ): Promise<PluginRecord["source"]> {
158
+ const command = basename(entry.command ?? "");
159
+ const args = positional(entry.args ?? []);
160
+ if (NPM_RUNNERS.has(command) || command === "pnpm") {
161
+ const spec = (command === "pnpm" ? args[1] : args[0]) ?? "";
162
+ const pinned = npmPinned(spec);
163
+ const version = pinned
164
+ ? undefined
165
+ : await npxCachedVersion(npmName(spec), userHome);
166
+ return { type: "npm", spec, pinned, ...(version ? { version } : {}) };
167
+ }
168
+ if (PY_RUNNERS.has(command)) {
169
+ const fromIndex = (entry.args ?? []).indexOf("--from");
170
+ const spec =
171
+ fromIndex >= 0
172
+ ? (entry.args?.[fromIndex + 1] ?? "")
173
+ : ((command === "pipx" ? args[1] : args[0]) ?? "");
174
+ return { type: "python", spec, pinned: /==|@/.test(spec) };
175
+ }
176
+ if (DOCKER_RUNNERS.has(command)) {
177
+ // `docker run [flags] image[:tag] [cmd]` — flag values (`-e K=V`,
178
+ // `-v /a:/b`) are the positional args that are not the image.
179
+ const image =
180
+ args.slice(1).find((arg) => !arg.includes("=") && !arg.startsWith("/")) ??
181
+ "";
182
+ return {
183
+ type: "docker",
184
+ image,
185
+ pinned: /:[^/]+$|@sha256:/.test(image),
186
+ };
187
+ }
188
+ const local = (entry.args ?? []).find((arg) => isAbsolute(arg));
189
+ if (local) return { type: "local", path: local };
190
+ return { type: "command", command: entry.command ?? "" };
191
+ }
192
+
193
+ /**
194
+ * Walk config.json's plugins into the manifest and the list of local
195
+ * checkouts to archive. Deterministic: config order, numbered roots.
196
+ */
197
+ export async function discoverPlugins(ctx: SourceContext): Promise<{
198
+ manifest: PluginsManifest;
199
+ roots: ExternalRoot[];
200
+ }> {
201
+ const plugins: PluginRecord[] = [];
202
+ const roots: ExternalRoot[] = [];
203
+ for (const [index, entry] of pluginEntries(ctx.config).entries()) {
204
+ const source: PluginRecord["source"] = entry.path
205
+ ? { type: "local", path: entry.path }
206
+ : await commandSource(entry, ctx.userHome);
207
+ if (source.type === "local") {
208
+ const root = await packageRoot(source.path, ctx.userHome);
209
+ const version = await readVersion(root);
210
+ if (version) source.version = version;
211
+ if (!isInside(ctx.home, root) && (await pathExists(root))) {
212
+ source.archiveRoot = `plugin-src/${index}-${basename(root)}`;
213
+ roots.push({ root: source.archiveRoot, source: root, kind: "plugin" });
214
+ }
215
+ }
216
+ plugins.push({
217
+ name: displayName(entry),
218
+ enabled: entry.enabled !== false,
219
+ source,
220
+ });
221
+ }
222
+ return { manifest: { schema: 1, plugins }, roots };
223
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Clone — putting a snapshot's outside-the-home roots where they belong
3
+ * on a different machine.
4
+ *
5
+ * Everything under the Talon home restores relative to whatever home the
6
+ * new machine has. The external roots (session stores, plugin checkouts)
7
+ * were recorded by absolute path, and two things about those paths are
8
+ * host-specific:
9
+ *
10
+ * - the user's home directory (`/home/alice` → `/Users/alice`), and
11
+ * - Claude Code's project slug, which IS the absolute cwd with its
12
+ * separators flattened — a transcript filed under the old workspace
13
+ * path is invisible to a session started in the new one.
14
+ *
15
+ * A clone rewrites both, and rewrites the same prefixes inside
16
+ * config.json (plugin paths, a passphrase file) so the restored config
17
+ * points at the restored files.
18
+ */
19
+
20
+ import { readFile } from "node:fs/promises";
21
+ import { basename, isAbsolute, join, relative } from "node:path";
22
+ import writeFileAtomic from "write-file-atomic";
23
+ import { isInside } from "../plan.js";
24
+ import type { ExternalRoot, SnapshotOrigin } from "../types.js";
25
+ import {
26
+ claudeProjectSlug,
27
+ claudeProjectsDir,
28
+ sessionCwds,
29
+ type SourceContext,
30
+ } from "./sessions.js";
31
+
32
+ /** The machine a clone lands on. */
33
+ export type CloneTarget = Pick<SourceContext, "home" | "env"> & {
34
+ userHome: string;
35
+ };
36
+
37
+ /** Move `path` from one prefix to another; unchanged when outside it. */
38
+ function reprefix(path: string, from: string, to: string): string {
39
+ if (from === to || !isInside(from, path)) return path;
40
+ const rest = relative(from, path);
41
+ return rest ? join(to, rest) : to;
42
+ }
43
+
44
+ /** Re-slug a Claude project directory name for the new Talon home. */
45
+ export function relocateClaudeSlug(
46
+ name: string,
47
+ originHome: string,
48
+ targetHome: string,
49
+ ): string {
50
+ const pairs = sessionCwds(originHome).map((cwd, i) => [
51
+ claudeProjectSlug(cwd),
52
+ claudeProjectSlug(sessionCwds(targetHome)[i]),
53
+ ]);
54
+ // Longest first: agent-workspace's slug must not be matched as a
55
+ // prefix-extension of some shorter one.
56
+ pairs.sort((a, b) => b[0].length - a[0].length);
57
+ for (const [from, to] of pairs) {
58
+ if (name === from || name.startsWith(`${from}-`))
59
+ return to + name.slice(from.length);
60
+ }
61
+ return name;
62
+ }
63
+
64
+ /** Where one external root lands on the clone target. */
65
+ export function relocateRoot(
66
+ root: ExternalRoot,
67
+ origin: SnapshotOrigin,
68
+ target: CloneTarget,
69
+ ): string {
70
+ if (root.kind === "claude-project") {
71
+ return join(
72
+ claudeProjectsDir(target.userHome, target.env),
73
+ relocateClaudeSlug(basename(root.source), origin.home, target.home),
74
+ );
75
+ }
76
+ const moved = reprefix(root.source, origin.home, target.home);
77
+ return moved !== root.source
78
+ ? moved
79
+ : reprefix(root.source, origin.userHome, target.userHome);
80
+ }
81
+
82
+ /** Deep-rewrite every string that is a path under the origin's homes. */
83
+ function rewriteValue(
84
+ value: unknown,
85
+ origin: SnapshotOrigin,
86
+ target: CloneTarget,
87
+ ): unknown {
88
+ if (typeof value === "string") {
89
+ // Cheap "is this a path?" gate. isAbsolute, not a leading "/": on
90
+ // Windows an absolute path is "C:\\Users\\…", and a clone that
91
+ // skipped those left config.json pointing at the origin machine.
92
+ if (!isAbsolute(value)) return value;
93
+ const moved = reprefix(value, origin.home, target.home);
94
+ return moved !== value
95
+ ? moved
96
+ : reprefix(value, origin.userHome, target.userHome);
97
+ }
98
+ if (Array.isArray(value))
99
+ return value.map((item) => rewriteValue(item, origin, target));
100
+ if (value && typeof value === "object") {
101
+ return Object.fromEntries(
102
+ Object.entries(value).map(([key, item]) => [
103
+ key,
104
+ rewriteValue(item, origin, target),
105
+ ]),
106
+ );
107
+ }
108
+ return value;
109
+ }
110
+
111
+ /**
112
+ * Point the restored config.json at the clone's paths. Returns false when
113
+ * there was nothing to change (same homes, or no config).
114
+ */
115
+ export async function rewriteConfigForClone(
116
+ origin: SnapshotOrigin,
117
+ target: CloneTarget,
118
+ ): Promise<boolean> {
119
+ if (origin.home === target.home && origin.userHome === target.userHome)
120
+ return false;
121
+ const path = join(target.home, "config.json");
122
+ let raw: string;
123
+ try {
124
+ raw = await readFile(path, "utf8");
125
+ } catch {
126
+ return false;
127
+ }
128
+ const rewritten = JSON.stringify(
129
+ rewriteValue(JSON.parse(raw), origin, target),
130
+ null,
131
+ 2,
132
+ );
133
+ if (rewritten === JSON.stringify(JSON.parse(raw), null, 2)) return false;
134
+ await writeFileAtomic(path, rewritten + "\n", { mode: 0o600 });
135
+ return true;
136
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Backend session stores — the conversations themselves.
3
+ *
4
+ * Talon's database knows which session a chat is on; the transcript that
5
+ * session resumes from lives with the backend that ran it, outside the
6
+ * Talon home. A snapshot without these restores every chat to a session
7
+ * id that points at nothing, so they are captured here:
8
+ *
9
+ * - Claude Agent SDK: `~/.claude/projects/<slug>/` for every cwd Talon
10
+ * runs sessions in — the workspace and agent-workspace/, plus any
11
+ * directory beneath them (the slug is the path with every
12
+ * non-alphanumeric character turned into `-`, so a subdirectory's
13
+ * slug extends its parent's).
14
+ * - Codex: `$CODEX_HOME/sessions` (default `~/.codex`).
15
+ * - OpenCode / Kilo: `$XDG_DATA_HOME/<name>/storage` plus the session
16
+ * database, copied through SQLite rather than byte-wise.
17
+ * - Antigravity (agy): the CLI's conversations, brain and summaries.
18
+ *
19
+ * Only stores of backends the config enables are captured — a Codex
20
+ * install used for unrelated work is not Talon's state. Claude is always
21
+ * considered: it is the default backend and its store is scoped by cwd.
22
+ *
23
+ * Pure discovery: this reads directory listings, never file contents.
24
+ */
25
+
26
+ import { readdir, stat } from "node:fs/promises";
27
+ import { join } from "node:path";
28
+ import type { ExternalRoot } from "../types.js";
29
+
30
+ /** What discovery needs to know about this machine. */
31
+ export type SourceContext = {
32
+ /** Talon home (`~/.talon`). */
33
+ home: string;
34
+ /** The OS user's home; null = do not look outside the Talon home. */
35
+ userHome: string | null;
36
+ env: Readonly<Record<string, string | undefined>>;
37
+ /** Parsed config.json (untyped: the snapshot reads what it archives). */
38
+ config: Record<string, unknown>;
39
+ };
40
+
41
+ /** Claude Code's project directory name for a cwd. */
42
+ export function claudeProjectSlug(cwd: string): string {
43
+ return cwd.replace(/[^A-Za-z0-9]/g, "-");
44
+ }
45
+
46
+ /** Where Claude Code keeps per-cwd transcripts. */
47
+ export function claudeProjectsDir(
48
+ userHome: string,
49
+ env: SourceContext["env"],
50
+ ): string {
51
+ const configDir = env.CLAUDE_CONFIG_DIR?.trim();
52
+ return join(configDir || join(userHome, ".claude"), "projects");
53
+ }
54
+
55
+ /** The cwds Talon starts Claude sessions in. */
56
+ export function sessionCwds(home: string): string[] {
57
+ return [join(home, "workspace"), join(home, "agent-workspace")];
58
+ }
59
+
60
+ /** Every backend id the config can route a turn to. */
61
+ function enabledBackends(config: Record<string, unknown>): Set<string> {
62
+ const ids = new Set<string>();
63
+ for (const key of [
64
+ "backend",
65
+ "defaultBackend",
66
+ "heartbeatBackend",
67
+ "dreamBackend",
68
+ ]) {
69
+ const value = config[key];
70
+ if (typeof value === "string") ids.add(value);
71
+ }
72
+ const list = config.enabledBackends;
73
+ if (Array.isArray(list)) {
74
+ for (const id of list) if (typeof id === "string") ids.add(id);
75
+ }
76
+ return ids;
77
+ }
78
+
79
+ export async function pathExists(path: string): Promise<boolean> {
80
+ try {
81
+ await stat(path);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /** Claude project directories for the Talon cwds, sorted for a stable plan. */
89
+ async function claudeRoots(
90
+ userHome: string,
91
+ ctx: SourceContext,
92
+ ): Promise<ExternalRoot[]> {
93
+ const projects = claudeProjectsDir(userHome, ctx.env);
94
+ let names: string[];
95
+ try {
96
+ names = await readdir(projects);
97
+ } catch {
98
+ return [];
99
+ }
100
+ const slugs = sessionCwds(ctx.home).map(claudeProjectSlug);
101
+ return names
102
+ .filter((name) =>
103
+ slugs.some((slug) => name === slug || name.startsWith(`${slug}-`)),
104
+ )
105
+ .sort()
106
+ .map((name) => ({
107
+ root: `sessions/claude/${name}`,
108
+ source: join(projects, name),
109
+ kind: "claude-project" as const,
110
+ }));
111
+ }
112
+
113
+ /** One backend's store: directories plus (optionally) SQLite files. */
114
+ type StoreSpec = {
115
+ backend: string;
116
+ dir: (userHome: string, env: SourceContext["env"]) => string;
117
+ dirs: readonly string[];
118
+ databases: readonly string[];
119
+ };
120
+
121
+ const xdgData = (userHome: string, env: SourceContext["env"]): string =>
122
+ env.XDG_DATA_HOME?.trim() || join(userHome, ".local", "share");
123
+
124
+ const STORES: readonly StoreSpec[] = [
125
+ {
126
+ backend: "codex",
127
+ dir: (userHome, env) => env.CODEX_HOME?.trim() || join(userHome, ".codex"),
128
+ dirs: ["sessions"],
129
+ databases: [],
130
+ },
131
+ {
132
+ backend: "opencode",
133
+ dir: (userHome, env) => join(xdgData(userHome, env), "opencode"),
134
+ dirs: ["storage"],
135
+ databases: ["opencode.db"],
136
+ },
137
+ {
138
+ backend: "kilo",
139
+ dir: (userHome, env) => join(xdgData(userHome, env), "kilo"),
140
+ dirs: ["storage"],
141
+ databases: ["kilo.db"],
142
+ },
143
+ {
144
+ backend: "agy",
145
+ dir: (userHome) => join(userHome, ".gemini", "antigravity-cli"),
146
+ dirs: ["conversations", "brain", "annotations", "implicit"],
147
+ databases: ["conversation_summaries.db"],
148
+ },
149
+ ];
150
+
151
+ async function storeRoots(
152
+ spec: StoreSpec,
153
+ userHome: string,
154
+ env: SourceContext["env"],
155
+ ): Promise<ExternalRoot[]> {
156
+ const base = spec.dir(userHome, env);
157
+ const roots: ExternalRoot[] = [];
158
+ for (const name of spec.dirs) {
159
+ const source = join(base, name);
160
+ if (await pathExists(source)) {
161
+ roots.push({
162
+ root: `sessions/${spec.backend}/${name}`,
163
+ source,
164
+ kind: "session-store",
165
+ });
166
+ }
167
+ }
168
+ for (const name of spec.databases) {
169
+ const source = join(base, name);
170
+ if (await pathExists(source)) {
171
+ roots.push({
172
+ root: `sessions/${spec.backend}/${name}`,
173
+ source,
174
+ kind: "session-store",
175
+ sqlite: true,
176
+ });
177
+ }
178
+ }
179
+ return roots;
180
+ }
181
+
182
+ /**
183
+ * Every session root this machine has for the configured backends, in a
184
+ * deterministic order. Nothing outside the Talon home is looked at when
185
+ * `userHome` is null (tests that only fake a Talon home).
186
+ */
187
+ export async function discoverSessionRoots(
188
+ ctx: SourceContext,
189
+ ): Promise<ExternalRoot[]> {
190
+ if (!ctx.userHome) return [];
191
+ const roots = await claudeRoots(ctx.userHome, ctx);
192
+ const backends = enabledBackends(ctx.config);
193
+ for (const spec of STORES) {
194
+ if (!backends.has(spec.backend)) continue;
195
+ roots.push(...(await storeRoots(spec, ctx.userHome, ctx.env)));
196
+ }
197
+ return roots;
198
+ }
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * backups/<id>/manifest.json what this snapshot is
8
8
  * backups/<id>/state.tar.zst identity + state + database
9
+ * backups/<id>/logins.tar.zst WhatsApp auth + userbot session
9
10
  * backups/<id>/palace-<hash12>.tar.zst the memory palace, when present
10
11
  *
11
12
  * The manifest on disk is authoritative. SQLite is a cache so `/backup`
@@ -100,10 +101,11 @@ export async function writeManifest(
100
101
  home: string = dirs.root,
101
102
  ): Promise<void> {
102
103
  const dir = snapshotDir(manifest.id, home);
103
- await mkdir(dir, { recursive: true });
104
+ await mkdir(dir, { recursive: true, mode: 0o700 });
104
105
  await writeFileAtomic(
105
106
  join(dir, MANIFEST_NAME),
106
107
  JSON.stringify(manifest, null, 2) + "\n",
108
+ { mode: 0o600 },
107
109
  );
108
110
  }
109
111
 
@@ -29,8 +29,40 @@ export type SnapshotPart = {
29
29
  * targets may skip the upload entirely.
30
30
  */
31
31
  contentAddressed?: boolean;
32
+ /** Written through archive/crypt.ts (name ends in `.enc`). */
33
+ encrypted?: boolean;
34
+ /**
35
+ * Kept on this machine only — never handed to a remote target. Login
36
+ * sessions (WhatsApp, the userbot) are, unless `backup.loginSessions`
37
+ * is "remote". A restore from a remote copy skips a missing local-only
38
+ * part instead of failing.
39
+ */
40
+ localOnly?: boolean;
41
+ };
42
+
43
+ /**
44
+ * The MAC over a manifest (see archive/manifest-auth.ts). Present on
45
+ * every snapshot written with a backup passphrase.
46
+ */
47
+ export type ManifestAuth = {
48
+ v: 1;
49
+ alg: "hmac-sha256";
50
+ kdf: "scrypt";
51
+ log2N: number;
52
+ r: number;
53
+ p: number;
54
+ /** base64 */
55
+ salt: string;
56
+ /** base64 */
57
+ mac: string;
32
58
  };
33
59
 
60
+ /**
61
+ * Where WhatsApp and userbot login sessions may go: nowhere, the local
62
+ * snapshot store only, or also to remote targets.
63
+ */
64
+ type LoginSessionsPolicy = "off" | "local" | "remote";
65
+
34
66
  /** Per-target upload state, mirrored into the `backup_remotes` table. */
35
67
  export type RemoteState = {
36
68
  status: "pending" | "uploaded" | "failed";
@@ -42,6 +74,29 @@ export type RemoteState = {
42
74
  /** Where an `extra/<n>/…` subtree came from, so restore can put it back. */
43
75
  type ExtraMapping = { n: number; source: string };
44
76
 
77
+ /**
78
+ * A root captured from outside the Talon home — a backend's session
79
+ * store, a plugin checkout. `source` is the absolute path at snapshot
80
+ * time; a clone rewrites it for the new machine (see sources/relocate.ts).
81
+ */
82
+ export type ExternalRoot = {
83
+ /** Archive path of the root (`sessions/claude/<slug>`, `plugin-src/<n>-<name>`). */
84
+ root: string;
85
+ source: string;
86
+ /** How a clone relocates it. */
87
+ kind: "claude-project" | "session-store" | "plugin";
88
+ /** A SQLite file captured via `VACUUM INTO`, not copied byte-wise. */
89
+ sqlite?: boolean;
90
+ };
91
+
92
+ /** The paths a snapshot was taken against — what a clone rewrites from. */
93
+ export type SnapshotOrigin = {
94
+ /** The operating-system user's home directory. */
95
+ userHome: string;
96
+ /** The Talon home (`~/.talon`). */
97
+ home: string;
98
+ };
99
+
45
100
  export type Manifest = {
46
101
  schema: 1;
47
102
  id: string;
@@ -60,11 +115,16 @@ export type Manifest = {
60
115
  excludes: string[];
61
116
  /** `extra/<n>` → absolute source path. */
62
117
  extras?: ExtraMapping[];
118
+ /** Session stores and plugin sources from outside the Talon home. */
119
+ external?: ExternalRoot[];
120
+ origin?: SnapshotOrigin;
63
121
  /** Tree fingerprint of the palace part, for content-addressed reuse. */
64
122
  palaceHash?: string;
65
123
  /** Total bytes of all parts. */
66
124
  sizeBytes: number;
67
125
  remote: Record<string, RemoteState>;
126
+ /** MAC under the backup passphrase; absent on plaintext/legacy snapshots. */
127
+ auth?: ManifestAuth;
68
128
  };
69
129
 
70
130
  /** A snapshot as the listing surfaces show it. */
@@ -87,10 +147,16 @@ export type BackupSettings = {
87
147
  keepLocal: number;
88
148
  keepRemote: number;
89
149
  includePalace: boolean;
150
+ /** WhatsApp auth + userbot session: see {@link LoginSessionsPolicy}. */
151
+ loginSessions: LoginSessionsPolicy;
152
+ /** Backend session transcripts and traces, in a part of their own. */
153
+ includeSessions: boolean;
90
154
  workspaceInclude: readonly string[];
91
155
  extraPaths: readonly string[];
92
156
  /** Unset = every registered target; `[]` = local only. */
93
157
  targets?: readonly string[];
94
158
  checkpointBeforeUpdate: boolean;
95
159
  notifyChatId?: string;
160
+ /** Present = snapshots must be encrypted (see passphrase.ts). */
161
+ encryption?: { passphraseFile?: string };
96
162
  };