talon-agent 5.3.0 → 5.4.1
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 +4 -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/cli/config-view.ts +5 -0
- package/src/cli/config.ts +4 -2
- package/src/cli/setup.ts +49 -0
- package/src/core/agent-runtime/model-ref.ts +1 -0
- package/src/core/config/index.ts +1 -0
- package/src/core/daemon/respawn.ts +34 -10
- package/src/core/daemon/signals.ts +111 -0
- package/src/index.ts +7 -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
|
+
}
|
package/src/backend/builtins.ts
CHANGED
package/src/cli/config-view.ts
CHANGED
|
@@ -54,6 +54,7 @@ export async function viewConfig(): Promise<void> {
|
|
|
54
54
|
kilo: "Kilo (@kilocode/sdk)",
|
|
55
55
|
opencode: "OpenCode (@opencode-ai/sdk)",
|
|
56
56
|
codex: "OpenAI Codex CLI",
|
|
57
|
+
agy: "Google Antigravity CLI",
|
|
57
58
|
"openai-agents": "OpenAI Agents (@openai/agents)",
|
|
58
59
|
};
|
|
59
60
|
console.log(
|
|
@@ -63,6 +64,10 @@ export async function viewConfig(): Promise<void> {
|
|
|
63
64
|
console.log(
|
|
64
65
|
` ${pc.dim("Claude binary")} ${pc.green(config.claudeBinary)}`,
|
|
65
66
|
);
|
|
67
|
+
if (config.agyBinary)
|
|
68
|
+
console.log(
|
|
69
|
+
` ${pc.dim("Antigravity bin")} ${pc.green(config.agyBinary)}`,
|
|
70
|
+
);
|
|
66
71
|
if (config.codexApiKey)
|
|
67
72
|
console.log(
|
|
68
73
|
` ${pc.dim("Codex API key")} ${maskToken(config.codexApiKey)}`,
|
package/src/cli/config.ts
CHANGED
|
@@ -27,10 +27,12 @@ export function printBanner(): void {
|
|
|
27
27
|
*/
|
|
28
28
|
export type Config = {
|
|
29
29
|
frontend: string | string[];
|
|
30
|
-
/** Active backend (`claude` / `kilo` / `opencode` / `codex` / `openai-agents`). */
|
|
31
|
-
backend?: "claude" | "kilo" | "opencode" | "codex" | "openai-agents";
|
|
30
|
+
/** Active backend (`claude` / `kilo` / `opencode` / `codex` / `agy` / `openai-agents`). */
|
|
31
|
+
backend?: "claude" | "kilo" | "opencode" | "codex" | "agy" | "openai-agents";
|
|
32
32
|
botToken?: string;
|
|
33
33
|
claudeBinary?: string;
|
|
34
|
+
/** Path to the Antigravity `agy` executable. */
|
|
35
|
+
agyBinary?: string;
|
|
34
36
|
/** Codex-specific OpenAI API key. */
|
|
35
37
|
codexApiKey?: string;
|
|
36
38
|
/** OpenAI API key — used by OpenAI Agents and legacy Codex config. */
|