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
package/README.md CHANGED
@@ -8,11 +8,11 @@
8
8
  [![Bun](https://img.shields.io/badge/bun-1.3%2B-000000?logo=bun&logoColor=white)](https://bun.sh)
9
9
  [![TypeScript](https://img.shields.io/badge/TypeScript-7.0-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
10
10
  [![Frontends](https://img.shields.io/badge/frontends-Telegram_%7C_WhatsApp_%7C_Discord_%7C_Teams_%7C_Terminal_%7C_App-25D366)](#frontends)
11
- [![Backends](https://img.shields.io/badge/backends-Claude_%7C_Kilo_%7C_OpenCode_%7C_Codex_%7C_OpenAI_Agents-D97706)](#backends)
11
+ [![Backends](https://img.shields.io/badge/backends-Claude_%7C_Kilo_%7C_OpenCode_%7C_Codex_%7C_Antigravity_%7C_OpenAI_Agents-D97706)](#backends)
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
13
13
  [![CI](https://github.com/dylanneve1/talon/actions/workflows/ci.yml/badge.svg)](https://github.com/dylanneve1/talon/actions/workflows/ci.yml)
14
14
 
15
- Multi-platform agentic AI harness. Runs on **Telegram**, **WhatsApp**, **Discord**, **Microsoft Teams**, the **Terminal**, and a **cross-platform Desktop/Mobile companion app** (Flutter), with a pluggable backend (**Claude Agent SDK**, **Kilo**, **OpenCode**, **Codex**, or **OpenAI Agents**) and full tool access through MCP.
15
+ Multi-platform agentic AI harness. Runs on **Telegram**, **WhatsApp**, **Discord**, **Microsoft Teams**, the **Terminal**, and a **cross-platform Desktop/Mobile companion app** (Flutter), with a pluggable backend (**Claude Agent SDK**, **Kilo**, **OpenCode**, **Codex**, **Antigravity**, or **OpenAI Agents**) and full tool access through MCP.
16
16
 
17
17
  ---
18
18
 
@@ -21,7 +21,7 @@ Multi-platform agentic AI harness. Runs on **Telegram**, **WhatsApp**, **Discord
21
21
  | | |
22
22
  | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
23
23
  | **Multi-frontend** | Telegram (grammY + GramJS userbot), WhatsApp (Baileys multi-device), Discord (discord.js), Microsoft Teams (Bot Framework), Terminal with live tool visibility, and a **Desktop/Mobile app** (Flutter) over a local/remote bridge — one or several at once, see [Frontends](#frontends) |
24
- | **Pluggable backend** | Claude Agent SDK, Kilo, OpenCode, Codex, OpenAI Agents — selectable per-process via `backend` config. Streaming, model fallback, context-overflow recovery. |
24
+ | **Pluggable backend** | Claude Agent SDK, Kilo, OpenCode, Codex, Antigravity, OpenAI Agents — selectable per-process via `backend` config. Streaming, model fallback, context-overflow recovery. |
25
25
  | **MCP tools** | Messaging, media, history, search, web fetch, cron jobs, triggers, goals, stickers, file system, admin controls |
26
26
  | **Plugins** | Hot-reloadable plugin system with `talon plugin install/enable/disable` (npm, git, or local sources). Built-in: GitHub, MemPalace, Playwright, Brave Search |
27
27
  | **Background agents** | Heartbeat (hourly by default — advances goals, proactively messages when something matters) and Dream (memory consolidation + diary) |
@@ -63,6 +63,7 @@ npx talon chat # terminal chat mode
63
63
  - `kilo` backend: nothing extra — `@kilocode/sdk` spawns a local server. Free models are accessible without auth; routed models use Kilo's own credentials.
64
64
  - `opencode` backend: nothing extra — `@opencode-ai/sdk` spawns a local server.
65
65
  - `codex` backend: install the `codex` CLI (`npm i -g @openai/codex`) and authenticate with `codex login`, `CODEX_API_KEY`, `TALON_CODEX_KEY`, or `codexApiKey`. `OPENAI_API_KEY` is used only as a fallback when no Codex login exists.
66
+ - `agy` backend: install Google's Antigravity CLI so `agy` is on `PATH` (or set `agyBinary` / `AGY_BINARY`), then run `agy` **once interactively** on the host and complete the Google sign-in. There is no API key — headless runs reuse the cached OAuth credentials under `~/.gemini/antigravity-cli/`.
66
67
 
67
68
  ### Standalone binary
68
69
 
@@ -137,6 +138,7 @@ index.ts Composition root
137
138
  | +-- kilo/ Kilo HTTP server backend (streaming via SSE)
138
139
  | +-- opencode/ OpenCode HTTP server backend
139
140
  | +-- codex/ Codex CLI backend (`@openai/codex-sdk`)
141
+ | +-- agy/ Antigravity CLI backend (headless stream-json)
140
142
  | +-- openai-agents/ OpenAI Agents SDK backend (Responses API)
141
143
  |
142
144
  +-- frontend/
@@ -157,7 +159,7 @@ index.ts Composition root
157
159
  +-- util/ Config, logging, workspace, paths, time, runtime
158
160
  ```
159
161
 
160
- **Dependency rule:** `core/` imports nothing from `frontend/` or `backend/`. Frontends and backends depend on core types, never on each other. All five backends (Claude SDK, Kilo, OpenCode, Codex, OpenAI Agents) implement the same `Backend` capability interface from `core/agent-runtime/capabilities.ts`. Frontends mirror this: each implements the `Frontend` contract from `core/frontend-runtime/capabilities.ts` and self-registers in the frontend registry (identity + chat-id routing in a descriptor, lazy `create` in a per-frontend `factory.ts`) — see [docs/frontends.md](docs/frontends.md). Kilo and OpenCode additionally share the `remote-server/` infrastructure because they wrap forks of the same upstream HTTP agent server.
162
+ **Dependency rule:** `core/` imports nothing from `frontend/` or `backend/`. Frontends and backends depend on core types, never on each other. All six backends (Claude SDK, Kilo, OpenCode, Codex, Antigravity, OpenAI Agents) implement the same `Backend` capability interface from `core/agent-runtime/capabilities.ts`. Frontends mirror this: each implements the `Frontend` contract from `core/frontend-runtime/capabilities.ts` and self-registers in the frontend registry (identity + chat-id routing in a descriptor, lazy `create` in a per-frontend `factory.ts`) — see [docs/frontends.md](docs/frontends.md). Kilo and OpenCode additionally share the `remote-server/` infrastructure because they wrap forks of the same upstream HTTP agent server.
161
163
 
162
164
  **Prompts:** everything the model reads at session start is assembled by `core/prompt/` from the files in `prompts/` — see [prompts/README.md](prompts/README.md) for the assembly order, file ownership (user-editable vs package-owned templates), and the per-backend delivery contracts.
163
165
 
@@ -221,9 +223,10 @@ Select via the `backend` field in `~/.talon/config.json`. All backends implement
221
223
  | Kilo | `"kilo"` | Local HTTP server via `@kilocode/sdk` | SSE-streamed turns. Routes to many model providers via Kilo's auth. |
222
224
  | OpenCode | `"opencode"` | Local HTTP server via `@opencode-ai/sdk` | SSE-streamed turns; same MCP and session shape as Kilo (upstream fork). |
223
225
  | Codex | `"codex"` | Per-turn subprocess via `@openai/codex-sdk` | Requires the `codex` CLI from `@openai/codex` and Codex auth (`codex login`, `CODEX_API_KEY`, `TALON_CODEX_KEY`, or `codexApiKey`). MCP servers configured via TOML overrides at thread start. |
226
+ | Antigravity | `"agy"` | Long-lived per-chat subprocess (the `agy` CLI, headless stream-json) | Requires the Antigravity CLI on `PATH` (or `agyBinary` / `AGY_BINARY`) and a one-time interactive `agy` sign-in — subscription OAuth, no API key. MCP servers written into `~/.gemini/config/mcp_config.json` before the child spawns. |
224
227
  | OpenAI Agents | `"openai-agents"` | In-process via `@openai/agents` | Responses API (or any OpenAI-compatible endpoint via `TALON_AGENTS_URL` / `openaiBaseUrl`). Persistent per-chat MCP bundles. |
225
228
 
226
- The Kilo and OpenCode backends share infrastructure (`backend/remote-server/`) since the upstream HTTP API is the same; each backend supplies its own SDK client, port, and delivery suffix. Codex is its own integration on top of the Codex CLI's JSONL event stream.
229
+ The Kilo and OpenCode backends share infrastructure (`backend/remote-server/`) since the upstream HTTP API is the same; each backend supplies its own SDK client, port, and delivery suffix. Codex is its own integration on top of the Codex CLI's JSONL event stream, and Antigravity is its own on top of `agy`'s headless NDJSON protocol.
227
230
 
228
231
  ---
229
232
 
@@ -465,9 +468,10 @@ Config file: `~/.talon/config.json`
465
468
  | Field | Default | Description |
466
469
  | -------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------- |
467
470
  | `frontend` | `"telegram"` | `"telegram"`, `"whatsapp"`, `"discord"`, `"teams"`, `"terminal"`, `"native"`, or an array ([Frontends](#frontends)) |
468
- | `backend` | `"claude"` | `"claude"`, `"kilo"`, `"opencode"`, `"codex"`, or `"openai-agents"` |
471
+ | `backend` | `"claude"` | `"claude"`, `"kilo"`, `"opencode"`, `"codex"`, `"agy"`, or `"openai-agents"` |
469
472
  | `botToken` | --- | Telegram bot token |
470
473
  | `model` | `"default"` | Default model. Interpretation depends on the active backend. |
474
+ | `agyBinary` | --- | Path to the Antigravity `agy` executable. `AGY_BINARY` overrides it. |
471
475
  | `codexApiKey` | --- | Codex-only OpenAI API key. Prefer this over `openaiApiKey` for Codex API-key auth. `codex login` takes precedence over shared `openaiApiKey`. |
472
476
  | `concurrency` | `1` | Max concurrent AI queries (1--20) |
473
477
  | `pulse` | `true` | Periodic group engagement |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.2.2",
3
+ "version": "5.4.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
package/src/app.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  } from "./core/background/cron/scheduler.js";
28
28
  import { shutdownTriggers } from "./core/background/triggers/index.js";
29
29
  import { shutdownAgents } from "./core/agents/index.js";
30
+ import { stopBackupScheduler } from "./core/backup/index.js";
30
31
  import { pruneSettledTriggers } from "./storage/triggers.js";
31
32
  import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
32
33
  import {
@@ -77,11 +78,48 @@ import {
77
78
  // the successor will run is importable at all. Nothing has booted yet,
78
79
  // so this is both the strongest and the last harmless place to say so.
79
80
  // See core/update/self-update.ts for what a failure does instead.
81
+ //
82
+ // This stays ahead of the staged restore below: the smoke run must not
83
+ // touch state, and a restore is the single most destructive thing this
84
+ // file does.
80
85
  if (process.argv.includes(BOOT_SMOKE_FLAG)) {
81
86
  console.log(BOOT_SMOKE_OK);
82
87
  process.exit(0);
83
88
  }
84
89
 
90
+ /**
91
+ * A `/backup restore <id>` from chat writes ~/.talon/restore-pending.json
92
+ * and restarts. It is applied HERE, before anything else: every store
93
+ * below opens the database, and the database is one of the files this is
94
+ * about to replace. The pre-restore checkpoint inside needs the current
95
+ * database, so the handle is closed between the two — which is why the
96
+ * composition root, and not core/backup, owns that call.
97
+ *
98
+ * Never throws: a failed restore still boots the daemon (with the reason
99
+ * in the log and the request deleted, so the next boot is normal).
100
+ */
101
+ async function applyStagedRestore(): Promise<string | null> {
102
+ const { applyPendingRestore, readRestorePending } =
103
+ await import("./core/backup/index.js");
104
+ if (!(await readRestorePending())) return null;
105
+ const { loadConfig } = await import("./core/config/index.js");
106
+ const { resolveBackupSettings } = await import("./core/backup/plan.js");
107
+ const { closeDatabase } = await import("./storage/db.js");
108
+ const report = await applyPendingRestore({
109
+ settings: resolveBackupSettings(loadConfig().backup),
110
+ beforeApply: closeDatabase,
111
+ });
112
+ if (!report) return null;
113
+ return (
114
+ `♻️ Restored snapshot ${report.id}` +
115
+ (report.checkpointId
116
+ ? ` (previous state saved as checkpoint ${report.checkpointId})`
117
+ : "")
118
+ );
119
+ }
120
+
121
+ const restoreReport = await bootPhase("staged restore", applyStagedRestore);
122
+
85
123
  const { config } = await bootPhase("bootstrap", () => bootstrap());
86
124
 
87
125
  // Record this process as the daemon. The gateway port is appended once
@@ -131,6 +169,36 @@ onBackendChange((holder, newBackend, info) => {
131
169
 
132
170
  // ── Graceful shutdown ────────────────────────────────────────────────────────
133
171
 
172
+ /**
173
+ * Arm the backup scheduler. Here rather than in bootstrap because this is
174
+ * where the process lifecycle lives — `stopBackupScheduler` is two screens
175
+ * down in `gracefulShutdown`, and the two belong together. Failure
176
+ * notifications go to `backup.notifyChatId` when set, otherwise to the
177
+ * admin, over the same route as the plan alerts.
178
+ */
179
+ async function startBackups(): Promise<void> {
180
+ const { initBackup } = await import("./core/backup/index.js");
181
+ const { resolveBackupSettings } = await import("./core/backup/plan.js");
182
+ const notifyChatId = config.backup?.notifyChatId;
183
+ await initBackup({
184
+ settings: resolveBackupSettings(config.backup),
185
+ notify: notifyChatId
186
+ ? async (text: string) => {
187
+ const { resolveFrontendIdAmong } =
188
+ await import("./core/frontend-runtime/routing.js");
189
+ const name = resolveFrontendIdAmong(
190
+ notifyChatId,
191
+ frontends.map((frontend) => frontend.name),
192
+ );
193
+ const target =
194
+ frontends.find((frontend) => frontend.name === name) ??
195
+ frontends[0];
196
+ if (target) await target.sendMessage(Number(notifyChatId), text);
197
+ }
198
+ : undefined,
199
+ });
200
+ }
201
+
134
202
  let shuttingDown = false;
135
203
  let triggerPruneTimer: ReturnType<typeof setInterval> | null = null;
136
204
 
@@ -230,6 +298,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
230
298
  });
231
299
  }
232
300
  await shutdownStep("fuse layer", unmountNamespaceFs);
301
+ await shutdownStep("backup scheduler", stopBackupScheduler);
233
302
  await shutdownStep("pulse timer", stopPulseTimer);
234
303
  await shutdownStep("heartbeat", async () => {
235
304
  stopHeartbeatTimer();
@@ -298,6 +367,7 @@ async function main(): Promise<void> {
298
367
  await Promise.all(frontends.map((frontend) => frontend.init()));
299
368
  log("bot", "Starting Talon...");
300
369
 
370
+ await startBackups();
301
371
  if (config.pulse) startPulseTimer(config.pulseIntervalMs);
302
372
  if (config.heartbeat) startHeartbeatTimer(config.heartbeatIntervalMinutes);
303
373
  startWatchdog(config.workspace);
@@ -345,6 +415,14 @@ async function main(): Promise<void> {
345
415
  // Phase 0 accounting (docs/ts-migration-plan.md): the boot is over the
346
416
  // moment the frontends are listening, so the totals are folded into the
347
417
  // metrics store here, from the same uptime figure the log line prints.
418
+ // A restore applied at boot happened before any frontend existed, so the
419
+ // operator hears about it here, on the first channel that can carry it.
420
+ if (restoreReport) {
421
+ const { notifyAdmin } =
422
+ await import("./core/frontend-runtime/admin-notify.js");
423
+ await notifyAdmin(restoreReport);
424
+ }
425
+
348
426
  const bootMs = Math.round(process.uptime() * 1000);
349
427
  recordBootMetrics(bootMs);
350
428
  startResourceSampler();
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Antigravity authentication.
3
+ *
4
+ * There is no API key. agy authenticates through a consumer Google
5
+ * OAuth flow run once interactively, and caches the result at
6
+ * `~/.gemini/antigravity-cli/antigravity-oauth-token`. Headless runs
7
+ * reuse that cache; an unauthenticated non-interactive run exits with
8
+ * an `authentication required` error on stderr rather than hanging.
9
+ *
10
+ * Subscription-backed access with no key to configure is the entire
11
+ * point of this backend, so the only thing Talon can do about auth is
12
+ * read the cache, report it in `talon doctor`, and — when a turn
13
+ * fails for it — say plainly that the fix is running `agy` once on
14
+ * the host.
15
+ */
16
+
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { readFileSync } from "node:fs";
20
+
21
+ /** Where the CLI caches its OAuth token. Env override for tests. */
22
+ function agyTokenPath(override?: string): string {
23
+ return (
24
+ override ||
25
+ process.env.TALON_AGY_TOKEN_FILE ||
26
+ join(homedir(), ".gemini", "antigravity-cli", "antigravity-oauth-token")
27
+ );
28
+ }
29
+
30
+ export interface AgyAuthInfo {
31
+ /** The token file was found and parsed. */
32
+ present: boolean;
33
+ /** `auth_method` from the file — `consumer` on a personal account. */
34
+ method?: string;
35
+ /** Access-token expiry, when the file records one. */
36
+ expiry?: Date;
37
+ /** True when `expiry` is in the past. */
38
+ expired: boolean;
39
+ /** A refresh token is cached, so an expired access token self-heals. */
40
+ refreshable: boolean;
41
+ /** Why the file could not be read / parsed. */
42
+ problem?: string;
43
+ path: string;
44
+ }
45
+
46
+ function readExpiry(value: unknown): Date | undefined {
47
+ if (typeof value !== "string" || !value) return undefined;
48
+ const date = new Date(value);
49
+ return Number.isNaN(date.getTime()) ? undefined : date;
50
+ }
51
+
52
+ /**
53
+ * Read the cached OAuth token.
54
+ *
55
+ * The 1.2.x file nests the credential under `token`
56
+ * (`{access_token, refresh_token, expiry}`) with `auth_method` at the
57
+ * top level, but older builds wrote `expiry` / `refresh_token` flat.
58
+ * Both shapes are accepted so a doctor check doesn't go red on a
59
+ * layout change.
60
+ */
61
+ export function detectAgyAuth(tokenPath?: string): AgyAuthInfo {
62
+ const path = agyTokenPath(tokenPath);
63
+ const base: AgyAuthInfo = {
64
+ present: false,
65
+ expired: false,
66
+ refreshable: false,
67
+ path,
68
+ };
69
+ let raw: string;
70
+ try {
71
+ raw = readFileSync(path, "utf-8");
72
+ } catch {
73
+ return { ...base, problem: "no cached credentials" };
74
+ }
75
+ let parsed: Record<string, unknown>;
76
+ try {
77
+ const value: unknown = JSON.parse(raw);
78
+ if (!value || typeof value !== "object") throw new Error("not an object");
79
+ parsed = value as Record<string, unknown>;
80
+ } catch (err) {
81
+ return {
82
+ ...base,
83
+ problem: `token file is not valid JSON (${
84
+ err instanceof Error ? err.message : String(err)
85
+ })`,
86
+ };
87
+ }
88
+
89
+ const nested =
90
+ parsed.token && typeof parsed.token === "object"
91
+ ? (parsed.token as Record<string, unknown>)
92
+ : {};
93
+ const expiry = readExpiry(nested.expiry ?? parsed.expiry);
94
+ const refreshToken = nested.refresh_token ?? parsed.refresh_token;
95
+ return {
96
+ present: true,
97
+ ...(typeof parsed.auth_method === "string"
98
+ ? { method: parsed.auth_method }
99
+ : {}),
100
+ ...(expiry ? { expiry } : {}),
101
+ expired: expiry ? expiry.getTime() <= Date.now() : false,
102
+ refreshable: typeof refreshToken === "string" && refreshToken.length > 0,
103
+ path,
104
+ };
105
+ }
106
+
107
+ /** True for the stderr text an unauthenticated headless run produces. */
108
+ export function isAgyAuthFailure(text: string): boolean {
109
+ return /authentication required|not (?:yet )?authenticated|please (?:run|sign in|log in)/i.test(
110
+ text,
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Turn an auth failure into an error that names the fix. The raw CLI
116
+ * text is a bare `authentication required` with no hint that the
117
+ * remedy is an interactive login on the host.
118
+ */
119
+ export function agyAuthError(cause?: unknown): Error {
120
+ const err = new Error(
121
+ "Antigravity is not authenticated. Run `agy` once interactively on " +
122
+ "this host and complete the Google sign-in; headless runs then reuse " +
123
+ "the cached credentials at " +
124
+ `${agyTokenPath()}. There is no API key for this backend.`,
125
+ );
126
+ if (cause !== undefined) (err as Error & { cause?: unknown }).cause = cause;
127
+ return err;
128
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Antigravity (`agy`) backend constants.
3
+ *
4
+ * The CLI is Google's Antigravity agent driven in headless mode
5
+ * (`docs/agy/headless-docs.md`). Talon keeps one long-lived
6
+ * `--input-format stream-json` child per chat and feeds it one
7
+ * `user` event per turn; everything below is the vocabulary that
8
+ * child is spawned and framed with.
9
+ */
10
+
11
+ import { buildDeliveryContract } from "../runtime/prompt/delivery-contract.js";
12
+
13
+ /**
14
+ * System-prompt suffix appended to the user-configured system prompt.
15
+ *
16
+ * agy has no system-prompt flag at all, so the assembled prompt rides
17
+ * in as a fenced block on the FIRST turn of a conversation (see
18
+ * `handler/message.ts`); resumed conversations inherit it from their
19
+ * stored history. The contract itself is the shared text-or-tools one:
20
+ * agy streams `text_delta` for prose AND routes Talon's delivery tools
21
+ * through MCP, so both delivery routes are live.
22
+ *
23
+ * Tool names are frontend-specific (native's send tool is
24
+ * `send_message`, telegram's is `send`), so the suffix is built per
25
+ * chat from the chat's owning frontend.
26
+ */
27
+ export function agySystemPromptSuffix(frontend: string): string {
28
+ return `\n\n${buildDeliveryContract("text-or-tools", frontend)}\n`;
29
+ }
30
+
31
+ /** Telegram-shaped default, kept for the one-shot path and tests. */
32
+ export const AGY_SYSTEM_PROMPT_SUFFIX = agySystemPromptSuffix("telegram");
33
+
34
+ /**
35
+ * Default model when none is configured.
36
+ *
37
+ * `agy models` lists Gemini, Claude and GPT-OSS slugs; the Flash-High
38
+ * Gemini is the subscription-backed default the interactive CLI uses
39
+ * and the only one guaranteed present on a consumer account.
40
+ */
41
+ export const AGY_DEFAULT_MODEL = "gemini-3.8-flash-high";
42
+
43
+ /** Minimum `agy --version` the backend is known to work against. */
44
+ export const AGY_MIN_VERSION = "1.2.0";
45
+
46
+ /**
47
+ * Prefix every MCP server entry Talon writes into agy's shared
48
+ * `mcp_config.json` carries. Anything under this prefix is ours to
49
+ * add, rewrite and delete; anything else in that file belongs to the
50
+ * user and is preserved byte-for-byte.
51
+ */
52
+ export const AGY_MCP_PREFIX = "__talon__";
53
+
54
+ /**
55
+ * How long a per-chat child may sit idle before it is reaped. A warm
56
+ * child costs a few hundred MB of resident Go process; 10 minutes
57
+ * matches the MCP hub's own child TTL so a conversation that goes
58
+ * quiet releases both at roughly the same time.
59
+ */
60
+ export const AGY_IDLE_REAP_MS = 10 * 60 * 1000;
61
+
62
+ /** Grace between SIGTERM and SIGKILL when tearing a child down. */
63
+ export const AGY_KILL_GRACE_MS = 2000;
64
+
65
+ /**
66
+ * Fixed CLI flags every headless child is spawned with.
67
+ *
68
+ * - `--input-format stream-json` — many turns in one process; each
69
+ * `user` line on stdin runs one turn and yields one `result`.
70
+ * - `--output-format stream-json` — required by the input format,
71
+ * and the only shape that reports tools and usage live.
72
+ * - `--dangerously-skip-permissions` — headless means no operator to
73
+ * approve a tool; without it every shell/file call is soft-denied
74
+ * with a stderr notice and the turn quietly does nothing. Same
75
+ * posture as every other Talon backend (see
76
+ * `codex/constants.ts: CODEX_THREAD_PERMISSIONS`).
77
+ * - `--print-timeout 0s` — no ceiling on a turn; Talon owns
78
+ * turn timeouts, not the CLI.
79
+ */
80
+ export const AGY_BASE_ARGS: readonly string[] = [
81
+ "--input-format",
82
+ "stream-json",
83
+ "--output-format",
84
+ "stream-json",
85
+ "--dangerously-skip-permissions",
86
+ "--print-timeout",
87
+ "0s",
88
+ ];
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `talon doctor` checks for the Antigravity backend: the binary, its
3
+ * version, the cached OAuth credentials, and whether the model
4
+ * catalog answers.
5
+ */
6
+
7
+ import { execFileSync } from "node:child_process";
8
+ import type {
9
+ DoctorCheck,
10
+ DoctorConfigSlice,
11
+ } from "../../core/doctor/types.js";
12
+ import { binaryOnPath } from "../../util/binary-on-path.js";
13
+ import { AGY_MIN_VERSION } from "./constants.js";
14
+ import { detectAgyAuth } from "./auth.js";
15
+ import { parseAgyModels } from "./models.js";
16
+
17
+ /** Resolve the binary the same way the runtime does: env, config, PATH. */
18
+ function resolveBinary(config: DoctorConfigSlice | undefined): string {
19
+ const configured = (config as { agyBinary?: string } | undefined)?.agyBinary;
20
+ return process.env.AGY_BINARY || configured || "agy";
21
+ }
22
+
23
+ /** `1.2.7` → [1, 2, 7]; missing parts are 0. */
24
+ function parseVersion(text: string): number[] {
25
+ const match = /(\d+)\.(\d+)(?:\.(\d+))?/.exec(text);
26
+ if (!match) return [];
27
+ return [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
28
+ }
29
+
30
+ function versionAtLeast(found: number[], required: number[]): boolean {
31
+ for (let i = 0; i < required.length; i++) {
32
+ const a = found[i] ?? 0;
33
+ const b = required[i] ?? 0;
34
+ if (a !== b) return a > b;
35
+ }
36
+ return true;
37
+ }
38
+
39
+ function run(binary: string, args: string[]): string {
40
+ return execFileSync(binary, args, {
41
+ encoding: "utf-8",
42
+ timeout: 30_000,
43
+ stdio: ["ignore", "pipe", "pipe"],
44
+ maxBuffer: 4 * 1024 * 1024,
45
+ });
46
+ }
47
+
48
+ function versionCheck(binary: string): DoctorCheck {
49
+ let output: string;
50
+ try {
51
+ output = run(binary, ["--version"]).trim();
52
+ } catch (err) {
53
+ return {
54
+ label: "Antigravity CLI version unreadable",
55
+ status: "warn",
56
+ detail: err instanceof Error ? err.message : String(err),
57
+ issue: true,
58
+ };
59
+ }
60
+ const found = parseVersion(output);
61
+ const required = parseVersion(AGY_MIN_VERSION);
62
+ if (found.length === 0) {
63
+ return {
64
+ label: "Antigravity CLI version unrecognised",
65
+ status: "warn",
66
+ detail: output,
67
+ issue: true,
68
+ };
69
+ }
70
+ if (!versionAtLeast(found, required)) {
71
+ return {
72
+ label: `Antigravity CLI too old (${output})`,
73
+ status: "fail",
74
+ detail: `headless stream-json needs >= ${AGY_MIN_VERSION}`,
75
+ };
76
+ }
77
+ return { label: "Antigravity CLI version", status: "ok", detail: output };
78
+ }
79
+
80
+ function authCheck(): DoctorCheck {
81
+ const auth = detectAgyAuth();
82
+ if (!auth.present) {
83
+ return {
84
+ label: "Antigravity auth missing",
85
+ status: "warn",
86
+ detail: `${auth.problem ?? "no token file"} — run \`agy\` once interactively to sign in`,
87
+ issue: true,
88
+ };
89
+ }
90
+ if (auth.expired && !auth.refreshable) {
91
+ return {
92
+ label: "Antigravity auth expired",
93
+ status: "warn",
94
+ detail: `token expired ${auth.expiry?.toISOString() ?? ""} with no refresh token — run \`agy\` again`,
95
+ issue: true,
96
+ };
97
+ }
98
+ const detail = [
99
+ auth.method ? `${auth.method} OAuth` : "OAuth",
100
+ auth.expired ? "access token stale (refreshable)" : undefined,
101
+ auth.expiry ? `expiry ${auth.expiry.toISOString()}` : undefined,
102
+ ]
103
+ .filter(Boolean)
104
+ .join(", ");
105
+ return { label: "Antigravity auth", status: "ok", detail };
106
+ }
107
+
108
+ function modelsCheck(binary: string): DoctorCheck {
109
+ try {
110
+ const models = parseAgyModels(run(binary, ["models"]));
111
+ if (models.length === 0) {
112
+ return {
113
+ label: "Antigravity models empty",
114
+ status: "warn",
115
+ detail: "`agy models` returned no rows",
116
+ issue: true,
117
+ };
118
+ }
119
+ return {
120
+ label: "Antigravity models",
121
+ status: "ok",
122
+ detail: `${models.length} available (${models[0].id}…)`,
123
+ };
124
+ } catch (err) {
125
+ return {
126
+ label: "Antigravity models unavailable",
127
+ status: "warn",
128
+ detail: err instanceof Error ? err.message : String(err),
129
+ issue: true,
130
+ };
131
+ }
132
+ }
133
+
134
+ export async function agyDoctorChecks(
135
+ config: DoctorConfigSlice | undefined,
136
+ isActive = true,
137
+ ): Promise<DoctorCheck[]> {
138
+ const binary = resolveBinary(config);
139
+ const found =
140
+ binary.includes("/") || binary.includes("\\") ? true : binaryOnPath(binary);
141
+ if (!found) {
142
+ return [
143
+ {
144
+ label: "Antigravity CLI not found",
145
+ status: "fail",
146
+ detail:
147
+ "install the Antigravity CLI and put `agy` on PATH, or set `agyBinary` / AGY_BINARY",
148
+ },
149
+ ];
150
+ }
151
+
152
+ const checks: DoctorCheck[] = [
153
+ { label: "Antigravity CLI installed", status: "ok", detail: binary },
154
+ versionCheck(binary),
155
+ authCheck(),
156
+ ];
157
+ // The catalog probe costs a process spawn and a network round-trip,
158
+ // so it only runs for the backend actually serving chats.
159
+ if (isActive) checks.push(modelsCheck(binary));
160
+ return checks;
161
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Antigravity reasoning-effort vocabulary.
3
+ *
4
+ * agy expresses effort in two places at once, and the precedence
5
+ * between them is the whole content of this module:
6
+ *
7
+ * 1. **Baked into the model id.** Most slugs carry the level as a
8
+ * suffix — `gemini-3.8-flash-high`, `gemini-3.1-pro-low`. Picking
9
+ * such an id IS picking an effort.
10
+ * 2. **The `--effort low|medium|high` flag.** Accepted alongside any
11
+ * model.
12
+ *
13
+ * Precedence Talon applies: a requested level first tries to re-point
14
+ * the model id at the sibling slug carrying that suffix (so the CLI
15
+ * validates the combination for us and the picker keeps showing the
16
+ * model actually in use); `--effort` is then passed as well, so a
17
+ * suffix-less id (`claude-sonnet-4-6`, `gpt-oss-120b-medium` has a
18
+ * baked medium but no siblings) still honours the request. Levels agy
19
+ * cannot express (`off`, `minimal`, `xhigh`, `max`) fall through to
20
+ * the model's own default with no flag and no id rewrite — the same
21
+ * "let the model decide" behaviour codex's mapping produces.
22
+ */
23
+
24
+ import type { ReasoningEffortLevel } from "../../core/types.js";
25
+
26
+ /** The three levels `--effort` accepts. */
27
+ export type AgyEffort = "low" | "medium" | "high";
28
+
29
+ const AGY_EFFORTS: readonly AgyEffort[] = ["low", "medium", "high"];
30
+
31
+ /** True for one of the three suffixes agy bakes into model ids. */
32
+ function isAgyEffort(value: string): value is AgyEffort {
33
+ return (AGY_EFFORTS as readonly string[]).includes(value);
34
+ }
35
+
36
+ /**
37
+ * Map a canonical level onto `--effort`, or undefined when agy has no
38
+ * way to express it (`off` / `minimal` / `xhigh` / `max`, or nothing
39
+ * requested).
40
+ */
41
+ export function toAgyEffort(
42
+ level: ReasoningEffortLevel | undefined,
43
+ ): AgyEffort | undefined {
44
+ if (!level) return undefined;
45
+ return isAgyEffort(level) ? level : undefined;
46
+ }
47
+
48
+ /** The effort suffix baked into a model id, if it has one. */
49
+ export function effortSuffixOf(modelId: string): AgyEffort | undefined {
50
+ const tail = modelId.slice(modelId.lastIndexOf("-") + 1);
51
+ return isAgyEffort(tail) ? tail : undefined;
52
+ }
53
+
54
+ /** The model id with any effort suffix stripped. */
55
+ export function modelIdStem(modelId: string): string {
56
+ const suffix = effortSuffixOf(modelId);
57
+ return suffix ? modelId.slice(0, -(suffix.length + 1)) : modelId;
58
+ }
59
+
60
+ /**
61
+ * Re-point a model id at the sibling slug carrying `effort`, when such
62
+ * a sibling exists in `catalogIds`. Returns the original id when the
63
+ * model has no effort suffix, when the sibling isn't offered, or when
64
+ * no effort was requested — never invents an id the CLI would reject
65
+ * (headless agy exits non-zero on an unknown `--model`).
66
+ */
67
+ export function applyEffortToModelId(
68
+ modelId: string,
69
+ effort: AgyEffort | undefined,
70
+ catalogIds: readonly string[],
71
+ ): string {
72
+ if (!effort) return modelId;
73
+ if (!effortSuffixOf(modelId)) return modelId;
74
+ const candidate = `${modelIdStem(modelId)}-${effort}`;
75
+ return catalogIds.includes(candidate) ? candidate : modelId;
76
+ }