talon-agent 5.2.2 → 5.4.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 (74) hide show
  1. package/README.md +10 -6
  2. package/package.json +1 -1
  3. package/src/app.ts +78 -0
  4. package/src/backend/agy/auth.ts +128 -0
  5. package/src/backend/agy/constants.ts +88 -0
  6. package/src/backend/agy/doctor.ts +161 -0
  7. package/src/backend/agy/effort.ts +76 -0
  8. package/src/backend/agy/events.ts +402 -0
  9. package/src/backend/agy/factory.ts +137 -0
  10. package/src/backend/agy/handler/index.ts +9 -0
  11. package/src/backend/agy/handler/message.ts +444 -0
  12. package/src/backend/agy/init.ts +64 -0
  13. package/src/backend/agy/mcp/config.ts +336 -0
  14. package/src/backend/agy/mcp/register.ts +134 -0
  15. package/src/backend/agy/models.ts +335 -0
  16. package/src/backend/agy/one-shot.ts +301 -0
  17. package/src/backend/agy/process/child.ts +378 -0
  18. package/src/backend/agy/process/orphans.ts +106 -0
  19. package/src/backend/agy/sessions.ts +90 -0
  20. package/src/backend/agy/state.ts +77 -0
  21. package/src/backend/builtins.ts +1 -0
  22. package/src/backend/codex/mcp-config.ts +1 -1
  23. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  24. package/src/backend/runtime/index.ts +1 -1
  25. package/src/cli/commands/backup.ts +396 -0
  26. package/src/cli/config-view.ts +5 -0
  27. package/src/cli/config.ts +4 -2
  28. package/src/cli/events.ts +14 -0
  29. package/src/cli/index.ts +64 -45
  30. package/src/cli/setup.ts +49 -0
  31. package/src/core/agent-runtime/model-ref.ts +1 -0
  32. package/src/core/backup/archive/digest.ts +77 -0
  33. package/src/core/backup/archive/tar.ts +567 -0
  34. package/src/core/backup/archive/zstd.ts +31 -0
  35. package/src/core/backup/index.ts +54 -0
  36. package/src/core/backup/plan.ts +273 -0
  37. package/src/core/backup/restore.ts +410 -0
  38. package/src/core/backup/scheduler.ts +357 -0
  39. package/src/core/backup/snapshot.ts +408 -0
  40. package/src/core/backup/status.ts +194 -0
  41. package/src/core/backup/store.ts +312 -0
  42. package/src/core/backup/targets.ts +281 -0
  43. package/src/core/backup/types.ts +96 -0
  44. package/src/core/backup/upload.ts +172 -0
  45. package/src/core/bus/events.ts +45 -1
  46. package/src/core/config/index.ts +53 -0
  47. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  48. package/src/core/engine/gateway-actions/index.ts +4 -0
  49. package/src/core/mcp-hub/talon-server.ts +1 -1
  50. package/src/core/plugin/actions.ts +34 -0
  51. package/src/core/plugin/index.ts +5 -1
  52. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  53. package/src/core/tools/index.ts +2 -0
  54. package/src/core/tools/ops/backup.ts +67 -0
  55. package/src/core/tools/types.ts +2 -1
  56. package/src/core/update/self-update.ts +3 -0
  57. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  58. package/src/frontend/discord/commands/backup.ts +203 -0
  59. package/src/frontend/discord/commands/definitions.ts +35 -0
  60. package/src/frontend/discord/commands/router.ts +3 -0
  61. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  62. package/src/frontend/telegram/callbacks/index.ts +8 -0
  63. package/src/frontend/telegram/commands/backup.ts +209 -0
  64. package/src/frontend/telegram/commands/definitions.ts +4 -0
  65. package/src/frontend/telegram/commands/index.ts +2 -0
  66. package/src/storage/backup/index.ts +82 -0
  67. package/src/storage/backup/repo.ts +164 -0
  68. package/src/storage/db.ts +20 -0
  69. package/src/storage/sql/backups.sql +46 -0
  70. package/src/storage/sql/db.sql +8 -0
  71. package/src/storage/sql/schema.sql +30 -0
  72. package/src/storage/sql/statements.generated.ts +60 -1
  73. package/src/util/log.ts +1 -0
  74. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -0,0 +1,396 @@
