talon-agent 5.2.1 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +2 -2
  2. package/src/app.ts +94 -1
  3. package/src/backend/codex/mcp-config.ts +1 -1
  4. package/src/backend/openai-agents/mcp-pool.ts +1 -1
  5. package/src/backend/runtime/index.ts +1 -1
  6. package/src/cli/commands/backup.ts +396 -0
  7. package/src/cli/events.ts +14 -0
  8. package/src/cli/index.ts +64 -45
  9. package/src/core/backup/archive/digest.ts +77 -0
  10. package/src/core/backup/archive/tar.ts +567 -0
  11. package/src/core/backup/archive/zstd.ts +31 -0
  12. package/src/core/backup/index.ts +54 -0
  13. package/src/core/backup/plan.ts +273 -0
  14. package/src/core/backup/restore.ts +410 -0
  15. package/src/core/backup/scheduler.ts +357 -0
  16. package/src/core/backup/snapshot.ts +408 -0
  17. package/src/core/backup/status.ts +194 -0
  18. package/src/core/backup/store.ts +312 -0
  19. package/src/core/backup/targets.ts +281 -0
  20. package/src/core/backup/types.ts +96 -0
  21. package/src/core/backup/upload.ts +172 -0
  22. package/src/core/bus/events.ts +45 -1
  23. package/src/core/config/index.ts +52 -0
  24. package/src/core/daemon/handoff.ts +192 -0
  25. package/src/core/daemon/respawn.ts +127 -52
  26. package/src/core/engine/gateway-actions/backup/index.ts +129 -0
  27. package/src/core/engine/gateway-actions/index.ts +4 -0
  28. package/src/core/mcp-hub/talon-server.ts +1 -1
  29. package/src/core/plugin/actions.ts +34 -0
  30. package/src/core/plugin/index.ts +5 -1
  31. package/src/core/tools/{ops/bridge.ts → bridge.ts} +7 -2
  32. package/src/core/tools/index.ts +2 -0
  33. package/src/core/tools/ops/backup.ts +67 -0
  34. package/src/core/tools/types.ts +2 -1
  35. package/src/core/update/self-update.ts +47 -0
  36. package/src/frontend/discord/callbacks/components/index.ts +3 -0
  37. package/src/frontend/discord/commands/backup.ts +203 -0
  38. package/src/frontend/discord/commands/definitions.ts +35 -0
  39. package/src/frontend/discord/commands/router.ts +3 -0
  40. package/src/frontend/telegram/callbacks/backup.ts +55 -0
  41. package/src/frontend/telegram/callbacks/index.ts +8 -0
  42. package/src/frontend/telegram/commands/backup.ts +209 -0
  43. package/src/frontend/telegram/commands/definitions.ts +4 -0
  44. package/src/frontend/telegram/commands/index.ts +2 -0
  45. package/src/index.ts +13 -5
  46. package/src/plugins/playwright/index.ts +39 -3
  47. package/src/plugins/playwright/provision.ts +21 -0
  48. package/src/plugins/playwright/version-coupling.ts +195 -0
  49. package/src/storage/backup/index.ts +82 -0
  50. package/src/storage/backup/repo.ts +164 -0
  51. package/src/storage/db.ts +20 -0
  52. package/src/storage/sql/backups.sql +46 -0
  53. package/src/storage/sql/db.sql +8 -0
  54. package/src/storage/sql/schema.sql +30 -0
  55. package/src/storage/sql/statements.generated.ts +60 -1
  56. package/src/util/log.ts +129 -81
  57. package/src/util/paths.ts +5 -0
  58. /package/src/core/tools/{ops/mcp-env.ts → mcp-env.ts} +0 -0
@@ -366,7 +366,60 @@ CREATE TABLE IF NOT EXISTS whatsapp_messages (
366
366
  );
367
367
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_msg ON whatsapp_messages(msg_id);
368
368
  CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_wa_id ON whatsapp_messages(wa_id);
