talon-agent 5.2.2 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +10 -6
  2. package/package.json +1 -1
  3. package/src/app.ts +78 -0
  4. package/src/backend/agy/auth.ts +128 -0
  5. package/src/backend/agy/constants.ts +88 -0
  6. package/src/backend/agy/doctor.ts +161 -0
  7. package/src/backend/agy/effort.ts +76 -0
  8. package/src/backend/agy/events.ts +402 -0
  9. package/src/backend/agy/factory.ts +137 -0
  10. package/src/backend/agy/handler/index.ts +9 -0
  11. package/src/backend/agy/handler/message.ts +444 -0
  12. package/src/backend/agy/init.ts +64 -0
  13. package/src/backend/agy/mcp/config.ts +336 -0
  14. package/src/backend/agy/mcp/register.ts +134 -0
  15. package/src/backend/agy/models.ts +335 -0
  16. package/src/backend/agy/one-shot.ts +301 -0
  17. package/src/backend/agy/process/child.ts +378 -0
  18. package/src/backend/agy/process/orphans.ts +106 -0
  19. package/src/backend/agy/sessions.ts +90 -0
  20. package/src/backend/agy/state.ts +77 -0
  21. package/src/backend/builtins.ts +1 -0
  22. package/src/backend/codex/mcp-config.ts +1 -1
  23. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  24. package/src/backend/runtime/index.ts +1 -1
  25. package/src/cli/commands/backup.ts +396 -0
  26. package/src/cli/config-view.ts +5 -0
  27. package/src/cli/config.ts +4 -2
  28. package/src/cli/events.ts +14 -0
  29. package/src/cli/index.ts +64 -45
  30. package/src/cli/setup.ts +49 -0
  31. package/src/core/agent-runtime/model-ref.ts +1 -0
  32. package/src/core/backup/archive/digest.ts +77 -0
  33. package/src/core/backup/archive/tar.ts +567 -0
  34. package/src/core/backup/archive/zstd.ts +31 -0
  35. package/src/core/backup/index.ts +54 -0
  36. package/src/core/backup/plan.ts +273 -0
  37. package/src/core/backup/restore.ts +410 -0
  38. package/src/core/backup/scheduler.ts +357 -0
  39. package/src/core/backup/snapshot.ts +408 -0
  40. package/src/core/backup/status.ts +194 -0
  41. package/src/core/backup/store.ts +312 -0
  42. package/src/core/backup/targets.ts +281 -0
  43. package/src/core/backup/types.ts +96 -0
  44. package/src/core/backup/upload.ts +172 -0
  45. package/src/core/bus/events.ts +45 -1
  46. package/src/core/config/index.ts +53 -0
  47. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  48. package/src/core/engine/gateway-actions/index.ts +4 -0
  49. package/src/core/mcp-hub/talon-server.ts +1 -1
  50. package/src/core/plugin/actions.ts +34 -0
  51. package/src/core/plugin/index.ts +5 -1
  52. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  53. package/src/core/tools/index.ts +2 -0
  54. package/src/core/tools/ops/backup.ts +67 -0
  55. package/src/core/tools/types.ts +2 -1
  56. package/src/core/update/self-update.ts +3 -0
  57. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  58. package/src/frontend/discord/commands/backup.ts +203 -0
  59. package/src/frontend/discord/commands/definitions.ts +35 -0
  60. package/src/frontend/discord/commands/router.ts +3 -0
  61. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  62. package/src/frontend/telegram/callbacks/index.ts +8 -0
  63. package/src/frontend/telegram/commands/backup.ts +209 -0
  64. package/src/frontend/telegram/commands/definitions.ts +4 -0
  65. package/src/frontend/telegram/commands/index.ts +2 -0
  66. package/src/storage/backup/index.ts +82 -0
  67. package/src/storage/backup/repo.ts +164 -0
  68. package/src/storage/db.ts +20 -0
  69. package/src/storage/sql/backups.sql +46 -0
  70. package/src/storage/sql/db.sql +8 -0
  71. package/src/storage/sql/schema.sql +30 -0
  72. package/src/storage/sql/statements.generated.ts +60 -1
  73. package/src/util/log.ts +1 -0
  74. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * The per-chat Antigravity child process.