1
+ /**
2
+ * `talon backup` — snapshots and checkpoints from the outside.
3
+ *
4
+ * Two execution modes, chosen per subcommand:
5
+ *
6
+ * - Through the daemon (`now`, `targets`, `status`) when one is
7
+ * running. The daemon has the plugins loaded, so it is the only
8
+ * process that can see the remote targets — and routing through it
9
+ * also means one writer, not two racing `VACUUM INTO`s.
10
+ * - In this process (`list`, `show`, `pin`, `unpin`, `prune`, and
11
+ * `now` with no daemon) — they read the manifests on disk and the
12
+ * index in talon.db, both of which are safe to touch concurrently.
13
+ *
14
+ * `restore` refuses while the daemon is running, full stop: it replaces
15
+ * the database and the memory under a live process. Stop Talon first, or
16
+ * use `/backup restore <id>` from chat, which stages the request and
17
+ * lets the next boot apply it before anything opens the database.
18
+ *
19
+ * Lives in `commands/` because that is where docs/structure.md's worklist
20
+ * item 8 puts every CLI command; `src/cli/` itself is at its file ceiling.
21
+ */
22
+
23
+ import pc from "picocolors";
24
+ import { createInterface } from "node:readline/promises";
25
+ import { getFrontends, loadConfig } from "../../core/config/index.js";
26
+ import { findRunningInstance } from "../../core/daemon/discovery.js";
27
+ import {
28
+ collectBackupStatus,
29
+ formatBackupStatus,
30
+ formatBytes,
31
+ formatRelative,
32
+ isSnapshotId,
33
+ listSnapshots,
34
+ discoverTargets,
35
+ readManifest,
36
+ restoreSnapshot,
37
+ setSnapshotPinned,
38
+ type BackupTarget,
39
+ type SnapshotSummary,
40
+ } from "../../core/backup/index.js";
41
+ import { resolveBackupSettings } from "../../core/backup/plan.js";
42
+ import { buildSnapshot } from "../../core/backup/snapshot.js";
43
+ import { pruneLocal } from "../../core/backup/store.js";
44
+ import { fetchGateway } from "../daemon-api.js";
45
+
46
+ const USAGE = `
47
+ ${pc.bold("talon backup")} — snapshots and checkpoints
48
+
49
+ ${pc.cyan("now")} [--checkpoint <label>] [--pin] take a snapshot now
50
+ ${pc.cyan("list")} [--remote <targetId>] list snapshots
51
+ ${pc.cyan("show")} <id> everything about one snapshot
52
+ ${pc.cyan("pin")} <id> | ${pc.cyan("unpin")} <id> keep past retention, or release
53
+ ${pc.cyan("restore")} <id> [--from <target>] [--yes] restore (daemon must be stopped)
54
+ ${pc.cyan("prune")} apply the local retention policy
55
+ ${pc.cyan("targets")} remote targets and their readiness
56
+ ${pc.cyan("status")} schedule, sizes, targets
57
+ `;
58
+
59
+ type Flags = { values: string[]; flags: Map<string, string | true> };
60
+
61
+ /** `--from drive --yes x` → { values: ["x"], flags: { from: "drive", yes: true } } */
62
+ function parseArgs(argv: readonly string[]): Flags {
63
+ const values: string[] = [];
64
+ const flags = new Map<string, string | true>();
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const arg = argv[i];
67
+ if (!arg.startsWith("--")) {
68
+ values.push(arg);
69
+ continue;
70
+ }
71
+ const name = arg.slice(2);
72
+ const next = argv[i + 1];
73
+ if (next && !next.startsWith("--")) {
74
+ flags.set(name, next);
75
+ i++;
76
+ } else {
77
+ flags.set(name, true);
78
+ }
79
+ }
80
+ return { values, flags };
81
+ }
82
+
83
+ async function daemonPort(): Promise<number | null> {
84
+ const instance = await findRunningInstance();
85
+ return instance?.port ?? null;
86
+ }
87
+
88
+ /** Run one action inside the daemon. Returns null when there is no daemon. */
89
+ async function viaDaemon(
90
+ body: Record<string, unknown>,
91
+ ): Promise<{ ok: boolean; text?: string; error?: string } | null> {
92
+ const port = await daemonPort();
93
+ if (port === null) return null;
94
+ // A snapshot of a large workspace can take minutes; the default CLI
95
+ // budget is three seconds.
96
+ return (await fetchGateway(
97
+ port,
98
+ "/action",
99
+ {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify(body),
103
+ },
104
+ 15 * 60_000,
105
+ )) as { ok: boolean; text?: string; error?: string };
106
+ }
107
+
108
+ function renderResult(result: {
109
+ ok: boolean;
110
+ text?: string;
111
+ error?: string;
112
+ }): void {
113
+ if (result.ok) console.log(` ${result.text ?? "Done."}\n`);
114
+ else console.log(` ${pc.red("●")} ${result.error ?? "Failed."}\n`);
115
+ }
116
+
117
+ function renderRow(snapshot: SnapshotSummary): string {
118
+ const marks = [
119
+ snapshot.kind === "checkpoint"
120
+ ? pc.magenta("checkpoint")
121
+ : pc.dim("backup"),
122
+ snapshot.pinned ? pc.yellow("pinned") : "",
123
+ snapshot.local ? "" : pc.dim("(remote only)"),
124
+ ].filter(Boolean);
125
+ const remote = Object.entries(snapshot.remote)
126
+ .map(([id, entry]) =>
127
+ entry.status === "uploaded"
128
+ ? pc.green(id)
129
+ : pc.yellow(`${id}:${entry.status}`),
130
+ )
131
+ .join(" ");
132
+ return (
133
+ ` ${pc.bold(snapshot.id)} ${marks.join(" ")} ${formatBytes(snapshot.sizeBytes).padStart(9)}` +
134
+ ` ${pc.dim(formatRelative(snapshot.createdAt))}` +
135
+ (snapshot.label ? ` ${snapshot.label}` : "") +
136
+ (remote ? ` → ${remote}` : "")
137
+ );
138
+ }
139
+
140
+ // ── Subcommands ─────────────────────────────────────────────────────────────
141
+
142
+ async function backupNow(flags: Flags): Promise<void> {
143
+ const label =
144
+ typeof flags.flags.get("checkpoint") === "string"
145
+ ? String(flags.flags.get("checkpoint"))
146
+ : undefined;
147
+ const pin = flags.flags.get("pin") === true;
148
+ const remote = await viaDaemon({ action: "backup.now", label, pin });
149
+ if (remote) {
150
+ renderResult(remote);
151
+ return;
152
+ }
153
+ console.log(
154
+ ` ${pc.dim("Talon is not running — taking a local snapshot (no remote upload).")}`,
155
+ );
156
+ const settings = resolveBackupSettings(loadConfig().backup);
157
+ const manifest = await buildSnapshot({
158
+ kind: label ? "checkpoint" : "backup",
159
+ label,
160
+ pinned: pin,
161
+ settings,
162
+ });
163
+ await pruneLocal(settings.keepLocal);
164
+ console.log(
165
+ ` ${pc.green("●")} ${manifest.id} — ${manifest.parts.length} part(s), ` +
166
+ `${formatBytes(manifest.sizeBytes)}\n`,
167
+ );
168
+ }
169
+
170
+ async function backupList(flags: Flags): Promise<void> {
171
+ const target = flags.flags.get("remote");
172
+ const snapshots = await listSnapshots();
173
+ const filtered =
174
+ typeof target === "string"
175
+ ? snapshots.filter(
176
+ (snapshot) => snapshot.remote[target]?.status === "uploaded",
177
+ )
178
+ : snapshots;
179
+ if (filtered.length === 0) {
180
+ console.log(` ${pc.dim("No snapshots.")}\n`);
181
+ return;
182
+ }
183
+ console.log("");
184
+ for (const snapshot of filtered) console.log(renderRow(snapshot));
185
+ const bytes = filtered.reduce((sum, snapshot) => sum + snapshot.sizeBytes, 0);
186
+ console.log(
187
+ `\n ${pc.dim(`${filtered.length} snapshot(s), ${formatBytes(bytes)}`)}\n`,
188
+ );
189
+ }
190
+
191
+ async function backupShow(id: string): Promise<void> {
192
+ const manifest = await readManifest(id);
193
+ if (!manifest) {
194
+ console.log(` ${pc.red("●")} No snapshot ${id} on this machine.\n`);
195
+ return;
196
+ }
197
+ console.log(
198
+ `\n ${pc.bold(manifest.id)} ${manifest.kind}${manifest.pinned ? pc.yellow(" pinned") : ""}`,
199
+ );
200
+ if (manifest.label) console.log(` ${manifest.label}`);
201
+ console.log(
202
+ ` taken ${new Date(manifest.createdAt).toISOString()} (${formatRelative(manifest.createdAt)})`,
203
+ );
204
+ console.log(
205
+ ` host ${manifest.host} talon ${manifest.talonVersion}${manifest.gitHead ? ` @ ${manifest.gitHead}` : ""}`,
206
+ );
207
+ console.log(` size ${formatBytes(manifest.sizeBytes)}`);
208
+ console.log(
209
+ ` parts ${manifest.parts.map((part) => `${part.name} (${formatBytes(part.bytes)})`).join(", ")}`,
210
+ );
211
+ console.log(` includes ${manifest.includes.join(", ")}`);
212
+ if (manifest.extras?.length) {
213
+ console.log(
214
+ ` extras ${manifest.extras.map((extra) => `extra/${extra.n} ← ${extra.source}`).join(", ")}`,
215
+ );
216
+ }
217
+ for (const [targetId, state] of Object.entries(manifest.remote)) {
218
+ console.log(
219
+ ` remote ${targetId}: ${state.status}${state.error ? ` — ${state.error}` : ""}`,
220
+ );
221
+ }
222
+ console.log("");
223
+ }
224
+
225
+ async function backupPin(id: string, pinned: boolean): Promise<void> {
226
+ const ok = await setSnapshotPinned(id, pinned);
227
+ console.log(
228
+ ok
229
+ ? ` ${pc.green("●")} ${id} is ${pinned ? "pinned — retention will never prune it" : "unpinned"}\n`
230
+ : ` ${pc.red("●")} No snapshot ${id}\n`,
231
+ );
232
+ }
233
+
234
+ async function backupPrune(): Promise<void> {
235
+ const settings = resolveBackupSettings(loadConfig().backup);
236
+ const removed = await pruneLocal(settings.keepLocal);
237
+ console.log(
238
+ removed.length === 0
239
+ ? ` ${pc.dim(`Nothing to prune (keepLocal=${settings.keepLocal}).`)}\n`
240
+ : ` ${pc.green("●")} Pruned ${removed.length}: ${removed.join(", ")}\n`,
241
+ );
242
+ }
243
+
244
+ async function backupTargets(): Promise<void> {
245
+ const result = await viaDaemon({ action: "backup.status" });
246
+ if (!result) {
247
+ console.log(
248
+ ` ${pc.yellow("●")} Talon is not running — remote targets live in the daemon's plugins.\n`,
249
+ );
250
+ return;
251
+ }
252
+ renderResult(result);
253
+ }
254
+
255
+ async function backupStatus(): Promise<void> {
256
+ const result = await viaDaemon({ action: "backup.status" });
257
+ if (result) {
258
+ renderResult(result);
259
+ return;
260
+ }
261
+ const status = await collectBackupStatus({ withTargets: false });
262
+ console.log(
263
+ `\n${formatBackupStatus(status)
264
+ .split("\n")
265
+ .map((line) => ` ${line}`)
266
+ .join("\n")}\n`,
267
+ );
268
+ }
269
+
270
+ async function confirm(question: string): Promise<boolean> {
271
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
272
+ try {
273
+ return (await rl.question(question)).trim().toLowerCase() === "yes";
274
+ } finally {
275
+ rl.close();
276
+ }
277
+ }
278
+
279
+ async function backupRestore(id: string, flags: Flags): Promise<void> {
280
+ if (!isSnapshotId(id)) {
281
+ console.log(` ${pc.red("●")} "${id}" is not a snapshot id.\n`);
282
+ return;
283
+ }
284
+ const instance = await findRunningInstance();
285
+ if (instance) {
286
+ console.log(
287
+ ` ${pc.red("●")} Talon is running (PID ${instance.pid}). A restore replaces the\n` +
288
+ ` database and memory underneath it. Run ${pc.cyan("talon stop")} first, or use\n` +
289
+ ` ${pc.cyan(`/backup restore ${id}`)} from chat — that stages the restore and applies\n` +
290
+ ` it during the next boot.\n`,
291
+ );
292
+ process.exitCode = 1;
293
+ return;
294
+ }
295
+ const manifest = await readManifest(id);
296
+ const from = flags.flags.get("from");
297
+ if (!manifest && typeof from !== "string") {
298
+ console.log(
299
+ ` ${pc.red("●")} No snapshot ${id} on this machine (use --from <target> to fetch it).\n`,
300
+ );
301
+ process.exitCode = 1;
302
+ return;
303
+ }
304
+ if (flags.flags.get("yes") !== true) {
305
+ console.log(
306
+ `\n This replaces config, prompts, keys, sessions, the database and memory\n` +
307
+ ` with the contents of ${pc.bold(id)}${manifest?.label ? ` ("${manifest.label}")` : ""}.\n` +
308
+ ` A pinned checkpoint of the current state is taken first.\n`,
309
+ );
310
+ if (!(await confirm(` Type ${pc.bold("yes")} to continue: `))) {
311
+ console.log(` ${pc.dim("Cancelled.")}\n`);
312
+ return;
313
+ }
314
+ }
315
+ const settings = resolveBackupSettings(loadConfig().backup);
316
+ const target =
317
+ typeof from === "string" ? await resolveTarget(from) : undefined;
318
+ const report = await restoreSnapshot({ id, settings, target });
319
+ const written = Object.entries(report.written)
320
+ .map(([root, count]) => `${root} (${count})`)
321
+ .join(", ");
322
+ console.log(`\n ${pc.green("●")} Restored ${report.id}`);
323
+ if (report.checkpointId) {
324
+ console.log(
325
+ ` Previous state saved as ${pc.bold(report.checkpointId)} (pinned).`,
326
+ );
327
+ }
328
+ console.log(` Written: ${written || "nothing"}`);
329
+ console.log(` Removed: ${report.removed} file(s)`);
330
+ console.log(
331
+ ` Database: ${report.databaseReplaced ? "replaced" : "left in place"}\n`,
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Find a target by id. Targets are plugins, and a restore runs with the
337
+ * daemon stopped, so this process loads them itself — the same call
338
+ * bootstrap makes, minus the frontends.
339
+ */
340
+ async function resolveTarget(id: string): Promise<BackupTarget | undefined> {
341
+ const config = loadConfig();
342
+ const { loadPlugins } = await import("../../core/plugin/index.js");
343
+ await loadPlugins(config.plugins, getFrontends(config));
344
+ const targets = await discoverTargets();
345
+ const target = targets.find((candidate) => candidate.id === id);
346
+ if (!target) {
347
+ console.log(
348
+ ` ${pc.red("●")} No backup target "${id}"` +
349
+ (targets.length > 0
350
+ ? ` — loaded: ${targets.map((t) => t.id).join(", ")}\n`
351
+ : " — no target plugins are configured.\n"),
352
+ );
353
+ }
354
+ return target;
355
+ }
356
+
357
+ // ── Entry ───────────────────────────────────────────────────────────────────
358
+
359
+ export async function runBackupCommand(argv: readonly string[]): Promise<void> {
360
+ const { values, flags } = parseArgs(argv);
361
+ const [subcommand, id] = values;
362
+ const args: Flags = { values, flags };
363
+ switch (subcommand) {
364
+ case "now":
365
+ await backupNow(args);
366
+ return;
367
+ case "list":
368
+ await backupList(args);
369
+ return;
370
+ case "show":
371
+ if (!id) return void console.log(USAGE);
372
+ await backupShow(id);
373
+ return;
374
+ case "pin":
375
+ case "unpin":
376
+ if (!id) return void console.log(USAGE);
377
+ await backupPin(id, subcommand === "pin");
378
+ return;
379
+ case "restore":
380
+ if (!id) return void console.log(USAGE);
381
+ await backupRestore(id, args);
382
+ return;
383
+ case "prune":
384
+ await backupPrune();
385
+ return;
386
+ case "targets":
387
+ await backupTargets();
388
+ return;
389
+ case "status":
390
+ case undefined:
391
+ await backupStatus();
392
+ return;
393
+ default:
394
+ console.log(USAGE);
395
+ }
396
+ }
@@ -54,6 +54,7 @@ export async function viewConfig(): Promise<void> {
54
54
  kilo: "Kilo (@kilocode/sdk)",
55
55
  opencode: "OpenCode (@opencode-ai/sdk)",
56
56
  codex: "OpenAI Codex CLI",
57
+ agy: "Google Antigravity CLI",
57
58
  "openai-agents": "OpenAI Agents (@openai/agents)",
58
59
  };
59
60
  console.log(
@@ -63,6 +64,10 @@ export async function viewConfig(): Promise<void> {
63
64
  console.log(
64
65
  ` ${pc.dim("Claude binary")} ${pc.green(config.claudeBinary)}`,
65
66
  );
67
+ if (config.agyBinary)
68
+ console.log(
69
+ ` ${pc.dim("Antigravity bin")} ${pc.green(config.agyBinary)}`,
70
+ );
66
71
  if (config.codexApiKey)
67
72
  console.log(
68
73
  ` ${pc.dim("Codex API key")} ${maskToken(config.codexApiKey)}`,
package/src/cli/config.ts CHANGED
@@ -27,10 +27,12 @@ export function printBanner(): void {
27
27
  */
28
28
  export type Config = {
29
29
  frontend: string | string[];
30
- /** Active backend (`claude` / `kilo` / `opencode` / `codex` / `openai-agents`). */
31
- backend?: "claude" | "kilo" | "opencode" | "codex" | "openai-agents";
30
+ /** Active backend (`claude` / `kilo` / `opencode` / `codex` / `agy` / `openai-agents`). */
31
+ backend?: "claude" | "kilo" | "opencode" | "codex" | "agy" | "openai-agents";
32
32
  botToken?: string;
33
33
  claudeBinary?: string;
34
+ /** Path to the Antigravity `agy` executable. */
35
+ agyBinary?: string;
34
36
  /** Codex-specific OpenAI API key. */
35
37
  codexApiKey?: string;
36
38
  /** OpenAI API key — used by OpenAI Agents and legacy Codex config. */
package/src/cli/events.ts CHANGED
@@ -44,6 +44,20 @@ function describe(event: TalonEvent): string {
44
44
  return `${event.agentId} "${event.label}" → ${event.state} (${event.durationMs}ms)`;
45
45
  case "agent.message":
46
46
  return `${event.from} → ${event.to} (${event.kind})`;
47
+ case "backup.started":
48
+ return `${event.kind} (${event.trigger})`;
49
+ case "backup.completed":
50
+ return (
51
+ `${event.snapshotId} ${event.kind} ${event.parts} part(s) ` +
52
+ `${(event.sizeBytes / 1024 / 1024).toFixed(1)}MB (${event.durationMs}ms)`
53
+ );
54
+ case "backup.failed":
55
+ return `${event.trigger} failed (streak ${event.consecutiveFailures}): ${event.error}`;
56
+ case "backup.uploaded":
57
+ return (
58
+ `${event.snapshotId} → ${event.targetId} ` +
59
+ `${(event.bytes / 1024 / 1024).toFixed(1)}MB${event.deduplicated ? " (deduplicated)" : ""}`
60
+ );
47
61
  }
48
62
  }
49
63
 
package/src/cli/index.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  * - `chat` — terminal chat mode
15
15
  * - `daemon` — start/stop/restart outcome rendering
16
16
  * - `menu` — interactive main menu (no-subcommand default)
17
+ * - `commands/` — one module per command; new commands land here
18
+ * (docs/structure.md worklist item 8)
17
19
  */
18
20
 
19
21
  import pc from "picocolors";
@@ -36,6 +38,7 @@ import { runPluginCommand } from "./plugin.js";
36
38
  import { runSkillCommand } from "./skill.js";
37
39
  import { runMemoryCommand } from "./memory.js";
38
40
  import { mainMenu } from "./menu.js";
41
+ import { runBackupCommand } from "./commands/backup.js";
39
42
 
40
43
  export * from "./context.js";
41
44
  export * from "./config.js";
@@ -58,8 +61,63 @@ const CLI_COMMANDS = [
58
61
  "plugin",
59
62
  "skill",
60
63
  "memory",
64
+ "backup",
61
65
  ];
62
66
 
67
+ /** `talon events [-f] [--history [N]]` → the tail options. */
68
+ function eventsOptions(args: string[]): {
69
+ follow: boolean;
70
+ history?: number;
71
+ } {
72
+ const historyAt = args.findIndex((a) => a === "--history");
73
+ const next = historyAt >= 0 ? Number(args[historyAt + 1]) : NaN;
74
+ return {
75
+ follow: args.includes("-f") || args.includes("--follow"),
76
+ ...(historyAt >= 0
77
+ ? { history: Number.isInteger(next) && next > 0 ? next : 100 }
78
+ : {}),
79
+ };
80
+ }
81
+
82
+ /** The `talon --help` sheet. Its own function: `runCli` is a router. */
83
+ function printHelp(): void {
84
+ printBanner();
85
+ console.log(" Usage: talon [command]\n");
86
+ console.log(" Commands:");
87
+ console.log(` ${pc.cyan("setup")} Guided setup wizard`);
88
+ console.log(` ${pc.cyan("start")} Start as background daemon`);
89
+ console.log(` ${pc.cyan("stop")} Stop the daemon`);
90
+ console.log(` ${pc.cyan("restart")} Restart the daemon`);
91
+ console.log(` ${pc.cyan("run")} Run in foreground (attached)`);
92
+ console.log(` ${pc.cyan("chat")} Terminal chat mode`);
93
+ console.log(` ${pc.cyan("status")} Show bot health`);
94
+ console.log(
95
+ ` ${pc.cyan("ps")} List agent tasks (--all includes journal history)`,
96
+ );
97
+ console.log(` ${pc.cyan("kill")} Abort a killable task by id`);
98
+ console.log(
99
+ ` ${pc.cyan("events")} Tail the event bus (-f follows, --history [N] reads the journal)`,
100
+ );
101
+ console.log(
102
+ ` ${pc.cyan("plugin")} Manage plugins (install/enable/disable)`,
103
+ );
104
+ console.log(
105
+ ` ${pc.cyan("skill")} Manage skills (install/enable/disable)`,
106
+ );
107
+ console.log(
108
+ ` ${pc.cyan("memory")} Read/edit the memory store (list/search/import/render)`,
109
+ );
110
+ console.log(
111
+ ` ${pc.cyan("backup")} Snapshots and checkpoints (now/list/show/pin/restore)`,
112
+ );
113
+ console.log(` ${pc.cyan("config")} View/edit configuration`);
114
+ console.log(` ${pc.cyan("logs")} Tail log file`);
115
+ console.log(` ${pc.cyan("doctor")} Validate environment`);
116
+ console.log(` ${pc.cyan("--version")} Print the package version`);
117
+ console.log();
118
+ console.log(` Run ${pc.cyan("talon")} with no args for interactive menu.\n`);
119
+ }
120
+
63
121
  /** Route a `talon <command>` invocation. Called by the entry point. */
64
122
  export async function runCli(): Promise<void> {
65
123
  const command = process.argv[2];
@@ -105,18 +163,12 @@ export async function runCli(): Promise<void> {
105
163
  case "kill":
106
164
  await killTask(process.argv[3]);
107
165
  break;
108
- case "events": {
109
- const args = process.argv.slice(3);
110
- const historyAt = args.findIndex((a) => a === "--history");
111
- const next = historyAt >= 0 ? Number(args[historyAt + 1]) : NaN;
112
- await showEvents({
113
- follow: args.includes("-f") || args.includes("--follow"),
114
- ...(historyAt >= 0
115
- ? { history: Number.isInteger(next) && next > 0 ? next : 100 }
116
- : {}),
117
- });
166
+ case "events":
167
+ await showEvents(eventsOptions(process.argv.slice(3)));
168
+ break;
169
+ case "backup":
170
+ await runBackupCommand(process.argv.slice(3));
118
171
  break;
119
- }
120
172
  case "plugin":
121
173
  await runPluginCommand(process.argv.slice(3));
122
174
  break;
@@ -133,40 +185,7 @@ export async function runCli(): Promise<void> {
133
185
  }
134
186
  case "--help":
135
187
  case "-h":
136
- printBanner();
137
- console.log(" Usage: talon [command]\n");
138
- console.log(" Commands:");
139
- console.log(` ${pc.cyan("setup")} Guided setup wizard`);
140
- console.log(` ${pc.cyan("start")} Start as background daemon`);
141
- console.log(` ${pc.cyan("stop")} Stop the daemon`);
142
- console.log(` ${pc.cyan("restart")} Restart the daemon`);
143
- console.log(` ${pc.cyan("run")} Run in foreground (attached)`);
144
- console.log(` ${pc.cyan("chat")} Terminal chat mode`);
145
- console.log(` ${pc.cyan("status")} Show bot health`);
146
- console.log(
147
- ` ${pc.cyan("ps")} List agent tasks (--all includes journal history)`,
148
- );
149
- console.log(` ${pc.cyan("kill")} Abort a killable task by id`);
150
- console.log(
151
- ` ${pc.cyan("events")} Tail the event bus (-f follows, --history [N] reads the journal)`,
152
- );
153
- console.log(
154
- ` ${pc.cyan("plugin")} Manage plugins (install/enable/disable)`,
155
- );
156
- console.log(
157
- ` ${pc.cyan("skill")} Manage skills (install/enable/disable)`,
158
- );
159
- console.log(
160
- ` ${pc.cyan("memory")} Read/edit the memory store (list/search/import/render)`,
161
- );
162
- console.log(` ${pc.cyan("config")} View/edit configuration`);
163
- console.log(` ${pc.cyan("logs")} Tail log file`);
164
- console.log(` ${pc.cyan("doctor")} Validate environment`);
165
- console.log(` ${pc.cyan("--version")} Print the package version`);
166
- console.log();
167
- console.log(
168
- ` Run ${pc.cyan("talon")} with no args for interactive menu.\n`,
169
- );
188
+ printHelp();
170
189
  break;
171
190
  case undefined:
172
191
  mainMenu();
package/src/cli/setup.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  import * as p from "@clack/prompts";
7
7
  import pc from "picocolors";
8
8
  import { printBanner, loadConfig, saveConfig, type Config } from "./config.js";
9
+ import { binaryOnPath } from "../util/binary-on-path.js";
10
+ import { detectAgyAuth } from "../backend/agy/auth.js";
9
11
 
10
12
  /** Strip spaces, "+", brackets and dashes from a phone number. */
11
13
  const bareDigits = (raw: string) => raw.replace(/[\s+()-]/g, "");
@@ -393,6 +395,10 @@ async function askBackend(config: Config): Promise<Config["backend"]> {
393
395
  value: "codex",
394
396
  label: `Codex ${pc.dim("— OpenAI Codex CLI (@openai/codex)")}`,
395
397
  },
398
+ {
399
+ value: "agy",
400
+ label: `Antigravity ${pc.dim("— Google Antigravity CLI (agy, subscription OAuth)")}`,
401
+ },
396
402
  {
397
403
  value: "openai-agents",
398
404
  label: `OpenAI Agents ${pc.dim("— @openai/agents (OpenAI or any OpenAI-compatible endpoint)")}`,
@@ -405,6 +411,7 @@ async function askBackend(config: Config): Promise<Config["backend"]> {
405
411
  type BackendAnswers = Pick<
406
412
  SetupAnswers,
407
413
  | "claudeBinary"
414
+ | "agyBinary"
408
415
  | "codexApiKey"
409
416
  | "openaiApiKey"
410
417
  | "openaiBaseUrl"
@@ -434,6 +441,44 @@ async function askCodexBackend(config: Config): Promise<BackendAnswers> {
434
441
  return { codexApiKey: trimmedOrUndefined(codexApiKey) };
435
442
  }
436
443
 
444
+ /**
445
+ * Antigravity has no API key to collect — it authenticates through a
446
+ * consumer Google OAuth flow run once interactively, cached under
447
+ * `~/.gemini/antigravity-cli/`. All the wizard can usefully do is
448
+ * point at the binary and say where the login happens.
449
+ */
450
+ async function askAgyBackend(config: Config): Promise<BackendAnswers> {
451
+ const binary = await askOrExit(
452
+ p.text({
453
+ message: "Antigravity CLI binary path",
454
+ placeholder: "leave empty for default (agy on PATH)",
455
+ initialValue: config.agyBinary || "",
456
+ }),
457
+ );
458
+ const resolved = trimmedOrUndefined(binary);
459
+ const probe = resolved || "agy";
460
+ const installed = binaryOnPath(probe);
461
+ if (!installed) {
462
+ p.log.warn(
463
+ `${pc.yellow("!")} \`${probe}\` not found — install the Antigravity CLI ` +
464
+ `and make sure it is on PATH before starting Talon.`,
465
+ );
466
+ }
467
+ const auth = detectAgyAuth();
468
+ if (!auth.present) {
469
+ p.log.warn(
470
+ `${pc.yellow("!")} No cached Antigravity credentials. Run ${pc.cyan("agy")} ` +
471
+ `once on this host and complete the Google sign-in; headless runs ` +
472
+ `then reuse the cache. There is no API key for this backend.`,
473
+ );
474
+ } else {
475
+ p.log.success(
476
+ `Antigravity credentials found (${auth.method ?? "oauth"}) at ${auth.path}`,
477
+ );
478
+ }
479
+ return { agyBinary: resolved };
480
+ }
481
+
437
482
  async function askOpenAiAgentsBackend(config: Config): Promise<BackendAnswers> {
438
483
  const openaiApiKey = trimmedOrUndefined(
439
484
  await askOrExit(
@@ -494,6 +539,8 @@ async function askBackendCredentials(
494
539
  return askClaudeBackend(config);
495
540
  case "codex":
496
541
  return askCodexBackend(config);
542
+ case "agy":
543
+ return askAgyBackend(config);
497
544
  case "openai-agents":
498
545
  return askOpenAiAgentsBackend(config);
499
546
  default:
@@ -553,6 +600,7 @@ export type SetupAnswers = {
553
600
  backend: Config["backend"];
554
601
  botToken?: string;
555
602
  claudeBinary?: string;
603
+ agyBinary?: string;
556
604
  codexApiKey?: string;
557
605
  openaiApiKey?: string;
558
606
  openaiBaseUrl?: string;
@@ -612,6 +660,7 @@ export function buildSetupConfig(
612
660
  codexApiKey: ownsCredential("codex")
613
661
  ? answers.codexApiKey
614
662
  : existing.codexApiKey,
663
+ agyBinary: ownsCredential("agy") ? answers.agyBinary : existing.agyBinary,
615
664
  openaiApiKey: ownsCredential("openai-agents")
616
665
  ? answers.openaiApiKey
617
666
  : existing.openaiApiKey,