369
- CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_time ON whatsapp_messages(timestamp);`;
369
+ CREATE INDEX IF NOT EXISTS idx_whatsapp_messages_time ON whatsapp_messages(timestamp);
370
+
371
+ -- Snapshot index: the listing/status view over ~/.talon/backups/. The
372
+ -- manifest.json next to the parts on disk stays the source of truth for
373
+ -- a restore (a database that needs restoring cannot also be the record
374
+ -- of how), so these rows are a cache — dropped rows are re-derived from
375
+ -- the directories on the next boot, and a row whose directory is gone is
376
+ -- kept because the snapshot may still exist on a remote target.
377
+ CREATE TABLE IF NOT EXISTS backups (
378
+ id TEXT PRIMARY KEY,
379
+ kind TEXT NOT NULL,
380
+ label TEXT,
381
+ pinned INTEGER NOT NULL DEFAULT 0,
382
+ created_at INTEGER NOT NULL,
383
+ size_bytes INTEGER NOT NULL DEFAULT 0,
384
+ manifest_json TEXT NOT NULL
385
+ );
386
+ CREATE INDEX IF NOT EXISTS idx_backups_created ON backups(created_at DESC);
387
+
388
+ -- Per-target upload state for one snapshot. Separate from the manifest
389
+ -- copy so retention can ask "what is on Drive?" without opening a file
390
+ -- per snapshot, and so a failed upload's error survives a restart.
391
+ CREATE TABLE IF NOT EXISTS backup_remotes (
392
+ backup_id TEXT NOT NULL,
393
+ target_id TEXT NOT NULL,
394
+ status TEXT NOT NULL,
395
+ remote_id TEXT,
396
+ uploaded_at INTEGER,
397
+ error TEXT,
398
+ PRIMARY KEY (backup_id, target_id)
399
+ );`;
400
+
401
+ export const backupsSql = {
402
+ upsert: `INSERT OR REPLACE INTO backups
403
+ (id, kind, label, pinned, created_at, size_bytes, manifest_json)
404
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
405
+ get: `SELECT id, kind, label, pinned, created_at, size_bytes, manifest_json
406
+ FROM backups WHERE id = ?`,
407
+ all: `SELECT id, kind, label, pinned, created_at, size_bytes, manifest_json
408
+ FROM backups ORDER BY created_at DESC`,
409
+ ids: `SELECT id FROM backups`,
410
+ setPinned: `UPDATE backups SET pinned = ? WHERE id = ?`,
411
+ setManifest: `UPDATE backups SET manifest_json = ?, pinned = ?, size_bytes = ? WHERE id = ?`,
412
+ remove: `DELETE FROM backups WHERE id = ?`,
413
+ upsertRemote: `INSERT OR REPLACE INTO backup_remotes
414
+ (backup_id, target_id, status, remote_id, uploaded_at, error)
415
+ VALUES (?, ?, ?, ?, ?, ?)`,
416
+ remotesAll: `SELECT backup_id, target_id, status, remote_id, uploaded_at, error
417
+ FROM backup_remotes`,
418
+ remotesFor: `SELECT backup_id, target_id, status, remote_id, uploaded_at, error
419
+ FROM backup_remotes WHERE backup_id = ?`,
420
+ removeRemotes: `DELETE FROM backup_remotes WHERE backup_id = ?`,
421
+ removeRemote: `DELETE FROM backup_remotes WHERE backup_id = ? AND target_id = ?`,
422
+ } as const;
370
423
 
371
424
  export const chatSettingsSql = {
372
425
  upsert: `INSERT OR REPLACE INTO chat_settings (chat_id, settings) VALUES (?, ?)`,
@@ -421,6 +474,12 @@ ALTER TABLE history_messages ADD COLUMN attachments TEXT`,
421
474
  addSessionsLastTurnEndedAtColumn: `-- Column reconciliation for databases that shipped before the cache-age
422
475
  -- signal existed. Fresh databases get the column via schema.sql.
423
476
  ALTER TABLE sessions ADD COLUMN last_turn_ended_at INTEGER`,
477
+ vacuumInto: `-- Transactionally consistent copy of the whole database into a new file,
478
+ -- produced by SQLite itself (storage/db.ts snapshotDatabase). A backup
479
+ -- must never copy a live .db byte-wise: the WAL holds committed pages the
480
+ -- main file does not, so the copy would be a corrupt database or an old
481
+ -- one. Verified to accept a bound path on node:sqlite and bun:sqlite.
482
+ VACUUM INTO ?`,
424
483
  } as const;
425
484
 