3
+ *
4
+ * agy has no SDK: the integration surface is one long-lived
5
+ * `agy --input-format stream-json` process per chat, fed one
6
+ * `{"event":"user",...}` line per turn on stdin and read line-by-line
7
+ * on stdout. That shape is deliberate — the CLI's own docs call it
8
+ * "significantly faster than running repeated commands with
9
+ * `--continue`" because the process starts once and the conversation
10
+ * stays warm.
11
+ *
12
+ * What this module owns:
13
+ *
14
+ * - Spawning with the right flag set, cwd and `--conversation` for a
15
+ * resume, and tearing the child down through a
16
+ * SIGTERM → grace → SIGKILL ladder that never leaves a zombie.
17
+ * - Serialising turns: exactly one may be in flight. The dispatcher
18
+ * already serialises per chat, so a second concurrent turn is a
19
+ * bug, not a queue to build — it is rejected loudly.
20
+ * - Settling a turn on its `result` event, or on the child dying,
21
+ * whichever comes first.
22
+ * - Idle reaping, and the terminator kill: a turn in progress cannot
23
+ * be cancelled through the protocol, so `end_turn` kills the child
24
+ * and the next turn respawns it with `--conversation <id>`.
25
+ * - A stderr ring buffer, because that is the ONLY place the CLI
26
+ * reports `authentication required` and permission notices.
27
+ */
28
+
29
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
30
+ import { log, logWarn } from "../../../util/log.js";
31
+ import {
32
+ parseAgyLine,
33
+ type AgyEvent,
34
+ type AgyResult,
35
+ type AgyStepUpdate,
36
+ } from "../events.js";
37
+ import type { AgyEffort } from "../effort.js";
38
+ import {
39
+ AGY_BASE_ARGS,
40
+ AGY_IDLE_REAP_MS,
41
+ AGY_KILL_GRACE_MS,
42
+ } from "../constants.js";
43
+
44
+ /** How much stderr to retain for diagnostics. */
45
+ const STDERR_TAIL_CHARS = 4000;
46
+
47
+ /** Spawn shape for one child. Two children differing here can't be reused. */
48
+ export interface AgySpawnSpec {
49
+ binary: string;
50
+ cwd: string;
51
+ model: string;
52
+ effort?: AgyEffort;
53
+ /** Extra roots the agent may touch, passed as `--add-dir`. */
54
+ addDirs?: readonly string[];
55
+ /** Resume this conversation instead of starting a new one. */
56
+ conversationId?: string;
57
+ env?: NodeJS.ProcessEnv;
58
+ /** Idle TTL before the child is reaped. Defaults to [AGY_IDLE_REAP_MS]. */
59
+ idleMs?: number;
60
+ }
61
+
62
+ export interface AgyTurnHandlers {
63
+ onStep?: (step: AgyStepUpdate) => void;
64
+ onEvent?: (event: AgyEvent) => void;
65
+ }
66
+
67
+ /**
68
+ * The close a terminator (or a user interrupt) produces. The message
69
+ * contains "abort" on purpose: every backend's close-out path — and
70
+ * `handler/message.ts: isTerminatorAbort` here — recognises the
71
+ * expected end-of-turn close by that word, so a killed-on-purpose turn
72
+ * settles as a normal completion rather than an error with a retry.
73
+ */
74
+ export class AgyTurnAborted extends Error {
75
+ constructor(reason: string) {
76
+ super(`agy turn aborted (${reason})`);
77
+ this.name = "AgyTurnAborted";
78
+ }
79
+ }
80
+
81
+ /** The child died — with whatever stderr said about why. */
82
+ class AgyProcessExited extends Error {
83
+ constructor(
84
+ message: string,
85
+ readonly code: number | null,
86
+ readonly stderr: string,
87
+ ) {
88
+ super(message);
89
+ this.name = "AgyProcessExited";
90
+ }
91
+ }
92
+
93
+ /** Build the argv for one child, in a stable, assertable order. */
94
+ export function buildAgyArgs(spec: AgySpawnSpec): string[] {
95
+ const args = [...AGY_BASE_ARGS, "--model", spec.model];
96
+ if (spec.effort) args.push("--effort", spec.effort);
97
+ if (spec.conversationId) args.push("--conversation", spec.conversationId);
98
+ for (const dir of spec.addDirs ?? []) args.push("--add-dir", dir);
99
+ return args;
100
+ }
101
+
102
+ /** True when two specs describe the same process and one can be reused. */
103
+ function sameSpec(a: AgySpawnSpec, b: AgySpawnSpec): boolean {
104
+ return (
105
+ a.binary === b.binary &&
106
+ a.cwd === b.cwd &&
107
+ a.model === b.model &&
108
+ a.effort === b.effort &&
109
+ (a.addDirs ?? []).join("\u0000") === (b.addDirs ?? []).join("\u0000")
110
+ );
111
+ }
112
+
113
+ interface PendingTurn {
114
+ resolve: (result: AgyResult) => void;
115
+ reject: (err: unknown) => void;
116
+ handlers: AgyTurnHandlers;
117
+ }
118
+
119
+ export class AgyChild {
120
+ readonly spec: AgySpawnSpec;
121
+ private readonly label: string;
122
+ private proc: ChildProcessWithoutNullStreams | null = null;
123
+ private stdoutBuf = "";
124
+ private stderrTail = "";
125
+ private pending: PendingTurn | null = null;
126
+ private idleTimer: NodeJS.Timeout | null = null;
127
+ private exited = false;
128
+ /** Conversation id announced by `init` / carried on every event. */
129
+ conversationId: string | undefined;
130
+ /** Tool names the CLI advertised in `init`. */
131
+ tools: string[] = [];
132
+
133
+ constructor(label: string, spec: AgySpawnSpec) {
134
+ this.label = label;
135
+ this.spec = spec;
136
+ this.conversationId = spec.conversationId;
137
+ }
138
+
139
+ get alive(): boolean {
140
+ return this.proc !== null && !this.exited;
141
+ }
142
+
143
+ get stderrSnapshot(): string {
144
+ return this.stderrTail;
145
+ }
146
+
147
+ /** Spawn the process. Idempotent — a live child is left alone. */
148
+ start(): void {
149
+ if (this.alive) return;
150
+ const args = buildAgyArgs(this.spec);
151
+ log(
152
+ "agent",
153
+ `[${this.label}] agy spawn: ${this.spec.binary} ${args.join(" ")}`,
154
+ );
155
+ const proc = spawn(this.spec.binary, args, {
156
+ cwd: this.spec.cwd,
157
+ env: this.spec.env ?? process.env,
158
+ stdio: ["pipe", "pipe", "pipe"],
159
+ });
160
+ this.proc = proc;
161
+ this.exited = false;
162
+ proc.stdout.setEncoding("utf-8");
163
+ proc.stderr.setEncoding("utf-8");
164
+ proc.stdout.on("data", (chunk: string) => this.onStdout(chunk));
165
+ proc.stderr.on("data", (chunk: string) => this.onStderr(chunk));
166
+ proc.on("error", (err) => this.onExit(null, err.message));
167
+ proc.on("close", (code) => this.onExit(code, null));
168
+ this.armIdleTimer();
169
+ }
170
+
171
+ /**
172
+ * Run one turn. Resolves with the turn's `result` event; rejects
173
+ * with [AgyTurnAborted] when the child was killed mid-turn and
174
+ * [AgyProcessExited] when it died on its own.
175
+ */
176
+ runTurn(prompt: string, handlers: AgyTurnHandlers = {}): Promise<AgyResult> {
177
+ if (this.pending) {
178
+ return Promise.reject(
179
+ new Error(
180
+ `agy: a turn is already in flight for ${this.label} — ` +
181
+ `the dispatcher is expected to serialise per chat`,
182
+ ),
183
+ );
184
+ }
185
+ this.start();
186
+ const proc = this.proc;
187
+ if (!proc) return Promise.reject(new Error("agy: child failed to spawn"));
188
+ this.clearIdleTimer();
189
+
190
+ return new Promise<AgyResult>((resolve, reject) => {
191
+ this.pending = { resolve, reject, handlers };
192
+ const line = `${JSON.stringify({
193
+ event: "user",
194
+ message: { content: prompt },
195
+ })}\n`;
196
+ proc.stdin.write(line, (err) => {
197
+ if (err) this.settleReject(err);
198
+ });
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Kill the child. Any in-flight turn rejects with [AgyTurnAborted],
204
+ * carrying `reason` so the handler can tell a terminator kill from a
205
+ * reset or an idle reap.
206
+ */
207
+ kill(reason: string): void {
208
+ this.clearIdleTimer();
209
+ const proc = this.proc;
210
+ this.settleReject(new AgyTurnAborted(reason));
211
+ if (!proc || this.exited) {
212
+ this.proc = null;
213
+ return;
214
+ }
215
+ log("agent", `[${this.label}] agy child kill (${reason})`);
216
+ try {
217
+ proc.stdin.end();
218
+ } catch {
219
+ /* already closed */
220
+ }
221
+ try {
222
+ proc.kill("SIGTERM");
223
+ } catch {
224
+ /* already gone */
225
+ }
226
+ const timer = setTimeout(() => {
227
+ try {
228
+ if (!this.exited) proc.kill("SIGKILL");
229
+ } catch {
230
+ /* already gone */
231
+ }
232
+ }, AGY_KILL_GRACE_MS);
233
+ timer.unref();
234
+ this.proc = null;
235
+ }
236
+
237
+ // ── Internals ─────────────────────────────────────────────────────────────
238
+
239
+ private onStdout(chunk: string): void {
240
+ this.stdoutBuf += chunk;
241
+ let nl = this.stdoutBuf.indexOf("\n");
242
+ while (nl >= 0) {
243
+ const line = this.stdoutBuf.slice(0, nl);
244
+ this.stdoutBuf = this.stdoutBuf.slice(nl + 1);
245
+ this.onLine(line);
246
+ nl = this.stdoutBuf.indexOf("\n");
247
+ }
248
+ }
249
+
250
+ private onLine(line: string): void {
251
+ const event = parseAgyLine(line);
252
+ if (!event) return;
253
+ const pending = this.pending;
254
+ if (event.event === "init") {
255
+ this.conversationId = event.conversation_id ?? this.conversationId;
256
+ this.tools = event.init?.tools ?? [];
257
+ } else if (event.event === "step_update") {
258
+ const step = event.step_update;
259
+ if (step?.conversation_id) this.conversationId = step.conversation_id;
260
+ if (step) pending?.handlers.onStep?.(step);
261
+ }
262
+ pending?.handlers.onEvent?.(event);
263
+ if (event.event === "result") {
264
+ this.conversationId =
265
+ event.result?.conversation_id ?? this.conversationId;
266
+ this.settleResolve(event.result ?? {});
267
+ }
268
+ }
269
+
270
+ private onStderr(chunk: string): void {
271
+ this.stderrTail = (this.stderrTail + chunk).slice(-STDERR_TAIL_CHARS);
272
+ }
273
+
274
+ private onExit(code: number | null, error: string | null): void {
275
+ if (this.exited) return;
276
+ this.exited = true;
277
+ this.proc = null;
278
+ this.clearIdleTimer();
279
+ if (!this.pending) return;
280
+ const detail =
281
+ error ?? this.stderrTail.trim().split("\n").slice(-4).join("\n");
282
+ this.settleReject(
283
+ new AgyProcessExited(
284
+ `agy exited (code ${code ?? "n/a"})${detail ? `: ${detail}` : ""}`,
285
+ code,
286
+ this.stderrTail,
287
+ ),
288
+ );
289
+ }
290
+
291
+ private settleResolve(result: AgyResult): void {
292
+ const pending = this.pending;
293
+ this.pending = null;
294
+ this.armIdleTimer();
295
+ pending?.resolve(result);
296
+ }
297
+
298
+ private settleReject(err: unknown): void {
299
+ const pending = this.pending;
300
+ if (!pending) return;
301
+ this.pending = null;
302
+ pending.reject(err);
303
+ }
304
+
305
+ private armIdleTimer(): void {
306
+ this.clearIdleTimer();
307
+ const ms = this.spec.idleMs ?? AGY_IDLE_REAP_MS;
308
+ if (ms <= 0) return;
309
+ this.idleTimer = setTimeout(() => {
310
+ logWarn("agent", `[${this.label}] agy child idle for ${ms}ms — reaping`);
311
+ this.onIdle?.(this.label);
312
+ this.kill("idle");
313
+ }, ms);
314
+ this.idleTimer.unref();
315
+ }
316
+
317
+ private clearIdleTimer(): void {
318
+ if (!this.idleTimer) return;
319
+ clearTimeout(this.idleTimer);
320
+ this.idleTimer = null;
321
+ }
322
+
323
+ /** Set by the pool so an idle reap can also drop the MCP entries. */
324
+ onIdle?: (label: string) => void;
325
+ }
326
+
327
+ // ── Pool ────────────────────────────────────────────────────────────────────
328
+
329
+ const children = new Map<string, AgyChild>();
330
+
331
+ /**
332
+ * The live child for a chat, spawning one if needed.
333
+ *
334
+ * A child whose spec has drifted (model or effort changed, workspace
335
+ * moved) is killed and replaced — those are spawn-time flags with no
336
+ * runtime equivalent. The replacement inherits the conversation id, so
337
+ * the conversation survives a model switch.
338
+ */
339
+ export function ensureChild(chatId: string, spec: AgySpawnSpec): AgyChild {
340
+ const existing = children.get(chatId);
341
+ if (existing?.alive && sameSpec(existing.spec, spec)) return existing;
342
+ if (existing) {
343
+ const carried = existing.conversationId ?? spec.conversationId;
344
+ existing.kill(existing.alive ? "respawn" : "dead");
345
+ children.delete(chatId);
346
+ spec = { ...spec, conversationId: carried };
347
+ }
348
+ const child = new AgyChild(chatId, spec);
349
+ children.set(chatId, child);
350
+ child.start();
351
+ return child;
352
+ }
353
+
354
+ /** The child for a chat, if one exists (alive or not). */
355
+ export function getChild(chatId: string): AgyChild | undefined {
356
+ return children.get(chatId);
357
+ }
358
+
359
+ /** Kill and forget a chat's child. Returns true when one was running. */
360
+ export function killChild(chatId: string, reason: string): boolean {
361
+ const child = children.get(chatId);
362
+ if (!child) return false;
363
+ child.kill(reason);
364
+ children.delete(chatId);
365
+ return true;
366
+ }
367
+
368
+ /** Kill every child — daemon shutdown and the factory cleanup hook. */
369
+ export function killAllChildren(reason: string): void {
370
+ // Snapshot the keys: killChild mutates the map as we go.
371
+ const ids = childChatIds();
372
+ for (const chatId of ids) killChild(chatId, reason);
373
+ }
374
+
375
+ /** Chat ids with a registered child. Test + diagnostics helper. */
376
+ export function childChatIds(): string[] {
377
+ return [...children.keys()];
378
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Orphan subprocess eviction for the Antigravity backend.
3
+ *
4
+ * A one-shot run spawns a real `agy` process. Abort normally kills it,
5
+ * but a child wedged in a syscall — or one whose parent lost the
6
+ * handle across a daemon restart — outlives the run and holds its
7
+ * workspace and memory. The Claude SDK backend solves this by
8
+ * sweeping `/proc` for processes tagged with the run's chat id; the
9
+ * same approach works here, matching on the argv instead of the
10
+ * environment because agy takes its context from flags, not env.
11
+ */
12
+
13
+ import { readdir, readFile } from "node:fs/promises";
14
+ import { log } from "../../../util/log.js";
15
+ import { AGY_KILL_GRACE_MS } from "../constants.js";
16
+ import { childChatIds, getChild } from "./child.js";
17
+
18
+ /**
19
+ * Kill stray `agy` processes belonging to `contextLabel`.
20
+ *
21
+ * Two passes: the children this process still tracks (authoritative),
22
+ * then a `/proc` argv sweep for anything that escaped the pool. Linux
23
+ * only — `/proc` is where the argv lives; elsewhere the tracked pass
24
+ * still runs and the sweep is a no-op.
25
+ */
26
+ export async function evictOrphanSubprocesses(contextLabel: string): Promise<{
27
+ found: number;
28
+ termed: number;
29
+ killed: number;
30
+ }> {
31
+ const result = { found: 0, termed: 0, killed: 0 };
32
+
33
+ for (const chatId of childChatIds()) {
34
+ if (chatId !== contextLabel) continue;
35
+ const child = getChild(chatId);
36
+ if (!child?.alive) continue;
37
+ result.found++;
38
+ result.termed++;
39
+ child.kill("orphan-sweep");
40
+ }
41
+
42
+ const matched = await findOrphanPids(contextLabel);
43
+ result.found += matched.length;
44
+ for (const pid of matched) {
45
+ try {
46
+ process.kill(pid, "SIGTERM");
47
+ result.termed++;
48
+ } catch {
49
+ /* ESRCH / EPERM — nothing to do */
50
+ }
51
+ }
52
+ if (matched.length > 0) {
53
+ await new Promise<void>((resolve) => {
54
+ const timer = setTimeout(resolve, AGY_KILL_GRACE_MS);
55
+ timer.unref();
56
+ });
57
+ for (const pid of matched) {
58
+ try {
59
+ process.kill(pid, 0);
60
+ process.kill(pid, "SIGKILL");
61
+ result.killed++;
62
+ } catch {
63
+ /* already gone */
64
+ }
65
+ }
66
+ }
67
+
68
+ if (result.found > 0) {
69
+ log(
70
+ "heartbeat",
71
+ `agy subprocess sweep (${contextLabel}): found=${result.found} ` +
72
+ `termed=${result.termed} killed=${result.killed}`,
73
+ );
74
+ }
75
+ return result;
76
+ }
77
+
78
+ /** `/proc` pids whose argv is an `agy` run carrying `contextLabel`. */
79
+ async function findOrphanPids(contextLabel: string): Promise<number[]> {
80
+ if (process.platform !== "linux") return [];
81
+ let entries: string[];
82
+ try {
83
+ entries = await readdir("/proc");
84
+ } catch {
85
+ return [];
86
+ }
87
+ const matched: number[] = [];
88
+ for (const entry of entries) {
89
+ const pid = Number(entry);
90
+ if (!Number.isInteger(pid) || pid === process.pid) continue;
91
+ try {
92
+ const argv = (await readFile(`/proc/${pid}/cmdline`, "utf-8")).split(
93
+ "\0",
94
+ );
95
+ // Exact-token matching, never a substring scan: this code can
96
+ // SIGKILL, so a chat id that happens to appear inside an
97
+ // unrelated path must not select a victim.
98
+ if (!argv.some((arg) => arg === "agy" || arg.endsWith("/agy"))) continue;
99
+ if (argv.includes(contextLabel)) matched.push(pid);
100
+ } catch {
101
+ // Exited between readdir and readFile, or not ours. Skip.
102
+ continue;
103
+ }
104
+ }
105
+ return matched;
106
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Session lifecycle for the Antigravity backend: reset, warm, and the
3
+ * hot MCP refresh.
4
+ *
5
+ * All three are variations on the same two facts: the child reads its
6
+ * MCP servers once at spawn, and the conversation lives in agy's own
7
+ * store keyed by `conversation_id`. So "change the tools" and "start
8
+ * fresh" both come down to rewriting the config file and respawning.
9
+ */
10
+
11
+ import { log } from "../../util/log.js";
12
+ import { dirs } from "../../util/paths.js";
13
+ import { getSession } from "../../storage/sessions.js";
14
+ import { getChatSettings } from "../../storage/chat-settings.js";
15
+ import { getState, agyBinary } from "./state.js";
16
+ import { registerMcpForChat, unregisterMcpForChat } from "./mcp/register.js";
17
+ import { ensureChild, killChild } from "./process/child.js";
18
+ import { toAgyEffort } from "./effort.js";
19
+ import { getDefaultModelId } from "./models.js";
20
+
21
+ /**
22
+ * Drop everything this chat holds: kill the child, remove its MCP
23
+ * entries (and their schema snapshots), and forget the cached usage.
24
+ * The stored conversation id is cleared by the dispatcher's own
25
+ * `storage/sessions.ts: resetSession` call.
26
+ */
27
+ export function resetChat(chatId: string): void {
28
+ const conversationId = getSession(chatId).sessionId;
29
+ killChild(chatId, "reset");
30
+ unregisterMcpForChat(chatId);
31
+ // The snapshot is filed under both ids (see `settleUsage`).
32
+ getState().lastUsage.delete(chatId);
33
+ if (conversationId) getState().lastUsage.delete(conversationId);
34
+ log("agent", `[${chatId}] agy session reset`);
35
+ }
36
+
37
+ /**
38
+ * Pre-spawn the child and register its MCP servers so the first reply
39
+ * after a `/reset` doesn't serially pay CLI startup plus tool
40
+ * registration. Best-effort: a warm-up that throws must not surface
41
+ * as a failed reset.
42
+ */
43
+ export async function warmSession(chatId: string): Promise<void> {
44
+ const config = getState().config;
45
+ if (!config) return;
46
+ try {
47
+ registerMcpForChat(chatId);
48
+ const workspace = config.workspace || dirs.workspace;
49
+ const settings = getChatSettings(chatId);
50
+ const session = getSession(chatId);
51
+ ensureChild(chatId, {
52
+ binary: agyBinary(config.agyBinary),
53
+ cwd: workspace,
54
+ model: settings.model ?? config.model ?? getDefaultModelId(),
55
+ effort: toAgyEffort(settings.effort),
56
+ addDirs: [workspace],
57
+ ...(session.sessionId ? { conversationId: session.sessionId } : {}),
58
+ });
59
+ log("agent", `[${chatId}] agy session warmed`);
60
+ } catch (err) {
61
+ log(
62
+ "agent",
63
+ `[${chatId}] agy warm-up skipped: ${
64
+ err instanceof Error ? err.message : String(err)
65
+ }`,
66
+ );
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Re-derive the chat's MCP entries from the live plugin registry and
72
+ * respawn the child so it picks them up. agy snapshots each server's
73
+ * tool schemas at registration time and reads the server list once at
74
+ * process start, so a running child cannot learn a new tool — the
75
+ * respawn is the refresh.
76
+ */
77
+ export async function refreshTools(chatId: string): Promise<{
78
+ added: string[];
79
+ removed: string[];
80
+ errors: Record<string, string>;
81
+ }> {
82
+ const { added, removed } = registerMcpForChat(chatId);
83
+ const respawned = killChild(chatId, "tool-refresh");
84
+ log(
85
+ "agent",
86
+ `[${chatId}] agy tools refreshed: +${added.length} -${removed.length}` +
87
+ `${respawned ? " (child respawns on next turn)" : ""}`,
88
+ );
89
+ return { added, removed, errors: {} };
90
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Per-process state for the Antigravity backend.
3
+ *
4
+ * Only the bindings captured at `init` (config, gateway-port resolver,
5
+ * owning frontend) plus the per-chat usage the `/status` snapshot
6
+ * reads. Two neighbours own the rest deliberately: `models.ts` holds
7
+ * its own catalog cache (so the catalog can be read by `talon doctor`
8
+ * without a live backend), and `process.ts` owns the per-chat children
9
+ * (they have a spawn / idle-reap / kill lifecycle that does not belong
10
+ * in a bag of fields).
11
+ */
12
+
13
+ import type { TalonConfig } from "../../core/config/index.js";
14
+ import type { FrontendName } from "../../core/agent-runtime/backend-registry.js";
15
+
16
+ /** What `getSessionSnapshot` answers with, per chat. */
17
+ export interface AgySessionUsage {
18
+ inputTokens: number;
19
+ outputTokens: number;
20
+ cacheRead: number;
21
+ cacheWrite: number;
22
+ contextModelId?: string;
23
+ }
24
+
25
+ export interface AgyState {
26
+ config: TalonConfig | null;
27
+ gatewayPortFn: () => number;
28
+ frontendName: FrontendName;
29
+ /**
30
+ * System prompt for the next NEW conversation, when plugin
31
+ * hot-reload pushed one through `control.updateSystemPrompt`.
32
+ * Resumed conversations keep the prompt frozen into their history —
33
+ * same semantics as codex.
34
+ */
35
+ systemPromptOverride: string | null;
36
+ /** Last cumulative `result.usage` seen per chat. */
37
+ lastUsage: Map<string, AgySessionUsage>;
38
+ }
39
+
40
+ const state: AgyState = {
41
+ config: null,
42
+ gatewayPortFn: () => 19876,
43
+ frontendName: "telegram",
44
+ systemPromptOverride: null,
45
+ lastUsage: new Map<string, AgySessionUsage>(),
46
+ };
47
+
48
+ /** Accessor for the shared state object. */
49
+ export function getState(): AgyState {
50
+ return state;
51
+ }
52
+
53
+ /** The daemon's loopback bridge URL, as the MCP hub serves it. */
54
+ export function bridgeUrl(): string {
55
+ return `http://127.0.0.1:${state.gatewayPortFn()}`;
56
+ }
57
+
58
+ /**
59
+ * Path to the `agy` executable: config `agyBinary`, else the
60
+ * `AGY_BINARY` env var, else bare `agy` off PATH. Env wins over config
61
+ * so a test harness can point at a stub without rewriting the
62
+ * persisted config — the same precedence `codexBinary` uses.
63
+ */
64
+ export function agyBinary(configBinary?: string): string {
65
+ return (
66
+ process.env.AGY_BINARY || configBinary || state.config?.agyBinary || "agy"
67
+ );
68
+ }
69
+
70
+ /** Reset everything — test isolation helper and `cleanup` hook. */
71
+ export function resetState(): void {
72
+ state.config = null;
73
+ state.gatewayPortFn = () => 19876;
74
+ state.frontendName = "telegram";
75
+ state.systemPromptOverride = null;
76
+ state.lastUsage.clear();
77
+ }
@@ -31,5 +31,6 @@ export async function loadBuiltinBackends(): Promise<void> {
31
31
  registerBackend(createRemoteBackendFactory(profile));
32
32
  }
33
33
  await import("./codex/factory.js");
34
+ await import("./agy/factory.js");
34
35
  await import("./openai-agents/factory.js");
35
36
  }
@@ -29,7 +29,7 @@ import {
29
29
  pluginHubUrl,
30
30
  hubPluginServerNames,
31
31
  } from "../../core/mcp-hub/index.js";
32
- import type { ToolExclusionConfig } from "../../core/tools/ops/mcp-env.js";
32
+ import type { ToolExclusionConfig } from "../../core/tools/mcp-env.js";
33
33
  import { frontendsForChat } from "../runtime/frontends.js";
34
34
 
35
35
  /**
@@ -30,7 +30,7 @@ import {
30
30
  pluginHubUrl,
31
31
  hubPluginServerNames,
32
32
  } from "../../core/mcp-hub/index.js";
33
- import type { ToolExclusionConfig } from "../../core/tools/ops/mcp-env.js";
33
+ import type { ToolExclusionConfig } from "../../core/tools/mcp-env.js";
34
34
  import { frontendsForChat } from "../runtime/frontends.js";
35
35
  import { log, logWarn } from "../../util/log.js";
36
36
 
@@ -37,7 +37,7 @@
37
37
  * - SDK-specific event types — those live in each backend.
38
38
  * - Session storage — that's `src/storage/sessions.ts`.
39
39
  * - MCP server registration — backend-specific transport details
40
- * (the spawn/env contract they share is `core/tools/ops/mcp-env.ts`).
40
+ * (the spawn/env contract they share is `core/tools/mcp-env.ts`).
41
41
  */
42
42
 
43
43
  export { captureDeliveredText } from "./turn/delivered-text.js";