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.
- package/README.md +10 -6
- package/package.json +1 -1
- package/src/app.ts +78 -0
- package/src/backend/agy/auth.ts +128 -0
- package/src/backend/agy/constants.ts +88 -0
- package/src/backend/agy/doctor.ts +161 -0
- package/src/backend/agy/effort.ts +76 -0
- package/src/backend/agy/events.ts +402 -0
- package/src/backend/agy/factory.ts +137 -0
- package/src/backend/agy/handler/index.ts +9 -0
- package/src/backend/agy/handler/message.ts +444 -0
- package/src/backend/agy/init.ts +64 -0
- package/src/backend/agy/mcp/config.ts +336 -0
- package/src/backend/agy/mcp/register.ts +134 -0
- package/src/backend/agy/models.ts +335 -0
- package/src/backend/agy/one-shot.ts +301 -0
- package/src/backend/agy/process/child.ts +378 -0
- package/src/backend/agy/process/orphans.ts +106 -0
- package/src/backend/agy/sessions.ts +90 -0
- package/src/backend/agy/state.ts +77 -0
- package/src/backend/builtins.ts +1 -0
- 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/config-view.ts +5 -0
- package/src/cli/config.ts +4 -2
- package/src/cli/events.ts +14 -0
- package/src/cli/index.ts +64 -45
- package/src/cli/setup.ts +49 -0
- package/src/core/agent-runtime/model-ref.ts +1 -0
- 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 +53 -0
- 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 +3 -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/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 +1 -0
- /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
|
+
}
|
package/src/cli/config-view.ts
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
await
|
|
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
|
-
|
|
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,
|