426
485
  export const goalsSql = {
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 =
@@ -66,6 +67,7 @@ export type LogComponent =
66
67
  | "playwright"
67
68
  | "memory"
68
69
  | "stickers"
70
+ | "backup"
69
71
  | "backend-controller";
70
72
 
71
73
  const LOG_FILE = files.log;
@@ -81,20 +83,29 @@ if (!existsSync(dirs.root)) {
81
83
 
82
84
  // Rotate log file on startup if it exceeds 10MB
83
85
  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`;
86
+
87
+ /**
88
+ * Move `path` aside to `path.old` when it has outgrown the cap. Used at
89
+ * import time for talon.log and at handoff time for respawn.log, so both
90
+ * files follow the same one-generation rule. Never throws.
91
+ */
92
+ function rotateIfLarge(path: string): void {
93
+ try {
94
+ if (!existsSync(path) || statSync(path).size <= MAX_LOG_SIZE) return;
95
+ const rotated = `${path}.old`;
87
96
  try {
88
97
  unlinkSync(rotated);
89
98
  } catch {
90
- /* ignore */
99
+ /* no previous generation */
91
100
  }
92
- renameSync(LOG_FILE, rotated);
101
+ renameSync(path, rotated);
102
+ } catch {
103
+ /* a log file we cannot rotate is still a log file we can append to */
93
104
  }
94
- } catch {
95
- /* ignore */
96
105
  }
97
106
 
107
+ rotateIfLarge(LOG_FILE);
108
+
98
109
  // Suppress console output for terminal frontend (stdout belongs to the REPL)
99
110
  let quiet = process.env.TALON_QUIET === "1";
100
111
  if (!quiet) {
@@ -149,9 +160,21 @@ const SINK_RETRY_MS = 30_000;
149
160
  /** Ceiling for the doubling backoff between reopen attempts. */
150
161
  const SINK_MAX_RETRY_MS = 5 * 60_000;
151
162
 
163
+ /**
164
+ * Where a {@link ResilientFileSink} puts its lines. Writing is
165
+ * synchronous by contract: a line that `write()` returned from is on the
166
+ * fd, not in a userspace queue. See the class doc for why that matters.
167
+ */
168
+ export type SyncLogTarget = {
169
+ /** Append one already-serialized line. Throws on failure. */
170
+ write(line: string): void;
171
+ /** Release the underlying handle. Never throws. */
172
+ close(): void;
173
+ };
174
+
152
175
  export type ResilientFileSinkOptions = {
153
- /** Opens the underlying file stream. Injection seam for tests. */
154
- open?: (path: string) => Writable;
176
+ /** Opens the underlying file. Injection seam for tests. */
177
+ open?: (path: string) => SyncLogTarget;
155
178
  /** Where the pause/resume notices go. Defaults to the console sink. */
156
179
  notify?: (level: "warn" | "info", message: string) => void;
157
180
  /** First backoff step (default 30s). */
@@ -161,20 +184,34 @@ export type ResilientFileSinkOptions = {
161
184
  };
162
185
 
163
186
  /**
164
- * A log file destination that cannot take the process down.
187
+ * A log file destination that cannot take the process down, and cannot
188
+ * lose the last thing the process said.
165
189
  *
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
190
+ * Two failures shaped this class, both on 2026-09-18.
191
+ *
192
+ * First, a bare `createWriteStream` handed to `pino.multistream` is a
193
+ * loaded gun: when the disk fills (ENOSPC), or the file is unlinked
194
+ * under a rotation (EBADF), or the fd goes bad (EIO), the stream emits
195
+ * `error`. With no listener that is an uncaught exception — raised from
170
196
  * 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.
197
+ * logger that is already broken. A full disk killed the daemon that
198
+ * way: no shutdown, no pidfile cleanup, no successor.
199
+ *
200
+ * Second — and this is why the writes below are synchronous — a
201
+ * `createWriteStream` buffers in userspace and drains on later ticks,
202
+ * while every terminal log line Talon writes is immediately followed by
203
+ * `process.exit()`: "State saved", "Respawn child started", "Timeout
204
+ * exceeded, forcing exit", "Fatal startup error". `process.exit()`
205
+ * discards whatever is still queued, so precisely the lines that explain
206
+ * a failed handoff were the ones that never reached the file. Measured
207
+ * under Bun 1.3.9: three lines logged and then `process.exit(0)` produced
208
+ * an empty (not even created) log file. A sink that writes with
209
+ * `writeSync` has nothing to flush and nothing to lose — no flush step to
210
+ * remember at any exit site, which is the only version of this that stays
211
+ * fixed.
174
212
  *
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,
213
+ * pino only ever sees this object, which never throws and never blocks:
214
+ * - write failures pause file logging and close the broken handle,
178
215
  * - lines written while paused are DROPPED and counted (never
179
216
  * buffered — the failure mode here is "no space", so growing a
180
217
  * buffer is the last thing to do),
@@ -184,26 +221,21 @@ export type ResilientFileSinkOptions = {
184
221
  * The console sink keeps working throughout, and carries the two
185
222
  * notices.
186
223
  */
187
- export class ResilientFileSink extends Writable {
224
+ export class ResilientFileSink {
188
225
  private readonly path: string;
189
- private readonly openStream: (path: string) => Writable;
226
+ private readonly openTarget: (path: string) => SyncLogTarget;
190
227
  private readonly notify: (level: "warn" | "info", message: string) => void;
191
228
  private readonly baseRetryMs: number;
192
229
  private readonly maxRetryMs: number;
193
- private inner: Writable | null = null;
230
+ private target: SyncLogTarget | null = null;
194
231
  private retryMs: number;
195
232
  private retryTimer: ReturnType<typeof setTimeout> | null = null;
196
233
  private droppedWhileDown = 0;
197
234
  private down = false;
198
235
 
199
236
  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
237
  this.path = path;
206
- this.openStream = opts.open ?? openLogFile;
238
+ this.openTarget = opts.open ?? openSyncLogFile;
207
239
  this.notify = opts.notify ?? notifyViaConsoleSink;
208
240
  this.baseRetryMs = opts.retryMs ?? SINK_RETRY_MS;
209
241
  this.maxRetryMs = opts.maxRetryMs ?? SINK_MAX_RETRY_MS;
@@ -221,63 +253,41 @@ export class ResilientFileSink extends Writable {
221
253
  return this.down;
222
254
  }
223
255
 
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) {
256
+ /** pino.multistream's entire contract: one serialized line in. */
257
+ write(line: string): void {
258
+ const target = this.target;
259
+ if (target === null) {
231
260
  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
- }
261
+ return;
242
262
  }
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
263
  try {
251
- this.inner?.end();
252
- } catch {
253
- /* going away anyway */
264
+ target.write(line);
265
+ } catch (err) {
266
+ this.droppedWhileDown++;
267
+ this.fail(err);
268
+ return;
254
269
  }
255
- callback();
270
+ this.markHealthy();
256
271
  }
257
272
 
258
- override _destroy(
259
- _err: Error | null,
260
- callback: (error?: Error | null) => void,
261
- ): void {
273
+ /** No-op: every write already reached the fd. Part of pino's shape. */
274
+ flushSync(): void {}
275
+
276
+ /** Close the file and stop retrying. Part of pino's shape. */
277
+ end(): void {
262
278
  this.clearRetry();
263
279
  this.detachInner();
264
- callback(null);
265
280
  }
266
281
 
267
282
  private openInner(): void {
268
283
  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;
284
+ this.target = this.openTarget(this.path);
275
285
  } catch (err) {
276
286
  this.fail(err);
277
287
  }
278
288
  }
279
289
 
280
- /** Give up on the current stream and arm a reopen. Never throws. */
290
+ /** Give up on the current handle and arm a reopen. Never throws. */
281
291
  private fail(err: unknown): void {
282
292
  const firstFailure = !this.down;
283
293
  this.detachInner();
@@ -311,15 +321,11 @@ export class ResilientFileSink extends Writable {
311
321
  }
312
322
 
313
323
  private detachInner(): void {
314
- const inner = this.inner;
315
- this.inner = null;
316
- if (inner === null) return;
324
+ const target = this.target;
325
+ this.target = null;
326
+ if (target === null) return;
317
327
  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();
328
+ target.close();
323
329
  } catch {
324
330
  /* best effort */
325
331
  }
@@ -358,9 +364,51 @@ export class ResilientFileSink extends Writable {
358
364
  }
359
365
  }
360
366
 
361
- function openLogFile(path: string): Writable {
367
+ /** Append `line` to `fd`, looping over a short write. Throws on failure. */
368
+ function appendLine(fd: number, line: string): void {
369
+ const buf = Buffer.from(line, "utf-8");
370
+ let offset = 0;
371
+ while (offset < buf.length) {
372
+ offset += writeSync(fd, buf, offset, buf.length - offset);
373
+ }
374
+ }
375
+
376
+ /** The production {@link SyncLogTarget}: an appended fd, written with writeSync. */
377
+ export function openSyncLogFile(path: string): SyncLogTarget {
362
378
  // 0600: turns and tool output land here — same sensitivity as history.
363
- return createWriteStream(path, { flags: "a", mode: 0o600 });
379
+ const fd = openSync(path, "a", 0o600);
380
+ return {
381
+ write: (line) => appendLine(fd, line),
382
+ close: () => {
383
+ try {
384
+ closeSync(fd);
385
+ } catch {
386
+ /* already gone */
387
+ }
388
+ },
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Open ~/.talon/respawn.log for a successor's stdout+stderr.
394
+ *
395
+ * A `/restart` or `/update` handoff spawns the next daemon detached; with
396
+ * `stdio: "ignore"` a successor that dies before its logger exists — a
397
+ * broken import after a dependency install, a fatal bind, a runtime that
398
+ * aborts — leaves no trace anywhere, which is exactly how the 2026-09-18
399
+ * handoff vanished. Handing the child this fd makes that visible. Same
400
+ * one-generation rotation as talon.log.
401
+ *
402
+ * Returns null when the file cannot be opened; the caller falls back to
403
+ * nothing, never to a failed handoff.
404
+ */
405
+ export function openRespawnLog(path: string = files.respawnLog): number | null {
406
+ try {
407
+ rotateIfLarge(path);
408
+ return openSync(path, "a", 0o600);
409
+ } catch {
410
+ return null;
411
+ }
364
412
  }
365
413
 
366
414
  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 */
File without changes