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
package/README.md
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
[](https://bun.sh)
|
|
9
9
|
[](https://www.typescriptlang.org/)
|
|
10
10
|
[](#frontends)
|
|
11
|
-
[](#backends)
|
|
11
|
+
[](#backends)
|
|
12
12
|
[](LICENSE)
|
|
13
13
|
[](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
|
|
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
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
|
+
}
|