talon-agent 5.2.1 → 5.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.2.1",
3
+ "version": "5.2.2",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -110,7 +110,7 @@
110
110
  "@openai/agents": "^0.18.0",
111
111
  "@openai/codex-sdk": "^0.154.0",
112
112
  "@opencode-ai/sdk": "^1.17.4",
113
- "@playwright/mcp": "0.0.81",
113
+ "@playwright/mcp": "0.0.56",
114
114
  "@types/cross-spawn": "^6.0.6",
115
115
  "@types/qrcode": "^1.5.6",
116
116
  "baileys": "^7.0.0-rc14",
package/src/app.ts CHANGED
@@ -29,7 +29,11 @@ import { shutdownTriggers } from "./core/background/triggers/index.js";
29
29
  import { shutdownAgents } from "./core/agents/index.js";
30
30
  import { pruneSettledTriggers } from "./storage/triggers.js";
31
31
  import { startWatchdog, stopWatchdog } from "./util/watchdog.js";
32
- import { spawnSuccessor } from "./core/daemon/respawn.js";
32
+ import {
33
+ BOOT_SMOKE_FLAG,
34
+ BOOT_SMOKE_OK,
35
+ spawnSuccessor,
36
+ } from "./core/daemon/respawn.js";
33
37
  import {
34
38
  crashCleanup,
35
39
  crashStep,
@@ -67,6 +71,17 @@ import {
67
71
  stopResourceSampler,
68
72
  } from "./core/daemon/resource-sampler.js";
69
73
 
74
+ // `/update` runs the freshly installed tree with --boot-smoke before it
75
+ // hands off. Every static import above has just been resolved against
76
+ // the new node_modules — reaching this line is the proof that the tree
77
+ // the successor will run is importable at all. Nothing has booted yet,
78
+ // so this is both the strongest and the last harmless place to say so.
79
+ // See core/update/self-update.ts for what a failure does instead.
80
+ if (process.argv.includes(BOOT_SMOKE_FLAG)) {
81
+ console.log(BOOT_SMOKE_OK);
82
+ process.exit(0);
83
+ }
84
+
70
85
  const { config } = await bootPhase("bootstrap", () => bootstrap());
71
86
 
72
87
  // Record this process as the daemon. The gateway port is appended once
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Handoff watcher — the process that proves a `/restart` or `/update`
3
+ * actually landed.
4
+ *
5
+ * The outgoing daemon spawns its successor and then calls
6
+ * `process.exit()`. Until 2026-09-18 that was the whole handoff, which
7
+ * means nothing in the system knew whether the successor had come up.
8
+ * On that day one didn't: it was spawned, lived about twenty seconds,
9
+ * never bound its gateway, and died — and because the dying parent was
10
+ * the only party to the handoff, Talon simply stayed down until a human
11
+ * noticed forty-five minutes later.
12
+ *
13
+ * So the handoff gets a witness. `spawnSuccessor()` (./respawn.ts)
14
+ * starts this watcher detached, sharing the successor's respawn.log fd.
15
+ * It polls identity-verified discovery (./discovery.ts — `app: "talon"`,
16
+ * `mode: "daemon"`, matching pid) until the successor answers /health or
17
+ * the window closes. Only a /health answer counts: discovery will also
18
+ * report a daemon whose pid is merely alive, and "the process exists" is
19
+ * exactly the claim that was false for 20 seconds on 2026-09-18. If the
20
+ * successor never serves, the watcher starts the daemon exactly
21
+ * the way `talon start` does (./control.ts — same spawn, same boot
22
+ * verification) and says why in the log. The watcher is tiny on purpose:
23
+ * `src/index.ts` dispatches its subcommand before the app graph loads,
24
+ * so it costs a bare runtime and these three modules.
25
+ */
26
+
27
+ import { dirname, resolve } from "node:path";
28
+ import { log, logError, logWarn } from "../../util/log.js";
29
+ import { startDaemon, type StartOutcome } from "./control.js";
30
+ import { findRunningInstance, type RunningInstance } from "./discovery.js";
31
+ import { isProcessAlive } from "./pidfile.js";
32
+
33
+ /** Hidden subcommand: `talon _handoff-watch <successor-pid>`. */
34
+ export const HANDOFF_WATCH_SUBCOMMAND = "_handoff-watch";
35
+
36
+ /**
37
+ * How long a successor gets to answer /health. Generous on purpose: a
38
+ * cold boot with every plugin and MCP server takes ~10s on the reference
39
+ * host, and `startDaemon()` itself waits 30s before calling a boot late.
40
+ */
41
+ const HANDOFF_WINDOW_MS = 90_000;
42
+ const POLL_MS = 500;
43
+
44
+ export type HandoffOutcome =
45
+ | { ok: true; via: "successor" | "restart"; pid: number; port?: number }
46
+ | { ok: false; reason: string };
47
+
48
+ export type WatchHandoffOptions = {
49
+ /** The pid `spawnSuccessor()` created. */
50
+ childPid: number;
51
+ /** Repo/package root, for the `talon start` equivalent. */
52
+ pkgRoot: string;
53
+ windowMs?: number;
54
+ pollMs?: number;
55
+ pidfilePath?: string;
56
+ /** Injection seams for tests. */
57
+ find?: typeof findRunningInstance;
58
+ alive?: (pid: number) => boolean;
59
+ start?: typeof startDaemon;
60
+ sleep?: (ms: number) => Promise<void>;
61
+ };
62
+
63
+ function defaultSleep(ms: number): Promise<void> {
64
+ return new Promise((r) => setTimeout(r, ms));
65
+ }
66
+
67
+ /** Why the wait ended without a live daemon. */
68
+ type WaitFailure = "successor-exited" | "window-expired";
69
+
70
+ /** Discovery found a process; only a /health answer proves it serves. */
71
+ function isServing(instance: RunningInstance | null): boolean {
72
+ return instance?.health !== undefined;
73
+ }
74
+
75
+ /**
76
+ * Poll until a serving daemon answers, the successor process
77
+ * disappears, or the window closes. A daemon that isn't our child still
78
+ * counts: the goal is a live Talon, not a particular pid.
79
+ */
80
+ async function awaitDaemon(
81
+ opts: WatchHandoffOptions,
82
+ ): Promise<RunningInstance | WaitFailure> {
83
+ const find = opts.find ?? findRunningInstance;
84
+ const alive = opts.alive ?? isProcessAlive;
85
+ const sleep = opts.sleep ?? defaultSleep;
86
+ const pollMs = opts.pollMs ?? POLL_MS;
87
+ const deadline = Date.now() + (opts.windowMs ?? HANDOFF_WINDOW_MS);
88
+
89
+ while (Date.now() < deadline) {
90
+ const instance = await find(opts.pidfilePath);
91
+ if (instance && isServing(instance)) return instance;
92
+ if (!alive(opts.childPid)) return "successor-exited";
93
+ await sleep(pollMs);
94
+ }
95
+ return "window-expired";
96
+ }
97
+
98
+ const FAILURE_DETAIL: Record<WaitFailure, string> = {
99
+ "successor-exited": "the successor exited before serving /health",
100
+ "window-expired": "the successor never answered /health in time",
101
+ };
102
+
103
+ function describeStart(outcome: StartOutcome): string {
104
+ if (outcome.ok) return `started (pid ${outcome.pid})`;
105
+ if (outcome.reason === "already-running") {
106
+ return `already running (pid ${outcome.instance.pid})`;
107
+ }
108
+ if (outcome.reason === "boot-timeout") return "spawned but not yet healthy";
109
+ return `${outcome.reason}${outcome.detail ? `: ${outcome.detail}` : ""}`;
110
+ }
111
+
112
+ function toOutcome(started: StartOutcome, why: string): HandoffOutcome {
113
+ if (started.ok) {
114
+ return { ok: true, via: "restart", pid: started.pid, port: started.port };
115
+ }
116
+ if (started.reason === "already-running") {
117
+ const inst = started.instance;
118
+ // `talon start` refuses while a pid is alive — right, since a second
119
+ // daemon would fight the first for Telegram's getUpdates. But an
120
+ // alive pid that has never served /health is the failure, not the
121
+ // recovery, so it is reported as one.
122
+ if (!isServing(inst)) {
123
+ return {
124
+ ok: false,
125
+ reason: `${why}; pid ${inst.pid} is alive but not serving — kill it and run \`talon start\``,
126
+ };
127
+ }
128
+ return { ok: true, via: "restart", pid: inst.pid, port: inst.port };
129
+ }
130
+ return { ok: false, reason: `${why}; restart ${describeStart(started)}` };
131
+ }
132
+
133
+ /**
134
+ * Verify the handoff, and repair it if it failed. Never throws: this
135
+ * process exists only to make the outcome known.
136
+ */
137
+ export async function watchHandoff(
138
+ opts: WatchHandoffOptions,
139
+ ): Promise<HandoffOutcome> {
140
+ const result = await awaitDaemon(opts);
141
+ if (typeof result !== "string") {
142
+ const via = result.pid === opts.childPid ? "successor" : "restart";
143
+ log(
144
+ "shutdown",
145
+ `Handoff verified — daemon pid ${result.pid} serving on ` +
146
+ `:${result.port ?? "?"} (${via})`,
147
+ );
148
+ return { ok: true, via, pid: result.pid, port: result.port };
149
+ }
150
+
151
+ const why = FAILURE_DETAIL[result];
152
+ logWarn(
153
+ "shutdown",
154
+ `Handoff failed — ${why}; starting Talon the way \`talon start\` does`,
155
+ );
156
+ const start = opts.start ?? startDaemon;
157
+ const started = await start({
158
+ pkgRoot: opts.pkgRoot,
159
+ pidfilePath: opts.pidfilePath,
160
+ });
161
+ const outcome = toOutcome(started, why);
162
+ if (outcome.ok)
163
+ log("shutdown", `Handoff recovered — ${describeStart(started)}`);
164
+ else logError("shutdown", `Handoff unrecoverable — ${outcome.reason}`);
165
+ return outcome;
166
+ }
167
+
168
+ /** src/core/daemon/ → the package root. */
169
+ function packageRoot(): string {
170
+ const here = import.meta.dirname ?? process.cwd();
171
+ return here.includes("$bunfs") || here.includes("~BUN")
172
+ ? dirname(process.execPath)
173
+ : resolve(here, "..", "..", "..");
174
+ }
175
+
176
+ /** Entry point for `talon _handoff-watch <pid>` (src/index.ts). */
177
+ export async function runHandoffWatch(argv: readonly string[]): Promise<void> {
178
+ const childPid = Number.parseInt(argv[0] ?? "", 10);
179
+ if (!Number.isInteger(childPid) || childPid <= 0) {
180
+ logError("shutdown", `Handoff watcher got no successor pid (${argv[0]})`);
181
+ process.exitCode = 2;
182
+ return;
183
+ }
184
+ log("shutdown", `Handoff watcher armed for pid ${childPid}`);
185
+ try {
186
+ const outcome = await watchHandoff({ childPid, pkgRoot: packageRoot() });
187
+ process.exitCode = outcome.ok ? 0 : 1;
188
+ } catch (err) {
189
+ logError("shutdown", "Handoff watcher crashed", err);
190
+ process.exitCode = 1;
191
+ }
192
+ }
@@ -1,38 +1,88 @@
1
1
  /**
2
- * Self-respawn helper for /restart commands across frontends.
2
+ * Self-respawn helper for /restart and /update across frontends.
3
3
  *
4
- * Spawns a fresh copy of the current process — same Node binary,
5
- * same `execArgv` (preserving the tsx loader so `.ts` entrypoints
6
- * still resolve), same script + user args, same cwd + env. The new
7
- * child is detached with stdio:"ignore" so it survives the parent's
8
- * exit; calling `unref()` lets the parent exit without waiting on
9
- * it.
4
+ * Spawns a fresh copy of the current process — same runtime binary, same
5
+ * `execArgv` (preserving a loader so `.ts` entrypoints still resolve),
6
+ * same script + user args, same cwd + env. The new child is detached so
7
+ * it survives the parent's exit; `unref()` lets the parent exit without
8
+ * waiting on it.
10
9
  *
11
- * Why not call the daemon's `talon restart` CLI? That path assumed
12
- * the bot was started via the daemon (talon.pid managed by
13
- * `daemonStart()`) and broke for anything else — `npm start`, `npx
14
- * tsx src/index.ts`, systemd, foreman, pm2, or running under a
15
- * debugger. Respawning from our own `process.argv` works regardless
16
- * of launch method.
10
+ * Why not call the daemon's `talon restart` CLI? That path assumed the
11
+ * bot was started via the daemon (talon.pid managed by `daemonStart()`)
12
+ * and broke for anything else — `npm start`, `npx tsx src/index.ts`,
13
+ * systemd, foreman, pm2, or running under a debugger. Respawning from
14
+ * our own `process.argv` works regardless of launch method.
17
15
  *
18
- * Ordering matters. `respawnSelf()` only *arms* the handoff and
19
- * raises SIGTERM; the successor is spawned by `spawnSuccessor()` at
20
- * the tail of graceful shutdown, once the frontends have stopped.
21
- * Spawning up-front (the previous behaviour) left the successor
22
- * long-polling `getUpdates` while the outgoing process was still
23
- * draining in-flight queries — up to DRAIN_TIMEOUT_MS of two live
24
- * pollers. Telegram answers only one of them and re-delivers the
25
- * unconfirmed updates to the other, so a restart mid-turn produced
26
- * a 409 Conflict on the way out and duplicate replies on the way in.
27
- * Releasing the poll before the successor binds it removes the
28
- * overlap rather than relying on grammy's 409 retry to paper over it.
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
19
+ * (the original behaviour) left the successor long-polling `getUpdates`
20
+ * while the outgoing process was still draining in-flight queries — up
21
+ * to DRAIN_TIMEOUT_MS of two live pollers. Telegram answers only one of
22
+ * them and re-delivers the unconfirmed updates to the other, so a
23
+ * restart mid-turn produced a 409 Conflict on the way out and duplicate
24
+ * replies on the way in. Releasing the poll before the successor binds
25
+ * it removes the overlap rather than relying on grammy's 409 retry to
26
+ * paper over it.
27
+ *
28
+ * Two things the 2026-09-18 outage added, both about the fact that the
29
+ * outgoing process is dying and cannot be the one responsible for the
30
+ * outcome:
31
+ *
32
+ * - The successor's stdout and stderr go to ~/.talon/respawn.log, not
33
+ * to "ignore". A successor that dies before its own logger exists —
34
+ * a broken import after a dependency install, a fatal bind, a
35
+ * runtime that aborts — used to leave no trace in any file, on any
36
+ * process. That is precisely what happened: a successor was spawned,
37
+ * lived ~20s, never bound its gateway, and vanished without a line.
38
+ * - A watcher process is spawned alongside it (./handoff.ts). It
39
+ * outlives us, verifies the successor over identity-checked /health
40
+ * within a bounded window, and starts the daemon the way `talon
41
+ * start` does if the successor never comes up. Nothing in the
42
+ * handoff depends on a process that is about to call process.exit().
29
43
  */
30
44
 
31
45
  import { spawn } from "node:child_process";
32
- import { log, logError } from "../../util/log.js";
46
+ import { log, logError, openRespawnLog } from "../../util/log.js";
47
+ import { HANDOFF_WATCH_SUBCOMMAND } from "./handoff.js";
33
48
 
34
49
  let pendingReason: string | null = null;
35
50
 
51
+ /**
52
+ * Argv flag that makes the daemon entry resolve its whole import graph
53
+ * and exit 0 without booting (src/app.ts acts on it before the first
54
+ * bootstrap step). `/update` runs the freshly installed tree with this
55
+ * flag before handing off — see core/update/self-update.ts.
56
+ */
57
+ export const BOOT_SMOKE_FLAG = "--boot-smoke";
58
+
59
+ /** Printed by a successful smoke run; the update step matches on it. */
60
+ export const BOOT_SMOKE_OK = "talon boot-smoke ok";
61
+
62
+ /**
63
+ * A bun-compiled binary embeds its source tree: `process.argv[1]` points
64
+ * into the virtual FS ($bunfs / ~BUN) and has no path on disk, so the
65
+ * binary is re-invoked with no script argument at all.
66
+ */
67
+ function isEmbeddedEntry(entry: string): boolean {
68
+ return entry.includes("$bunfs") || entry.includes("~BUN");
69
+ }
70
+
71
+ /** The exact command that re-runs this process. */
72
+ export function successorCommand(): { cmd: string; args: string[] } {
73
+ return {
74
+ cmd: process.argv[0],
75
+ args: [...process.execArgv, ...process.argv.slice(1)],
76
+ };
77
+ }
78
+
79
+ /** The same runtime + entry, re-invoked with one of our own subcommands. */
80
+ function selfInvocation(extra: string[]): { cmd: string; args: string[] } {
81
+ const entry = process.argv[1] ?? "";
82
+ const prefix = isEmbeddedEntry(entry) ? [] : [...process.execArgv, entry];
83
+ return { cmd: process.argv[0], args: [...prefix, ...extra] };
84
+ }
85
+
36
86
  /**
37
87
  * Arm a respawn and raise SIGTERM on ourselves so the existing
38
88
  * graceful-shutdown path cleanly stops the frontends, flushes state,
@@ -57,46 +107,71 @@ export function respawnRequested(): boolean {
57
107
  return pendingReason !== null;
58
108
  }
59
109
 
110
+ export type SpawnFn = typeof spawn;
111
+
112
+ /**
113
+ * Start the watcher that outlives this process and answers the only
114
+ * question that matters: did the successor actually come up? Never
115
+ * throws — a missing watcher must not cost us the successor itself.
116
+ */
117
+ function spawnHandoffWatcher(
118
+ childPid: number,
119
+ fd: number | null,
120
+ spawnFn: SpawnFn,
121
+ ): void {
122
+ try {
123
+ const { cmd, args } = selfInvocation([
124
+ HANDOFF_WATCH_SUBCOMMAND,
125
+ String(childPid),
126
+ ]);
127
+ const watcher = spawnFn(cmd, args, {
128
+ cwd: process.cwd(),
129
+ detached: true,
130
+ stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
131
+ env: { ...process.env },
132
+ });
133
+ watcher.once("error", (err) => {
134
+ logError("shutdown", "Handoff watcher failed to start", err);
135
+ });
136
+ watcher.unref();
137
+ log("shutdown", `Handoff watcher started (pid ${watcher.pid})`);
138
+ } catch (err) {
139
+ logError("shutdown", "Handoff watcher failed to start", err);
140
+ }
141
+ }
142
+
60
143
  /**
61
- * Spawn the successor process. Called at the end of graceful
62
- * shutdown, after the frontends have stopped — so the incoming
63
- * process binds Telegram's long-poll only once this one has let go
64
- * of it. No-op unless `respawnSelf()` armed a handoff.
144
+ * Spawn the successor process. Called at the end of graceful shutdown,
145
+ * after the frontends have stopped — so the incoming process binds
146
+ * Telegram's long-poll only once this one has let go of it. No-op unless
147
+ * `respawnSelf()` armed a handoff.
65
148
  *
66
149
  * Never throws: a failed handoff must not prevent this process from
67
- * exiting. An external supervisor (systemd, pm2, the user's
68
- * terminal) can pick things up.
150
+ * exiting. The watcher is the safety net, not an external supervisor.
69
151
  */
70
- export function spawnSuccessor(): void {
152
+ export function spawnSuccessor(spawnFn: SpawnFn = spawn): void {
71
153
  if (pendingReason === null) return;
72
154
  const reason = pendingReason;
73
155
  pendingReason = null;
74
156
 
157
+ // One fd for both streams, shared with the watcher: the successor's
158
+ // boot output and the watcher's verdict land in the same file, in
159
+ // order, even when the successor never gets far enough to log.
160
+ const fd = openRespawnLog();
75
161
  try {
76
- const child = spawn(
77
- process.argv[0],
78
- [...process.execArgv, ...process.argv.slice(1)],
79
- {
80
- cwd: process.cwd(),
81
- detached: true,
82
- stdio: "ignore",
83
- env: { ...process.env },
84
- },
85
- );
162
+ const child = spawnFn(process.argv[0], successorCommand().args, {
163
+ cwd: process.cwd(),
164
+ detached: true,
165
+ stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
166
+ env: { ...process.env },
167
+ });
86
168
  child.once("error", (err) => {
87
- logError(
88
- "shutdown",
89
- `Respawn failed; exiting without a successor — restart manually`,
90
- err,
91
- );
169
+ logError("shutdown", `Respawn failed (${reason}) — see respawn.log`, err);
92
170
  });
93
171
  child.unref();
94
172
  log("shutdown", `Respawn child started (pid ${child.pid}) — ${reason}`);
173
+ if (child.pid) spawnHandoffWatcher(child.pid, fd, spawnFn);
95
174
  } catch (err) {
96
- logError(
97
- "shutdown",
98
- `Respawn failed; exiting without a successor — restart manually`,
99
- err,
100
- );
175
+ logError("shutdown", `Respawn failed (${reason}) — see respawn.log`, err);
101
176
  }
102
177
  }
@@ -8,6 +8,17 @@
8
8
  * source tree on disk, so {@link getRepoRoot} returns `null` and the
9
9
  * `/update` command is never registered.
10
10
  *
11
+ * The install is verified before anyone hands off to it. `npm install`
12
+ * rewrites node_modules underneath the still-running process, so a bad
13
+ * dependency resolution does not surface until the *successor* imports
14
+ * the tree — detached, with its output going nowhere, at the one moment
15
+ * the daemon has no one left to report to. On 2026-09-18 that cost a
16
+ * 45-minute outage. The last step of an update therefore runs the new
17
+ * tree in a child with {@link BOOT_SMOKE_FLAG}: it resolves the daemon's
18
+ * entire import graph and exits without booting. If it fails, the update
19
+ * fails — the caller reports it to the chat and the current process, the
20
+ * one that still works, keeps running.
21
+ *
11
22
  * The update force-syncs the checkout to the remote branch with
12
23
  * `git reset --hard` (plus `git clean -fd`), discarding any local edits
13
24
  * or diverged commits. A bot host is meant to mirror the remote exactly,
@@ -22,6 +33,11 @@ import { execFile } from "node:child_process";
22
33
  import { existsSync } from "node:fs";
23
34
  import { dirname, join } from "node:path";
24
35
  import { fileURLToPath } from "node:url";
36
+ import {
37
+ BOOT_SMOKE_FLAG,
38
+ BOOT_SMOKE_OK,
39
+ successorCommand,
40
+ } from "../daemon/respawn.js";
25
41
 
26
42
  /** Tuning knobs for {@link runSelfUpdate}. */
27
43
  export interface UpdateOptions {
@@ -36,6 +52,11 @@ export interface UpdateOptions {
36
52
  setup?: readonly string[];
37
53
  /** Override the repo root (tests). Defaults to {@link getRepoRoot}. */
38
54
  repoRoot?: string;
55
+ /**
56
+ * The command that re-runs this process, used for the post-install
57
+ * import check. Defaults to our own argv (tests override it).
58
+ */
59
+ entry?: { cmd: string; args: readonly string[] };
39
60
  /** Injectable command runner (tests). */
40
61
  runner?: CommandRunner;
41
62
  }
@@ -70,6 +91,8 @@ export type CommandRunner = (
70
91
  ) => Promise<{ ok: boolean; output: string }>;
71
92
 
72
93
  const GIT_TIMEOUT_MS = 60_000;
94
+ /** A cold import of the whole daemon graph, on a busy host. */
95
+ const VERIFY_TIMEOUT_MS = 180_000;
73
96
  const INSTALL_TIMEOUT_MS = 300_000;
74
97
  const SETUP_TIMEOUT_MS = 300_000;
75
98
  const MAX_OUTPUT_BUFFER = 16 * 1024 * 1024;
@@ -265,5 +288,26 @@ export async function runSelfUpdate(
265
288
  }
266
289
  }
267
290
 
291
+ const entry = opts.entry ?? successorCommand();
292
+ const verify = await record(
293
+ "verify import",
294
+ entry.cmd,
295
+ [...entry.args, BOOT_SMOKE_FLAG],
296
+ VERIFY_TIMEOUT_MS,
297
+ );
298
+ if (!verify.ok || !verify.output.includes(BOOT_SMOKE_OK)) {
299
+ return {
300
+ ok: false,
301
+ repoRoot,
302
+ steps,
303
+ before,
304
+ after,
305
+ changed,
306
+ error:
307
+ `the updated tree does not import — not restarting into it. ` +
308
+ `Still running ${before}; the checkout is at ${after}.`,
309
+ };
310
+ }
311
+
268
312
  return { ok: true, repoRoot, steps, before, after, changed: true };
269
313
  }
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Talon entry shim.
3
3
  *
4
- * Dispatches the hidden `_mcp-launch` (MCP supervisor) and `_lua-run`
5
- * (WASM Lua trigger runner) subcommands BEFORE the app graph loads —
6
- * both are Talon re-invoking itself (see core/mcp-hub/launcher.ts). The
7
- * dynamic import keeps these helper processes light: they evaluate this
8
- * shim and their own module, never the backends/frontends/plugins.
4
+ * Dispatches the hidden `_mcp-launch` (MCP supervisor), `_lua-run`
5
+ * (WASM Lua trigger runner) and `_handoff-watch` (restart/update
6
+ * witness) subcommands BEFORE the app graph loads — all three are Talon
7
+ * re-invoking itself (see core/mcp-hub/launcher.ts, core/daemon/
8
+ * handoff.ts). The dynamic import keeps these helper processes light:
9
+ * they evaluate this shim and their own module, never the
10
+ * backends/frontends/plugins.
9
11
  */
10
12
 
11
13
  import {
@@ -13,11 +15,17 @@ import {
13
15
  runSupervisor,
14
16
  } from "./core/mcp-hub/launcher.js";
15
17
  import { LUA_RUN_SUBCOMMAND, runLuaMain } from "./core/scripts/lua.js";
18
+ import {
19
+ HANDOFF_WATCH_SUBCOMMAND,
20
+ runHandoffWatch,
21
+ } from "./core/daemon/handoff.js";
16
22
 
17
23
  if (process.argv[2] === MCP_LAUNCH_SUBCOMMAND) {
18
24
  await runSupervisor(process.argv.slice(3));
19
25
  } else if (process.argv[2] === LUA_RUN_SUBCOMMAND) {
20
26
  await runLuaMain(process.argv.slice(3));
27
+ } else if (process.argv[2] === HANDOFF_WATCH_SUBCOMMAND) {
28
+ await runHandoffWatch(process.argv.slice(3));
21
29
  } else {
22
30
  await import("./app.js");
23
31
  }
@@ -25,9 +25,9 @@
25
25
  * with "428 Precondition Required". @playwright/mcp is therefore pinned
26
26
  * exactly in package.json (0.0.56 → playwright 1.58.x, matching python
27
27
  * playwright 1.58 which hosts Camoufox — camoufox itself caps playwright at
28
- * <1.61, so the node client cannot chase latest). Bump BOTH sides together,
29
- * deliberately — do not let a routine dependency bump move one without the
30
- * other.
28
+ * <1.61, so the node client cannot chase latest). The pin is enforced, not
29
+ * just documented: see version-coupling.ts (unit test that fails CI on a
30
+ * bump, validateConfig refusal, and a live handshake probe at init).
31
31
  */
32
32
 
33
33
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -35,6 +35,12 @@ import { dirname, resolve } from "node:path";
35
35
  import type { TalonPlugin } from "../../core/plugin/types.js";
36
36
  import { files } from "../../util/paths.js";
37
37
  import { log } from "../../util/log.js";
38
+ import {
39
+ ENDPOINT_PLAYWRIGHT_MINOR,
40
+ bundledPlaywrightVersion,
41
+ couplingError,
42
+ probeEndpoint,
43
+ } from "./version-coupling.js";
38
44
 
39
45
  export function createPlaywrightPlugin(config: {
40
46
  browser?: string;
@@ -141,6 +147,13 @@ export function createPlaywrightPlugin(config: {
141
147
  );
142
148
  }
143
149
 
150
+ // Endpoint mode: refuse to start on a client/server minor mismatch
151
+ // rather than fail every tool call later (see version-coupling.ts).
152
+ if (endpoint) {
153
+ const coupling = couplingError(bundledPlaywrightVersion());
154
+ if (coupling) errors.push(coupling);
155
+ }
156
+
144
157
  return errors.length > 0 ? errors : undefined;
145
158
  },
146
159
 
@@ -149,6 +162,29 @@ export function createPlaywrightPlugin(config: {
149
162
  "playwright",
150
163
  `Ready (${endpoint ? `Camoufox @ ${endpoint}` : `${browser}, headless=${headless}`})`,
151
164
  );
165
+ if (!endpoint) return;
166
+ // Live handshake with the bundled client version: catches a drifted
167
+ // server (the static check only knows the expected minor).
168
+ const client =
169
+ bundledPlaywrightVersion() ?? `${ENDPOINT_PLAYWRIGHT_MINOR}.0`;
170
+ const probe = await probeEndpoint(endpoint, client);
171
+ switch (probe.state) {
172
+ case "match":
173
+ log("playwright", `Endpoint handshake OK (Playwright ${client})`);
174
+ break;
175
+ case "mismatch":
176
+ log(
177
+ "playwright",
178
+ `ERROR: endpoint ${endpoint} is on Playwright ${probe.server} but the bundled client is ${probe.client} — every browser tool call will fail with 428. Align the python playwright hosting Camoufox with ENDPOINT_PLAYWRIGHT_MINOR (${ENDPOINT_PLAYWRIGHT_MINOR}) or re-pin @playwright/mcp.`,
179
+ );
180
+ break;
181
+ case "unreachable":
182
+ log(
183
+ "playwright",
184
+ `Warning: endpoint ${endpoint} did not answer the handshake probe (${probe.reason}); browser tools will fail until it is up.`,
185
+ );
186
+ break;
187
+ }
152
188
  },
153
189
  };
154
190
  }
