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
package/src/cli/setup.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import * as p from "@clack/prompts";
|
|
7
7
|
import pc from "picocolors";
|
|
8
8
|
import { printBanner, loadConfig, saveConfig, type Config } from "./config.js";
|
|
9
|
+
import { binaryOnPath } from "../util/binary-on-path.js";
|
|
10
|
+
import { detectAgyAuth } from "../backend/agy/auth.js";
|
|
9
11
|
|
|
10
12
|
/** Strip spaces, "+", brackets and dashes from a phone number. */
|
|
11
13
|
const bareDigits = (raw: string) => raw.replace(/[\s+()-]/g, "");
|
|
@@ -393,6 +395,10 @@ async function askBackend(config: Config): Promise<Config["backend"]> {
|
|
|
393
395
|
value: "codex",
|
|
394
396
|
label: `Codex ${pc.dim("— OpenAI Codex CLI (@openai/codex)")}`,
|
|
395
397
|
},
|
|
398
|
+
{
|
|
399
|
+
value: "agy",
|
|
400
|
+
label: `Antigravity ${pc.dim("— Google Antigravity CLI (agy, subscription OAuth)")}`,
|
|
401
|
+
},
|
|
396
402
|
{
|
|
397
403
|
value: "openai-agents",
|
|
398
404
|
label: `OpenAI Agents ${pc.dim("— @openai/agents (OpenAI or any OpenAI-compatible endpoint)")}`,
|
|
@@ -405,6 +411,7 @@ async function askBackend(config: Config): Promise<Config["backend"]> {
|
|
|
405
411
|
type BackendAnswers = Pick<
|
|
406
412
|
SetupAnswers,
|
|
407
413
|
| "claudeBinary"
|
|
414
|
+
| "agyBinary"
|
|
408
415
|
| "codexApiKey"
|
|
409
416
|
| "openaiApiKey"
|
|
410
417
|
| "openaiBaseUrl"
|
|
@@ -434,6 +441,44 @@ async function askCodexBackend(config: Config): Promise<BackendAnswers> {
|
|
|
434
441
|
return { codexApiKey: trimmedOrUndefined(codexApiKey) };
|
|
435
442
|
}
|
|
436
443
|
|
|
444
|
+
/**
|
|
445
|
+
* Antigravity has no API key to collect — it authenticates through a
|
|
446
|
+
* consumer Google OAuth flow run once interactively, cached under
|
|
447
|
+
* `~/.gemini/antigravity-cli/`. All the wizard can usefully do is
|
|
448
|
+
* point at the binary and say where the login happens.
|
|
449
|
+
*/
|
|
450
|
+
async function askAgyBackend(config: Config): Promise<BackendAnswers> {
|
|
451
|
+
const binary = await askOrExit(
|
|
452
|
+
p.text({
|
|
453
|
+
message: "Antigravity CLI binary path",
|
|
454
|
+
placeholder: "leave empty for default (agy on PATH)",
|
|
455
|
+
initialValue: config.agyBinary || "",
|
|
456
|
+
}),
|
|
457
|
+
);
|
|
458
|
+
const resolved = trimmedOrUndefined(binary);
|
|
459
|
+
const probe = resolved || "agy";
|
|
460
|
+
const installed = binaryOnPath(probe);
|
|
461
|
+
if (!installed) {
|
|
462
|
+
p.log.warn(
|
|
463
|
+
`${pc.yellow("!")} \`${probe}\` not found — install the Antigravity CLI ` +
|
|
464
|
+
`and make sure it is on PATH before starting Talon.`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
const auth = detectAgyAuth();
|
|
468
|
+
if (!auth.present) {
|
|
469
|
+
p.log.warn(
|
|
470
|
+
`${pc.yellow("!")} No cached Antigravity credentials. Run ${pc.cyan("agy")} ` +
|
|
471
|
+
`once on this host and complete the Google sign-in; headless runs ` +
|
|
472
|
+
`then reuse the cache. There is no API key for this backend.`,
|
|
473
|
+
);
|
|
474
|
+
} else {
|
|
475
|
+
p.log.success(
|
|
476
|
+
`Antigravity credentials found (${auth.method ?? "oauth"}) at ${auth.path}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return { agyBinary: resolved };
|
|
480
|
+
}
|
|
481
|
+
|
|
437
482
|
async function askOpenAiAgentsBackend(config: Config): Promise<BackendAnswers> {
|
|
438
483
|
const openaiApiKey = trimmedOrUndefined(
|
|
439
484
|
await askOrExit(
|
|
@@ -494,6 +539,8 @@ async function askBackendCredentials(
|
|
|
494
539
|
return askClaudeBackend(config);
|
|
495
540
|
case "codex":
|
|
496
541
|
return askCodexBackend(config);
|
|
542
|
+
case "agy":
|
|
543
|
+
return askAgyBackend(config);
|
|
497
544
|
case "openai-agents":
|
|
498
545
|
return askOpenAiAgentsBackend(config);
|
|
499
546
|
default:
|
|
@@ -553,6 +600,7 @@ export type SetupAnswers = {
|
|
|
553
600
|
backend: Config["backend"];
|
|
554
601
|
botToken?: string;
|
|
555
602
|
claudeBinary?: string;
|
|
603
|
+
agyBinary?: string;
|
|
556
604
|
codexApiKey?: string;
|
|
557
605
|
openaiApiKey?: string;
|
|
558
606
|
openaiBaseUrl?: string;
|
|
@@ -612,6 +660,7 @@ export function buildSetupConfig(
|
|
|
612
660
|
codexApiKey: ownsCredential("codex")
|
|
613
661
|
? answers.codexApiKey
|
|
614
662
|
: existing.codexApiKey,
|
|
663
|
+
agyBinary: ownsCredential("agy") ? answers.agyBinary : existing.agyBinary,
|
|
615
664
|
openaiApiKey: ownsCredential("openai-agents")
|
|
616
665
|
? answers.openaiApiKey
|
|
617
666
|
: existing.openaiApiKey,
|
package/src/core/config/index.ts
CHANGED
|
@@ -360,6 +360,7 @@ const configSchema = z.object({
|
|
|
360
360
|
* unset, the codex-sdk resolves `codex` from its package / PATH.
|
|
361
361
|
*/
|
|
362
362
|
codexBinary: z.string().optional(),
|
|
363
|
+
agyBinary: z.string().optional(),
|
|
363
364
|
model: z.string().default("default"),
|
|
364
365
|
/**
|
|
365
366
|
* Per-backend default model overrides. Keyed by backend id
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* systemd, foreman, pm2, or running under a debugger. Respawning from
|
|
14
14
|
* our own `process.argv` works regardless of launch method.
|
|
15
15
|
*
|
|
16
|
-
* Ordering matters. `respawnSelf()` only *arms* the handoff and
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Ordering matters. `respawnSelf()` only *arms* the handoff and enters
|
|
17
|
+
* graceful shutdown; the successor is spawned by `spawnSuccessor()` at
|
|
18
|
+
* the tail of it, once the frontends have stopped. Spawning up-front
|
|
19
19
|
* (the original behaviour) left the successor long-polling `getUpdates`
|
|
20
20
|
* while the outgoing process was still draining in-flight queries — up
|
|
21
21
|
* to DRAIN_TIMEOUT_MS of two live pollers. Telegram answers only one of
|
|
@@ -40,6 +40,16 @@
|
|
|
40
40
|
* within a bounded window, and starts the daemon the way `talon
|
|
41
41
|
* start` does if the successor never comes up. Nothing in the
|
|
42
42
|
* handoff depends on a process that is about to call process.exit().
|
|
43
|
+
*
|
|
44
|
+
* And one thing 2026-09-20 added: the shutdown is entered directly,
|
|
45
|
+
* through the function app.ts registers with `setRespawnShutdown()`,
|
|
46
|
+
* not by sending ourselves a SIGTERM. The signal round trip bought
|
|
47
|
+
* nothing, and under Bun it lost the whole restart: by the time
|
|
48
|
+
* `/restart` ran, the process had silently lost its OS-level SIGTERM
|
|
49
|
+
* handler (see ./signals.ts), so the signal terminated it on the spot —
|
|
50
|
+
* no shutdown, no successor, no pidfile cleanup, and nothing in any log
|
|
51
|
+
* after "Respawn requested". Only a process that never registered — an
|
|
52
|
+
* embedder, a test — still falls back to the signal.
|
|
43
53
|
*/
|
|
44
54
|
|
|
45
55
|
import { spawn } from "node:child_process";
|
|
@@ -47,6 +57,18 @@ import { log, logError, openRespawnLog } from "../../util/log.js";
|
|
|
47
57
|
import { HANDOFF_WATCH_SUBCOMMAND } from "./handoff.js";
|
|
48
58
|
|
|
49
59
|
let pendingReason: string | null = null;
|
|
60
|
+
let shutdown: ((reason: string) => void) | null = null;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Register the graceful-shutdown entry a respawn should run. app.ts
|
|
64
|
+
* hands over `gracefulShutdown`; `respawnSelf()` then calls it in-process
|
|
65
|
+
* instead of round-tripping a SIGTERM through the OS. `null` clears it.
|
|
66
|
+
*/
|
|
67
|
+
export function setRespawnShutdown(
|
|
68
|
+
fn: ((reason: string) => void) | null,
|
|
69
|
+
): void {
|
|
70
|
+
shutdown = fn;
|
|
71
|
+
}
|
|
50
72
|
|
|
51
73
|
/**
|
|
52
74
|
* Argv flag that makes the daemon entry resolve its whole import graph
|
|
@@ -84,9 +106,8 @@ function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
|
|
|
84
106
|
}
|
|
85
107
|
|
|
86
108
|
/**
|
|
87
|
-
* Arm a respawn and
|
|
88
|
-
*
|
|
89
|
-
* and hands off via `spawnSuccessor()`.
|
|
109
|
+
* Arm a respawn and enter graceful shutdown, which stops the frontends,
|
|
110
|
+
* flushes state, and hands off via `spawnSuccessor()`.
|
|
90
111
|
*
|
|
91
112
|
* `reason` is logged for operator visibility (e.g. "telegram
|
|
92
113
|
* /restart"). The function returns immediately; the successor starts
|
|
@@ -95,10 +116,13 @@ function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
|
|
|
95
116
|
export function respawnSelf(reason: string): void {
|
|
96
117
|
log("shutdown", `Respawn requested (${reason})`);
|
|
97
118
|
pendingReason = reason;
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
119
|
+
// Don't exit here directly — that would skip the flush and leave the
|
|
120
|
+
// PID file dangling. The registered shutdown is the same path a
|
|
121
|
+
// SIGTERM takes, entered without the signal.
|
|
122
|
+
if (shutdown) {
|
|
123
|
+
shutdown(reason);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
102
126
|
process.kill(process.pid, "SIGTERM");
|
|
103
127
|
}
|
|
104
128
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep OS-level signal handlers armed under Bun.
|
|
3
|
+
*
|
|
4
|
+
* Bun (observed on 1.3.9) uninstalls the process-wide handler for a
|
|
5
|
+
* signal as soon as ANY listener for it is removed — even while other
|
|
6
|
+
* listeners remain:
|
|
7
|
+
*
|
|
8
|
+
* process.on("SIGTERM", a);
|
|
9
|
+
* process.on("SIGTERM", b);
|
|
10
|
+
* process.removeListener("SIGTERM", b);
|
|
11
|
+
* // `a` is still registered in JS, yet SigCgt in /proc/<pid>/status
|
|
12
|
+
* // no longer lists SIGTERM: the next one terminates the process
|
|
13
|
+
* // with the default action, running no JS at all.
|
|
14
|
+
*
|
|
15
|
+
* Node keeps the handler until the last listener goes. Adding any
|
|
16
|
+
* listener makes Bun install the handler again, which is what this
|
|
17
|
+
* guard relies on.
|
|
18
|
+
*
|
|
19
|
+
* Talon hit it through `write-file-atomic`: each call loads signal-exit,
|
|
20
|
+
* which puts its own listeners on the termination signals, and unloads
|
|
21
|
+
* it afterwards, removing them. The daemon writes its pidfile that way
|
|
22
|
+
* while booting — so from that moment every SIGTERM it sent itself
|
|
23
|
+
* (`/restart` and `/update`, before respawn.ts entered the shutdown
|
|
24
|
+
* directly) or received from outside (`talon stop`'s fallback, systemd,
|
|
25
|
+
* docker) killed it outright: no graceful shutdown, no successor, no
|
|
26
|
+
* pidfile cleanup. Every `/update` between 2026-09-18 (the move to Bun)
|
|
27
|
+
* and 2026-09-20 ended this way.
|
|
28
|
+
*
|
|
29
|
+
* The guard wraps `process.removeListener` / `process.off`. After a
|
|
30
|
+
* removal that still leaves real listeners on a signal, it re-adds a
|
|
31
|
+
* no-op sentinel listener (removing any earlier copy first), so the
|
|
32
|
+
* handler is installed again before the caller gets control back. Once
|
|
33
|
+
* the last real listener is gone the sentinel goes too, and the signal
|
|
34
|
+
* falls back to its default action exactly as it would on Node — a
|
|
35
|
+
* lingering no-op listener would otherwise make the process ignore it.
|
|
36
|
+
*
|
|
37
|
+
* Installed by the entry shim (src/index.ts) so every Talon process —
|
|
38
|
+
* daemon, MCP supervisor, handoff watcher — is covered before any
|
|
39
|
+
* library gets a chance to remove a listener. A no-op on other runtimes.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { isBunRuntime } from "../../util/runtime.js";
|
|
43
|
+
|
|
44
|
+
type Listener = (...args: unknown[]) => void;
|
|
45
|
+
|
|
46
|
+
/** The slice of `process` the guard touches; tests pass an EventEmitter. */
|
|
47
|
+
export interface SignalEmitter {
|
|
48
|
+
on(event: string | symbol, listener: Listener): unknown;
|
|
49
|
+
removeListener(event: string | symbol, listener: Listener): unknown;
|
|
50
|
+
off?(event: string | symbol, listener: Listener): unknown;
|
|
51
|
+
listeners(event: string | symbol): readonly unknown[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SignalGuardOptions {
|
|
55
|
+
/** Install even off Bun (tests exercise the wrapper on Node). */
|
|
56
|
+
force?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SIGNAL_EVENT = /^SIG[A-Z0-9]+$/;
|
|
60
|
+
|
|
61
|
+
const INSTALLED = Symbol.for("talon.signalListenerGuard");
|
|
62
|
+
|
|
63
|
+
type Guarded = SignalEmitter & { [INSTALLED]?: () => void };
|
|
64
|
+
|
|
65
|
+
/** The sentinel. Its only job is to exist, so Bun keeps the handler. */
|
|
66
|
+
function talonSignalRearm(): void {}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Wrap the emitter's listener removal so a signal never silently loses
|
|
70
|
+
* its OS handler. Idempotent: a second install returns the first one's
|
|
71
|
+
* uninstall. Production never uninstalls; tests do.
|
|
72
|
+
*/
|
|
73
|
+
export function installSignalListenerGuard(
|
|
74
|
+
target: SignalEmitter = process,
|
|
75
|
+
opts: SignalGuardOptions = {},
|
|
76
|
+
): () => void {
|
|
77
|
+
const emitter = target as Guarded;
|
|
78
|
+
if (!opts.force && !isBunRuntime()) return () => {};
|
|
79
|
+
const existing = emitter[INSTALLED];
|
|
80
|
+
if (existing) return existing;
|
|
81
|
+
|
|
82
|
+
const originalRemove = emitter.removeListener;
|
|
83
|
+
const originalOff = emitter.off;
|
|
84
|
+
|
|
85
|
+
const guardedRemove = function (
|
|
86
|
+
this: Guarded,
|
|
87
|
+
event: string | symbol,
|
|
88
|
+
listener: Listener,
|
|
89
|
+
): unknown {
|
|
90
|
+
const result = originalRemove.call(this, event, listener);
|
|
91
|
+
if (typeof event !== "string" || !SIGNAL_EVENT.test(event)) return result;
|
|
92
|
+
// Bun has just dropped the handler. What is left decides whether the
|
|
93
|
+
// signal should still be handled at all.
|
|
94
|
+
const remaining = this.listeners(event).filter(
|
|
95
|
+
(fn) => fn !== talonSignalRearm,
|
|
96
|
+
);
|
|
97
|
+
originalRemove.call(this, event, talonSignalRearm);
|
|
98
|
+
if (remaining.length > 0) this.on(event, talonSignalRearm);
|
|
99
|
+
return result;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
emitter.removeListener = guardedRemove;
|
|
103
|
+
if (originalOff) emitter.off = guardedRemove;
|
|
104
|
+
const uninstall = (): void => {
|
|
105
|
+
emitter.removeListener = originalRemove;
|
|
106
|
+
if (originalOff) emitter.off = originalOff;
|
|
107
|
+
delete emitter[INSTALLED];
|
|
108
|
+
};
|
|
109
|
+
emitter[INSTALLED] = uninstall;
|
|
110
|
+
return uninstall;
|
|
111
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* backends/frontends/plugins.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { installSignalListenerGuard } from "./core/daemon/signals.js";
|
|
13
14
|
import {
|
|
14
15
|
MCP_LAUNCH_SUBCOMMAND,
|
|
15
16
|
runSupervisor,
|
|
@@ -20,6 +21,12 @@ import {
|
|
|
20
21
|
runHandoffWatch,
|
|
21
22
|
} from "./core/daemon/handoff.js";
|
|
22
23
|
|
|
24
|
+
// Before anything can remove a signal listener: under Bun that would
|
|
25
|
+
// silently disarm SIGTERM/SIGINT for the whole process (core/daemon/
|
|
26
|
+
// signals.ts). Every Talon process passes through here, so every one
|
|
27
|
+
// is covered.
|
|
28
|
+
installSignalListenerGuard();
|
|
29
|
+
|
|
23
30
|
if (process.argv[2] === MCP_LAUNCH_SUBCOMMAND) {
|
|
24
31
|
await runSupervisor(process.argv.slice(3));
|
|
25
32
|
} else if (process.argv[2] === LUA_RUN_SUBCOMMAND) {
|