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/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,
@@ -28,6 +28,7 @@ import type { ReasoningEffortLevel } from "../types.js";
28
28
  * lockstep.
29
29
  */
30
30
  export const BACKEND_IDS = [
31
+ "agy",
31
32
  "claude",
32
33
  "codex",
33
34
  "kilo",
@@ -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 raises
17
- * SIGTERM; the successor is spawned by `spawnSuccessor()` at the tail of
18
- * graceful shutdown, once the frontends have stopped. Spawning up-front
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 raise SIGTERM on ourselves so the existing
88
- * graceful-shutdown path cleanly stops the frontends, flushes state,
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
- // SIGTERM triggers the graceful-shutdown handler in src/app.ts,
99
- // which stops the frontends, flushes state, spawns the successor,
100
- // and calls process.exit(0). Don't exit here directly — that would
101
- // skip the flush and leave the PID file dangling.
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) {