@@ -28,6 +28,11 @@ import {
28
28
  type ProvisionOutcome,
29
29
  } from "../../core/plugin/provision.js";
30
30
  import { dirs } from "../../util/paths.js";
31
+ import {
32
+ ENDPOINT_PLAYWRIGHT_MINOR,
33
+ bundledPlaywrightVersion,
34
+ couplingError,
35
+ } from "./version-coupling.js";
31
36
 
32
37
  /** Engines whose builds Playwright manages (vs system channels). */
33
38
  const MANAGED_ENGINES = new Set(["chromium", "firefox", "webkit"]);
@@ -54,6 +59,8 @@ export interface PlaywrightProvisionDeps {
54
59
  listDir?: (p: string) => string[];
55
60
  /** playwright-core's browser registry (default: browsers.json beside the CLI). */
56
61
  registry?: BrowserDescriptor[];
62
+ /** Bundled playwright-core version (default: read from node_modules). */
63
+ bundledPlaywrightVersion?: string;
57
64
  }
58
65
 
59
66
  /** One entry of playwright-core's browsers.json. */
@@ -287,12 +294,26 @@ export function inspectPlaywright(
287
294
  deps: PlaywrightProvisionDeps = {},
288
295
  ): DoctorCheck[] {
289
296
  if (section.endpoint ?? section.endpointFile) {
297
+ const bundled = deps.bundledPlaywrightVersion ?? bundledPlaywrightVersion();
298
+ const coupling = couplingError(bundled);
290
299
  return [
291
300
  {
292
301
  label: "Playwright: remote endpoint mode",
293
302
  status: "info",
294
303
  detail: "browser lives on the remote end",
295
304
  },
305
+ coupling
306
+ ? {
307
+ label: "Playwright: client/endpoint version mismatch",
308
+ status: "fail",
309
+ detail: coupling,
310
+ issue: true,
311
+ }
312
+ : {
313
+ label: `Playwright: client on endpoint minor ${ENDPOINT_PLAYWRIGHT_MINOR}`,
314
+ status: "ok",
315
+ detail: bundled ? `playwright-core ${bundled}` : undefined,
316
+ },
296
317
  ];
297
318
  }
298
319
  const browser = section.browser ?? "chromium";
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Endpoint-mode version coupling — the machinery behind the pin.
3
+ *
4
+ * In endpoint mode the MCP child connects to a remote Playwright server
5
+ * (the python-playwright process hosting Camoufox). Playwright's server
6
+ * refuses the WebSocket upgrade with "428 Precondition Required" when the
7
+ * client's playwright MINOR (sent in the User-Agent, `Playwright/x.y.z`)
8
+ * differs from its own — so a client bump that nobody noticed turns every
9
+ * browser tool call into an opaque error at the worst possible moment.
10
+ *
11
+ * Three guards, all keyed on ENDPOINT_PLAYWRIGHT_MINOR:
12
+ * - a unit test asserts the playwright-core that @playwright/mcp bundles is
13
+ * on that minor, so a dependency bump goes red in CI instead of green;
14
+ * - `validateConfig` refuses to start the plugin on a mismatch, with a
15
+ * message that names both versions and the fix;
16
+ * - `probeEndpoint` performs the real handshake at init and reports the
17
+ * server's verdict, so a drifted *server* is caught too.
18
+ *
19
+ * Bumping: change ENDPOINT_PLAYWRIGHT_MINOR and `@playwright/mcp` in
20
+ * package.json in the same commit, after upgrading the python side
21
+ * (camoufox caps python-playwright, so the node side cannot chase latest).
22
+ */
23
+
24
+ import { randomBytes } from "node:crypto";
25
+ import { readFileSync } from "node:fs";
26
+ import { request as httpRequest } from "node:http";
27
+ import { request as httpsRequest } from "node:https";
28
+ import { resolve } from "node:path";
29
+
30
+ /**
31
+ * Playwright minor of the remote endpoint (python playwright behind
32
+ * Camoufox). Must equal the minor of the playwright-core bundled by the
33
+ * pinned @playwright/mcp — see the header comment before changing it.
34
+ */
35
+ export const ENDPOINT_PLAYWRIGHT_MINOR = "1.58";
36
+
37
+ /** "1.58.0-alpha-2026-01-16" → "1.58". */
38
+ export function minorOf(version: string): string {
39
+ const m = version.match(/^(\d+)\.(\d+)/);
40
+ return m ? `${m[1]}.${m[2]}` : version;
41
+ }
42
+
43
+ function defaultModulesRoot(): string {
44
+ return resolve(import.meta.dirname ?? ".", "../../../node_modules");
45
+ }
46
+
47
+ /** Version of the playwright-core the MCP child will run with. */
48
+ export function bundledPlaywrightVersion(
49
+ modulesRoot: string = defaultModulesRoot(),
50
+ ): string | undefined {
51
+ try {
52
+ const pkg = JSON.parse(
53
+ readFileSync(
54
+ resolve(modulesRoot, "playwright-core/package.json"),
55
+ "utf-8",
56
+ ),
57
+ ) as { version?: string };
58
+ return pkg.version;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Static check: does the bundled client sit on the endpoint's minor?
66
+ * Returns an error message, or undefined when coupled (or when the bundle
67
+ * cannot be read — that is reported separately as a missing install).
68
+ */
69
+ export function couplingError(
70
+ bundled: string | undefined,
71
+ expectedMinor: string = ENDPOINT_PLAYWRIGHT_MINOR,
72
+ ): string | undefined {
73
+ if (!bundled) return undefined;
74
+ const got = minorOf(bundled);
75
+ if (got === expectedMinor) return undefined;
76
+ return (
77
+ `@playwright/mcp bundles playwright-core ${bundled} (minor ${got}) but the ` +
78
+ `remote endpoint is on Playwright ${expectedMinor} — every browser tool call ` +
79
+ `would fail with "428 Precondition Required". Pin @playwright/mcp to the ` +
80
+ `release that bundles playwright-core ${expectedMinor}.x, or bump ` +
81
+ `ENDPOINT_PLAYWRIGHT_MINOR together with the python side ` +
82
+ `(src/plugins/playwright/version-coupling.ts).`
83
+ );
84
+ }
85
+
86
+ export type EndpointProbe =
87
+ | { state: "match"; client: string }
88
+ | { state: "mismatch"; client: string; server: string }
89
+ | { state: "unreachable"; client: string; reason: string };
90
+
91
+ /** Parse the body Playwright's server sends with its 428. */
92
+ export function parseMismatch(
93
+ body: string,
94
+ ): { server: string; client: string } | undefined {
95
+ const server = body.match(/server version:\s*v?([\d.]+)/);
96
+ const client = body.match(/client version:\s*v?([\d.]+)/);
97
+ return server && client
98
+ ? { server: server[1], client: client[1] }
99
+ : undefined;
100
+ }
101
+
102
+ /**
103
+ * Perform the WebSocket upgrade the MCP child performs, advertising
104
+ * `clientVersion`, and read the server's verdict. Never throws; never
105
+ * leaves a connection open (a completed upgrade is torn down at once, and
106
+ * Playwright's server treats that as an ordinary client disconnect).
107
+ */
108
+ export function probeEndpoint(
109
+ endpoint: string,
110
+ clientVersion: string,
111
+ timeoutMs = 3000,
112
+ ): Promise<EndpointProbe> {
113
+ return new Promise((settle) => {
114
+ let url: URL;
115
+ try {
116
+ url = new URL(endpoint);
117
+ } catch {
118
+ settle({
119
+ state: "unreachable",
120
+ client: clientVersion,
121
+ reason: `invalid endpoint URL: ${endpoint}`,
122
+ });
123
+ return;
124
+ }
125
+ const secure = url.protocol === "wss:" || url.protocol === "https:";
126
+ const request = secure ? httpsRequest : httpRequest;
127
+ const req = request({
128
+ host: url.hostname,
129
+ port: url.port || (secure ? 443 : 80),
130
+ path: `${url.pathname}${url.search}`,
131
+ method: "GET",
132
+ headers: {
133
+ Connection: "Upgrade",
134
+ Upgrade: "websocket",
135
+ "Sec-WebSocket-Version": "13",
136
+ "Sec-WebSocket-Key": randomBytes(16).toString("base64"),
137
+ "User-Agent": `Playwright/${clientVersion} (talon endpoint probe)`,
138
+ },
139
+ timeout: timeoutMs,
140
+ });
141
+ let settled = false;
142
+ const done = (result: EndpointProbe) => {
143
+ if (settled) return;
144
+ settled = true;
145
+ req.destroy();
146
+ settle(result);
147
+ };
148
+ req.on("upgrade", (_res, socket) => {
149
+ socket.destroy();
150
+ done({ state: "match", client: clientVersion });
151
+ });
152
+ req.on("response", (res) => {
153
+ let body = "";
154
+ res.setEncoding("utf-8");
155
+ res.on("data", (chunk: string) => {
156
+ body += chunk;
157
+ });
158
+ res.on("end", () => {
159
+ if (res.statusCode === 428) {
160
+ const parsed = parseMismatch(body);
161
+ done(
162
+ parsed
163
+ ? {
164
+ state: "mismatch",
165
+ client: clientVersion,
166
+ server: parsed.server,
167
+ }
168
+ : {
169
+ state: "unreachable",
170
+ client: clientVersion,
171
+ reason: "428 without a version box in the body",
172
+ },
173
+ );
174
+ return;
175
+ }
176
+ done({
177
+ state: "unreachable",
178
+ client: clientVersion,
179
+ reason: `HTTP ${res.statusCode ?? "?"} instead of an upgrade`,
180
+ });
181
+ });
182
+ });
183
+ req.on("timeout", () =>
184
+ done({ state: "unreachable", client: clientVersion, reason: "timeout" }),
185
+ );
186
+ req.on("error", (err: Error) =>
187
+ done({
188
+ state: "unreachable",
189
+ client: clientVersion,
190
+ reason: err.message,
191
+ }),
192
+ );
193
+ req.end();
194
+ });
195
+ }
package/src/util/log.ts CHANGED
@@ -16,9 +16,10 @@ import {
16
16
  statSync,
17
17
  renameSync,
18
18
  unlinkSync,
19
- createWriteStream,
19
+ openSync,
20
+ writeSync,
21
+ closeSync,
20
22
  } from "node:fs";
21
- import { Writable } from "node:stream";
22
23
  import { dirs, files } from "./paths.js";
23
24
 
24
25
  export type LogComponent =
@@ -81,20 +82,29 @@ if (!existsSync(dirs.root)) {
81
82
 
82
83
  // Rotate log file on startup if it exceeds 10MB
83
84
  const MAX_LOG_SIZE = 10 * 1024 * 1024;
84
- try {
85
- if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > MAX_LOG_SIZE) {
86
- const rotated = `${LOG_FILE}.old`;
85
+
86
+ /**
87
+ * Move `path` aside to `path.old` when it has outgrown the cap. Used at
88
+ * import time for talon.log and at handoff time for respawn.log, so both
89
+ * files follow the same one-generation rule. Never throws.
90
+ */
91
+ function rotateIfLarge(path: string): void {
92
+ try {
93
+ if (!existsSync(path) || statSync(path).size <= MAX_LOG_SIZE) return;
94
+ const rotated = `${path}.old`;
87
95
  try {
88
96
  unlinkSync(rotated);
89
97
  } catch {
90
- /* ignore */
98
+ /* no previous generation */
91
99
  }
92
- renameSync(LOG_FILE, rotated);
100
+ renameSync(path, rotated);
101
+ } catch {
102
+ /* a log file we cannot rotate is still a log file we can append to */
93
103
  }
94
- } catch {
95
- /* ignore */
96
104
  }
97
105
 
106
+ rotateIfLarge(LOG_FILE);
107
+
98
108
  // Suppress console output for terminal frontend (stdout belongs to the REPL)
99
109
  let quiet = process.env.TALON_QUIET === "1";
100
110
  if (!quiet) {
@@ -149,9 +159,21 @@ const SINK_RETRY_MS = 30_000;
149
159
  /** Ceiling for the doubling backoff between reopen attempts. */
150
160
  const SINK_MAX_RETRY_MS = 5 * 60_000;
151
161
 
162
+ /**
163
+ * Where a {@link ResilientFileSink} puts its lines. Writing is
164
+ * synchronous by contract: a line that `write()` returned from is on the
165
+ * fd, not in a userspace queue. See the class doc for why that matters.
166
+ */
167
+ export type SyncLogTarget = {
168
+ /** Append one already-serialized line. Throws on failure. */
169
+ write(line: string): void;
170
+ /** Release the underlying handle. Never throws. */
171
+ close(): void;
172
+ };
173
+
152
174
  export type ResilientFileSinkOptions = {
153
- /** Opens the underlying file stream. Injection seam for tests. */
154
- open?: (path: string) => Writable;
175
+ /** Opens the underlying file. Injection seam for tests. */
176
+ open?: (path: string) => SyncLogTarget;
155
177
  /** Where the pause/resume notices go. Defaults to the console sink. */
156
178
  notify?: (level: "warn" | "info", message: string) => void;
157
179
  /** First backoff step (default 30s). */
@@ -161,20 +183,34 @@ export type ResilientFileSinkOptions = {
161
183
  };
162
184
 
163
185
  /**
164
- * A log file destination that cannot take the process down.
186
+ * A log file destination that cannot take the process down, and cannot
187
+ * lose the last thing the process said.
165
188
  *
166
- * A bare `createWriteStream` handed to `pino.multistream` is a loaded
167
- * gun: when the disk fills (ENOSPC), or the file is unlinked under a
168
- * rotation (EBADF), or the fd goes bad (EIO), the stream emits `error`.
169
- * With no listener that is an uncaught exception — and it fires from
189
+ * Two failures shaped this class, both on 2026-09-18.
190
+ *
191
+ * First, a bare `createWriteStream` handed to `pino.multistream` is a
192
+ * loaded gun: when the disk fills (ENOSPC), or the file is unlinked
193
+ * under a rotation (EBADF), or the fd goes bad (EIO), the stream emits
194
+ * `error`. With no listener that is an uncaught exception — raised from
170
195
  * inside a log call, so the crash handler's own `logError` runs on a
171
- * logger that is already broken. That is how a full disk killed the
172
- * daemon on 2026-09-18: the process aborted mid-`/update` with no
173
- * shutdown, no pidfile cleanup, and no successor.
196
+ * logger that is already broken. A full disk killed the daemon that
197
+ * way: no shutdown, no pidfile cleanup, no successor.
198
+ *
199
+ * Second — and this is why the writes below are synchronous — a
200
+ * `createWriteStream` buffers in userspace and drains on later ticks,
201
+ * while every terminal log line Talon writes is immediately followed by
202
+ * `process.exit()`: "State saved", "Respawn child started", "Timeout
203
+ * exceeded, forcing exit", "Fatal startup error". `process.exit()`
204
+ * discards whatever is still queued, so precisely the lines that explain
205
+ * a failed handoff were the ones that never reached the file. Measured
206
+ * under Bun 1.3.9: three lines logged and then `process.exit(0)` produced
207
+ * an empty (not even created) log file. A sink that writes with
208
+ * `writeSync` has nothing to flush and nothing to lose — no flush step to
209
+ * remember at any exit site, which is the only version of this that stays
210
+ * fixed.
174
211
  *
175
- * This sink owns the file stream instead of exposing it. pino only ever
176
- * sees this object, which never emits `error` and never blocks:
177
- * - write failures pause file logging and destroy the broken stream,
212
+ * pino only ever sees this object, which never throws and never blocks:
213
+ * - write failures pause file logging and close the broken handle,
178
214
  * - lines written while paused are DROPPED and counted (never
179
215
  * buffered — the failure mode here is "no space", so growing a
180
216
  * buffer is the last thing to do),
@@ -184,26 +220,21 @@ export type ResilientFileSinkOptions = {
184
220
  * The console sink keeps working throughout, and carries the two
185
221
  * notices.
186
222
  */
187
- export class ResilientFileSink extends Writable {
223
+ export class ResilientFileSink {
188
224
  private readonly path: string;
189
- private readonly openStream: (path: string) => Writable;
225
+ private readonly openTarget: (path: string) => SyncLogTarget;
190
226
  private readonly notify: (level: "warn" | "info", message: string) => void;
191
227
  private readonly baseRetryMs: number;
192
228
  private readonly maxRetryMs: number;
193
- private inner: Writable | null = null;
229
+ private target: SyncLogTarget | null = null;
194
230
  private retryMs: number;
195
231
  private retryTimer: ReturnType<typeof setTimeout> | null = null;
196
232
  private droppedWhileDown = 0;
197
233
  private down = false;
198
234
 
199
235
  constructor(path: string, opts: ResilientFileSinkOptions = {}) {
200
- // decodeStrings:false keeps pino's serialized lines as strings;
201
- // autoDestroy:false means a downstream failure can never tear this
202
- // object down — it is the only thing standing between a broken file
203
- // and the process.
204
- super({ decodeStrings: false, autoDestroy: false });
205
236
  this.path = path;
206
- this.openStream = opts.open ?? openLogFile;
237
+ this.openTarget = opts.open ?? openSyncLogFile;
207
238
  this.notify = opts.notify ?? notifyViaConsoleSink;
208
239
  this.baseRetryMs = opts.retryMs ?? SINK_RETRY_MS;
209
240
  this.maxRetryMs = opts.maxRetryMs ?? SINK_MAX_RETRY_MS;
@@ -221,63 +252,41 @@ export class ResilientFileSink extends Writable {
221
252
  return this.down;
222
253
  }
223
254
 
224
- override _write(
225
- chunk: unknown,
226
- _encoding: BufferEncoding,
227
- callback: (error?: Error | null) => void,
228
- ): void {
229
- const inner = this.inner;
230
- if (inner === null) {
255
+ /** pino.multistream's entire contract: one serialized line in. */
256
+ write(line: string): void {
257
+ const target = this.target;
258
+ if (target === null) {
231
259
  this.droppedWhileDown++;
232
- } else {
233
- try {
234
- inner.write(chunk as string, (err) => {
235
- if (!err) this.markHealthy();
236
- });
237
- } catch (err) {
238
- // Synchronous throw (write-after-destroy on a stream we have not
239
- // been told about yet) — same handling as an `error` event.
240
- this.fail(err);
241
- }
260
+ return;
242
261
  }
243
- // Always report success: pino must never see this sink fail, and
244
- // backpressure here would stall whoever called log().
245
- callback();
246
- }
247
-
248
- override _final(callback: (error?: Error | null) => void): void {
249
- this.clearRetry();
250
262
  try {
251
- this.inner?.end();
252
- } catch {
253
- /* going away anyway */
263
+ target.write(line);
264
+ } catch (err) {
265
+ this.droppedWhileDown++;
266
+ this.fail(err);
267
+ return;
254
268
  }
255
- callback();
269
+ this.markHealthy();
256
270
  }
257
271
 
258
- override _destroy(
259
- _err: Error | null,
260
- callback: (error?: Error | null) => void,
261
- ): void {
272
+ /** No-op: every write already reached the fd. Part of pino's shape. */
273
+ flushSync(): void {}
274
+
275
+ /** Close the file and stop retrying. Part of pino's shape. */
276
+ end(): void {
262
277
  this.clearRetry();
263
278
  this.detachInner();
264
- callback(null);
265
279
  }
266
280
 
267
281
  private openInner(): void {
268
282
  try {
269
- const inner = this.openStream(this.path);
270
- inner.on("error", (err: Error) => {
271
- // Ignore errors from a stream we have already given up on.
272
- if (this.inner === inner) this.fail(err);
273
- });
274
- this.inner = inner;
283
+ this.target = this.openTarget(this.path);
275
284
  } catch (err) {
276
285
  this.fail(err);
277
286
  }
278
287
  }
279
288
 
280
- /** Give up on the current stream and arm a reopen. Never throws. */
289
+ /** Give up on the current handle and arm a reopen. Never throws. */
281
290
  private fail(err: unknown): void {
282
291
  const firstFailure = !this.down;
283
292
  this.detachInner();
@@ -311,15 +320,11 @@ export class ResilientFileSink extends Writable {
311
320
  }
312
321
 
313
322
  private detachInner(): void {
314
- const inner = this.inner;
315
- this.inner = null;
316
- if (inner === null) return;
323
+ const target = this.target;
324
+ this.target = null;
325
+ if (target === null) return;
317
326
  try {
318
- inner.removeAllListeners("error");
319
- // destroy() can surface one last error — swallow it here rather
320
- // than let it reach process-level uncaughtException.
321
- inner.on("error", () => {});
322
- inner.destroy();
327
+ target.close();
323
328
  } catch {
324
329
  /* best effort */
325
330
  }
@@ -358,9 +363,51 @@ export class ResilientFileSink extends Writable {
358
363
  }
359
364
  }
360
365
 
361
- function openLogFile(path: string): Writable {
366
+ /** Append `line` to `fd`, looping over a short write. Throws on failure. */
367
+ function appendLine(fd: number, line: string): void {
368
+ const buf = Buffer.from(line, "utf-8");
369
+ let offset = 0;
370
+ while (offset < buf.length) {
371
+ offset += writeSync(fd, buf, offset, buf.length - offset);
372
+ }
373
+ }
374
+
375
+ /** The production {@link SyncLogTarget}: an appended fd, written with writeSync. */
376
+ export function openSyncLogFile(path: string): SyncLogTarget {
362
377
  // 0600: turns and tool output land here — same sensitivity as history.
363
- return createWriteStream(path, { flags: "a", mode: 0o600 });
378
+ const fd = openSync(path, "a", 0o600);
379
+ return {
380
+ write: (line) => appendLine(fd, line),
381
+ close: () => {
382
+ try {
383
+ closeSync(fd);
384
+ } catch {
385
+ /* already gone */
386
+ }
387
+ },
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Open ~/.talon/respawn.log for a successor's stdout+stderr.
393
+ *
394
+ * A `/restart` or `/update` handoff spawns the next daemon detached; with
395
+ * `stdio: "ignore"` a successor that dies before its logger exists — a
396
+ * broken import after a dependency install, a fatal bind, a runtime that
397
+ * aborts — leaves no trace anywhere, which is exactly how the 2026-09-18
398
+ * handoff vanished. Handing the child this fd makes that visible. Same
399
+ * one-generation rotation as talon.log.
400
+ *
401
+ * Returns null when the file cannot be opened; the caller falls back to
402
+ * nothing, never to a failed handoff.
403
+ */
404
+ export function openRespawnLog(path: string = files.respawnLog): number | null {
405
+ try {
406
+ rotateIfLarge(path);
407
+ return openSync(path, "a", 0o600);
408
+ } catch {
409
+ return null;
410
+ }
364
411
  }
365
412
 
366
413
  function notifyViaConsoleSink(level: "warn" | "info", message: string): void {
package/src/util/paths.ts CHANGED
@@ -102,6 +102,11 @@ export const files = {
102
102
  config: resolve(TALON_ROOT, "config.json"),
103
103
  /** Structured log: ~/.talon/talon.log */
104
104
  log: resolve(TALON_ROOT, "talon.log"),
105
+ /**
106
+ * Successor stdout+stderr during a `/restart` or `/update` handoff:
107
+ * ~/.talon/respawn.log. See core/daemon/respawn.ts.
108
+ */
109
+ respawnLog: resolve(TALON_ROOT, "respawn.log"),
105
110
  /** Legacy JSON session store (imported into talon.db on first boot) */
106
111
  sessions: resolve(TALON_ROOT, "data", "sessions.json"),
107
112
  /** SQLite database (history, sessions, chat settings, media index): ~/.talon/data/talon.db */