talon-agent 5.18.1 → 5.19.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 (194) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +8 -3
  5. package/package.json +4 -2
  6. package/prompts/system/agent-brief.md +20 -3
  7. package/src/app.ts +13 -0
  8. package/src/backend/claude-sdk/handler.ts +4 -4
  9. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  10. package/src/backend/claude-sdk/stream.ts +2 -2
  11. package/src/backend/codex/auth.ts +1 -1
  12. package/src/backend/codex/handler/message.ts +11 -11
  13. package/src/backend/codex/init.ts +4 -9
  14. package/src/backend/codex/mcp-config.ts +1 -2
  15. package/src/backend/codex/oauth-incompat.ts +8 -4
  16. package/src/backend/codex/one-shot.ts +1 -1
  17. package/src/backend/openai-agents/builtins.ts +55 -27
  18. package/src/backend/openai-agents/factory.ts +3 -3
  19. package/src/backend/openai-agents/handler/message.ts +3 -5
  20. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  21. package/src/backend/remote-server/chat-turn.ts +6 -6
  22. package/src/backend/remote-server/events.ts +1 -5
  23. package/src/backend/remote-server/index.ts +0 -1
  24. package/src/backend/remote-server/messages.ts +3 -7
  25. package/src/backend/remote-server/one-shot.ts +1 -3
  26. package/src/backend/remote-server/session-helpers.ts +1 -4
  27. package/src/backend/remote-server/sse-stream.ts +8 -9
  28. package/src/backend/runtime/metrics.ts +7 -13
  29. package/src/backend/runtime/sleep.ts +1 -2
  30. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  31. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  32. package/src/bootstrap.ts +9 -1
  33. package/src/cli/doctor.ts +3 -0
  34. package/src/cli/index.ts +8 -10
  35. package/src/cli/logs.ts +148 -9
  36. package/src/cli/setup.ts +9 -11
  37. package/src/cli/status.ts +29 -0
  38. package/src/core/agent-runtime/README.md +5 -19
  39. package/src/core/agent-runtime/events.ts +3 -39
  40. package/src/core/agent-runtime/model-ref.ts +0 -8
  41. package/src/core/agents/registry.ts +49 -2
  42. package/src/core/auth/expiry-monitor.ts +9 -1
  43. package/src/core/auth/login-flow.ts +9 -1
  44. package/src/core/auth/status.ts +31 -3
  45. package/src/core/background/cron/scheduler.ts +25 -5
  46. package/src/core/background/dream/index.ts +29 -10
  47. package/src/core/background/failure-backoff.ts +30 -0
  48. package/src/core/background/heartbeat/agent.ts +2 -27
  49. package/src/core/background/heartbeat/index.ts +0 -2
  50. package/src/core/background/heartbeat/scheduler.ts +21 -13
  51. package/src/core/background/heartbeat/state.ts +10 -2
  52. package/src/core/background/isolated-agent.ts +6 -2
  53. package/src/core/background/pulse/pulse.ts +9 -0
  54. package/src/core/background/triggers/exit.ts +54 -0
  55. package/src/core/background/triggers/index.ts +1 -3
  56. package/src/core/background/triggers/resume.ts +2 -4
  57. package/src/core/backup/archive/tar.ts +14 -4
  58. package/src/core/backup/restore.ts +21 -13
  59. package/src/core/backup/scheduler.ts +28 -9
  60. package/src/core/backup/snapshot.ts +43 -10
  61. package/src/core/backup/store.ts +7 -19
  62. package/src/core/backup/targets.ts +69 -17
  63. package/src/core/config/index.ts +14 -0
  64. package/src/core/daemon/crash-marker.ts +141 -0
  65. package/src/core/daemon/crash.ts +9 -2
  66. package/src/core/daemon/handoff.ts +15 -0
  67. package/src/core/daemon/health-alerts.ts +297 -0
  68. package/src/core/daemon/log-reader.ts +289 -0
  69. package/src/core/doctor/index.ts +18 -2
  70. package/src/core/doctor/logs.ts +124 -0
  71. package/src/core/doctor/types.ts +1 -1
  72. package/src/core/engine/backend-controller/index.ts +1 -13
  73. package/src/core/engine/backend-router/router.ts +1 -1
  74. package/src/core/engine/dispatcher.ts +55 -2
  75. package/src/core/engine/fault-text.ts +40 -0
  76. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  77. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  78. package/src/core/engine/gateway-actions/history.ts +2 -4
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway.ts +60 -1
  81. package/src/core/engine/turn-health.ts +222 -0
  82. package/src/core/errors.ts +2 -2
  83. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  84. package/src/core/frontend-runtime/alerts.ts +130 -0
  85. package/src/core/mcp-hub/children.ts +78 -29
  86. package/src/core/mcp-hub/index.ts +21 -18
  87. package/src/core/mcp-hub/proxy-server.ts +8 -4
  88. package/src/core/mcp-hub/talon-server.ts +5 -12
  89. package/src/core/mesh/credentials/store.ts +16 -1
  90. package/src/core/mesh/devices/service.ts +26 -15
  91. package/src/core/mesh/devices/teleport.ts +14 -2
  92. package/src/core/mesh/links/node-binaries.ts +13 -6
  93. package/src/core/mesh/persist.ts +22 -10
  94. package/src/core/mesh/transfers/device-files.ts +5 -23
  95. package/src/core/mesh/transfers/transfers.ts +16 -3
  96. package/src/core/models/active-model.ts +2 -55
  97. package/src/core/plugin/actions.ts +19 -20
  98. package/src/core/plugin/builtins.ts +80 -90
  99. package/src/core/plugin/index.ts +1 -4
  100. package/src/core/plugin/loader.ts +25 -33
  101. package/src/core/plugin/mcp.ts +3 -5
  102. package/src/core/plugin/registry.ts +19 -35
  103. package/src/core/plugin/types.ts +2 -5
  104. package/src/core/prompt/assemble.ts +15 -3
  105. package/src/core/scripts/lua.ts +6 -2
  106. package/src/core/tasks/table.ts +8 -2
  107. package/src/core/tools/bridge.ts +2 -4
  108. package/src/core/tools/chat/cross-send.ts +1 -1
  109. package/src/core/tools/chat/messaging.ts +1 -1
  110. package/src/core/tools/index.ts +2 -2
  111. package/src/core/tools/mcp-env.ts +2 -59
  112. package/src/core/tools/ops/agents.ts +22 -1
  113. package/src/core/tools/schemas.ts +4 -9
  114. package/src/core/vfs/fusefs.ts +0 -5
  115. package/src/core/vfs/index.ts +9 -2
  116. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  117. package/src/core/vfs/mounts/proc.ts +17 -1
  118. package/src/core/vfs/workspace.ts +7 -3
  119. package/src/core/weaver/shuttle.ts +8 -1
  120. package/src/core/weaver/turn-log.ts +320 -0
  121. package/src/core/weaver/weaver.ts +48 -5
  122. package/src/frontend/discord/actions/index.ts +8 -1
  123. package/src/frontend/discord/diagnostics.ts +82 -6
  124. package/src/frontend/discord/handlers/index.ts +0 -2
  125. package/src/frontend/discord/middleware.ts +7 -13
  126. package/src/frontend/discord/runtime.ts +1 -3
  127. package/src/frontend/health/delivery.ts +115 -0
  128. package/src/frontend/health/outage.ts +116 -0
  129. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  130. package/src/frontend/native/bridge/server.ts +106 -21
  131. package/src/frontend/native/index.ts +1 -1
  132. package/src/frontend/native/media/media.ts +5 -1
  133. package/src/frontend/native/runtime.ts +12 -7
  134. package/src/frontend/native/surface/handlers.ts +1 -1
  135. package/src/frontend/native/surface/memory.ts +1 -1
  136. package/src/frontend/native/surface/models.ts +3 -3
  137. package/src/frontend/native/surface/settings.ts +20 -8
  138. package/src/frontend/native/turn/context.ts +6 -8
  139. package/src/frontend/native/turn/turn-meta.ts +2 -5
  140. package/src/frontend/native/turn/turn.ts +8 -10
  141. package/src/frontend/presentation/format.ts +2 -4
  142. package/src/frontend/presentation/session-status.ts +2 -6
  143. package/src/frontend/teams/actions.ts +8 -1
  144. package/src/frontend/teams/graph.ts +0 -1
  145. package/src/frontend/teams/index.ts +1 -4
  146. package/src/frontend/teams/poll.ts +40 -2
  147. package/src/frontend/teams/runtime.ts +14 -5
  148. package/src/frontend/telegram/actions/index.ts +4 -1
  149. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  150. package/src/frontend/telegram/actions/send.ts +12 -0
  151. package/src/frontend/telegram/handlers/context.ts +13 -2
  152. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  153. package/src/frontend/telegram/handlers/index.ts +0 -2
  154. package/src/frontend/telegram/index.ts +35 -9
  155. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  156. package/src/frontend/telegram/userbot.ts +147 -41
  157. package/src/frontend/terminal/builtins/session.ts +2 -2
  158. package/src/frontend/terminal/index.ts +1 -3
  159. package/src/frontend/terminal/renderer.ts +2 -18
  160. package/src/frontend/whatsapp/actions/index.ts +12 -1
  161. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  162. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  163. package/src/frontend/whatsapp/connection/health.ts +89 -0
  164. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  165. package/src/frontend/whatsapp/runtime.ts +11 -5
  166. package/src/native/blake3.ts +28 -2
  167. package/src/native/fusefs.ts +23 -5
  168. package/src/native/registry.ts +1 -1
  169. package/src/native/warden.ts +33 -5
  170. package/src/plugins/github/index.ts +0 -1
  171. package/src/plugins/mempalace/index.ts +9 -3
  172. package/src/plugins/playwright/index.ts +2 -4
  173. package/src/plugins/playwright/provision.ts +12 -4
  174. package/src/storage/chat-settings.ts +6 -1
  175. package/src/storage/cron.ts +29 -4
  176. package/src/storage/daily-log.ts +43 -47
  177. package/src/storage/db.ts +61 -33
  178. package/src/storage/history.ts +6 -1
  179. package/src/storage/journal.ts +9 -2
  180. package/src/storage/kv.ts +19 -6
  181. package/src/storage/media-index.ts +28 -6
  182. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  183. package/src/storage/repositories/sessions-repo.ts +10 -3
  184. package/src/storage/scripts.ts +24 -13
  185. package/src/storage/sessions.ts +11 -2
  186. package/src/storage/skills.ts +21 -2
  187. package/src/storage/stickers.ts +17 -3
  188. package/src/storage/triggers.ts +8 -3
  189. package/src/storage/turn-meta.ts +25 -7
  190. package/src/util/log.ts +189 -6
  191. package/src/util/logging/turn-scope.ts +85 -0
  192. package/src/util/time.ts +3 -3
  193. package/src/util/watchdog.ts +30 -0
  194. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -72,6 +72,7 @@ import {
72
72
  type SourceContext,
73
73
  } from "./sources/sessions.js";
74
74
  import {
75
+ DB_MEMBER,
75
76
  STATE_PART,
76
77
  indexSnapshot,
77
78
  linkOrCopy,
@@ -88,8 +89,6 @@ import type {
88
89
  SnapshotPart,
89
90
  } from "./types.js";
90
91
 
91
- /** Where the database copy lands inside the archive. */
92
- const DB_MEMBER = "db/talon.db";
93
92
  /** The part that holds WhatsApp auth and the userbot session. */
94
93
  const LOGINS_PART = "logins.tar.zst";
95
94
  /** How a clone reinstalls fetched plugins (see sources/plugins.ts). */
@@ -120,10 +119,18 @@ export type BuildOptions = {
120
119
 
121
120
  // ── Archive writing ─────────────────────────────────────────────────────────
122
121
 
122
+ /**
123
+ * Open failures that mean "this file is gone or locked since the walk":
124
+ * traces and backend transcripts churn while a snapshot runs, and one of
125
+ * them disappearing must not cost the whole backup (see collectTree).
126
+ */
127
+ const SKIPPABLE_OPEN_ERRORS = new Set(["ENOENT", "EACCES", "EPERM"]);
128
+
123
129
  async function addEntries(
124
130
  writer: TarWriter,
125
131
  entries: readonly SourceEntry[],
126
132
  ): Promise<void> {
133
+ const skipped: string[] = [];
127
134
  for (const entry of entries) {
128
135
  if (entry.type === "dir") {
129
136
  await writer.addDirectory(entry.archivePath, entry.mode, entry.mtime);
@@ -135,15 +142,27 @@ async function addEntries(
135
142
  entry.mtime,
136
143
  );
137
144
  } else {
138
- await writer.addFile(
139
- entry.archivePath,
140
- entry.source,
141
- entry.mode,
142
- entry.mtime,
143
- entry.size,
144
- );
145
+ try {
146
+ await writer.addFile(
147
+ entry.archivePath,
148
+ entry.source,
149
+ entry.mode,
150
+ entry.mtime,
151
+ entry.size,
152
+ );
153
+ } catch (err) {
154
+ const code = (err as NodeJS.ErrnoException).code ?? "";
155
+ if (!SKIPPABLE_OPEN_ERRORS.has(code)) throw err;
156
+ skipped.push(`${entry.source} (${code})`);
157
+ }
145
158
  }
146
159
  }
160
+ if (skipped.length > 0) {
161
+ logWarn(
162
+ "backup",
163
+ `Skipped ${skipped.length} file(s) that vanished or became unreadable mid-snapshot; first: ${skipped[0]}`,
164
+ );
165
+ }
147
166
  }
148
167
 
149
168
  /** A part's file name: `.enc` marks one written through the encryptor. */
@@ -171,6 +190,11 @@ async function writePart(
171
190
  const flushed = passphrase
172
191
  ? pipeline(compressor, await createEncryptor(passphrase), tap, out)
173
192
  : pipeline(compressor, tap, out);
193
+ // A sink failure (ENOSPC) rejects this while `fill` is still writing, and
194
+ // `fill` then throws the same error — so the await below is never
195
+ // reached. Observe it here, or it surfaces as an unhandled rejection
196
+ // that kills the CLI before the cleanup runs.
197
+ flushed.catch(() => {});
174
198
  try {
175
199
  const writer = new TarWriter(compressor);
176
200
  await fill(writer);
@@ -312,11 +336,20 @@ async function palaceFingerprint(
312
336
  const files: TreeFile[] = [];
313
337
  for (const entry of entries) {
314
338
  if (entry.type !== "file") continue;
339
+ let sha256: string;
340
+ try {
341
+ sha256 = await sha256File(entry.source);
342
+ } catch (err) {
343
+ // Gone since the walk: the part will skip it too (see addEntries).
344
+ const code = (err as NodeJS.ErrnoException).code ?? "";
345
+ if (SKIPPABLE_OPEN_ERRORS.has(code)) continue;
346
+ throw err;
347
+ }
315
348
  files.push({
316
349
  path: entry.archivePath,
317
350
  size: entry.size,
318
351
  mtime: entry.mtime,
319
- sha256: await sha256File(entry.source),
352
+ sha256,
320
353
  });
321
354
  }
322
355
  return treeHash(files);
@@ -17,15 +17,7 @@
17
17
  */
18
18
 
19
19
  import { randomBytes } from "node:crypto";
20
- import {
21
- link,
22
- copyFile,
23
- mkdir,
24
- readdir,
25
- readFile,
26
- rm,
27
- stat,
28
- } from "node:fs/promises";
20
+ import { link, copyFile, mkdir, readdir, readFile, rm } from "node:fs/promises";
29
21
  import { join, resolve } from "node:path";
30
22
  import writeFileAtomic from "write-file-atomic";
31
23
  import { dirs } from "../../util/paths.js";
@@ -40,6 +32,7 @@ import {
40
32
  recordBackup,
41
33
  updateBackupManifest,
42
34
  } from "../../storage/backup/index.js";
35
+ import { pathExists } from "./sources/sessions.js";
43
36
  import type {
44
37
  Manifest,
45
38
  RemoteState,
@@ -51,6 +44,8 @@ import type {
51
44
  const SNAPSHOT_ID_RE = /^\d{8}T\d{6}Z-[0-9a-f]{6}$/;
52
45
  const MANIFEST_NAME = "manifest.json";
53
46
  export const STATE_PART = "state.tar.zst";
47
+ /** Where the database copy lands inside the state part. */
48
+ export const DB_MEMBER = "db/talon.db";
54
49
 
55
50
  function backupsRoot(home: string = dirs.root): string {
56
51
  return join(home, "backups");
@@ -212,22 +207,15 @@ export async function listSnapshots(
212
207
  pinned: record.pinned,
213
208
  createdAt: record.createdAt,
214
209
  sizeBytes: record.sizeBytes,
215
- local: await exists(join(snapshotDir(record.id, home), MANIFEST_NAME)),
210
+ local: await pathExists(
211
+ join(snapshotDir(record.id, home), MANIFEST_NAME),
212
+ ),
216
213
  remote,
217
214
  });
218
215
  }
219
216
  return summaries;
220
217
  }
221
218
 
222
- async function exists(path: string): Promise<boolean> {
223
- try {
224
- await stat(path);
225
- return true;
226
- } catch {
227
- return false;
228
- }
229
- }
230
-
231
219
  /** Pin or unpin a snapshot, on disk and in the index. Returns false if unknown. */
232
220
  export async function setSnapshotPinned(
233
221
  id: string,
@@ -30,6 +30,17 @@ import type { Manifest, SnapshotPart } from "./types.js";
30
30
  /** The daemon's chat id for plugin actions that belong to no conversation. */
31
31
  const SYSTEM_CHAT = "system";
32
32
 
33
+ /**
34
+ * How long a target may take to answer. A plugin that never answers would
35
+ * otherwise hold the backup queue forever — every later snapshot, and the
36
+ * pre-update checkpoint `/update` waits on, queued behind it. Part uploads
37
+ * get a base allowance plus time for the bytes at a slow uplink's pace.
38
+ */
39
+ const CONTROL_DEADLINE_MS = 10 * 60_000;
40
+ const TRANSFER_BASE_MS = 30 * 60_000;
41
+ /** 64 KiB/s — well under any link a remote backup is usable over. */
42
+ const TRANSFER_FLOOR_BYTES_PER_MS = 64;
43
+
33
44
  /** A part as the upload call describes it: metadata plus where to read it. */
34
45
  type UploadPart = SnapshotPart & { path: string };
35
46
 
@@ -101,6 +112,37 @@ function targetError(
101
112
  });
102
113
  }
103
114
 
115
+ /** setTimeout's ceiling; a longer delay would fire at once. */
116
+ const MAX_TIMER_MS = 2 ** 31 - 1;
117
+
118
+ /**
119
+ * Send one body, failing with `target`'s name once `deadlineMs` passes.
120
+ * A null deadline sends it unbounded.
121
+ */
122
+ async function dispatchWithin(
123
+ deps: TargetDeps,
124
+ plugin: string,
125
+ body: Record<string, unknown>,
126
+ target: string,
127
+ deadlineMs: number | null,
128
+ ): Promise<ActionResult | null> {
129
+ if (deadlineMs === null) return deps.dispatch(plugin, body);
130
+ const ms = Math.min(deadlineMs, MAX_TIMER_MS);
131
+ let timer: ReturnType<typeof setTimeout> | undefined;
132
+ const deadline = new Promise<never>((_, reject) => {
133
+ timer = setTimeout(() => {
134
+ const action = String(body.action).replace(/^backup\.target\./, "");
135
+ const minutes = Math.round(ms / 60_000);
136
+ reject(targetError(target, action, `no answer within ${minutes} min`));
137
+ }, ms);
138
+ });
139
+ try {
140
+ return await Promise.race([deps.dispatch(plugin, body), deadline]);
141
+ } finally {
142
+ clearTimeout(timer);
143
+ }
144
+ }
145
+
104
146
  /** Unwrap `{ ok, data }`, turning every failure shape into one error. */
105
147
  function dataOf(
106
148
  result: ActionResult | null,
@@ -128,8 +170,11 @@ class PluginTarget implements BackupTarget {
128
170
  readonly detail?: string,
129
171
  ) {}
130
172
 
131
- private send(body: Record<string, unknown>): Promise<ActionResult | null> {
132
- return this.deps.dispatch(this.plugin, body);
173
+ private send(
174
+ body: Record<string, unknown>,
175
+ deadlineMs: number | null = CONTROL_DEADLINE_MS,
176
+ ): Promise<ActionResult | null> {
177
+ return dispatchWithin(this.deps, this.plugin, body, this.id, deadlineMs);
133
178
  }
134
179
 
135
180
  async upload(
@@ -138,12 +183,10 @@ class PluginTarget implements BackupTarget {
138
183
  manifest: Manifest,
139
184
  ): Promise<{ remoteId: string; deduplicated?: boolean }> {
140
185
  const data = dataOf(
141
- await this.send({
142
- action: "backup.target.upload",
143
- snapshotId,
144
- part,
145
- manifest,
146
- }),
186
+ await this.send(
187
+ { action: "backup.target.upload", snapshotId, part, manifest },
188
+ TRANSFER_BASE_MS + part.bytes / TRANSFER_FLOOR_BYTES_PER_MS,
189
+ ),
147
190
  this.id,
148
191
  "upload",
149
192
  );
@@ -206,12 +249,17 @@ class PluginTarget implements BackupTarget {
206
249
  destPath: string,
207
250
  ): Promise<void> {
208
251
  dataOf(
209
- await this.send({
210
- action: "backup.target.download",
211
- snapshotId,
212
- part: { name: partName },
213
- destPath,
214
- }),
252
+ // Unbounded: only an interactive restore downloads, the size is not
253
+ // known here, and the operator can interrupt it.
254
+ await this.send(
255
+ {
256
+ action: "backup.target.download",
257
+ snapshotId,
258
+ part: { name: partName },
259
+ destPath,
260
+ },
261
+ null,
262
+ ),
215
263
  this.id,
216
264
  "download",
217
265
  );
@@ -231,9 +279,13 @@ export async function discoverTargets(
231
279
  for (const plugin of deps.plugins()) {
232
280
  let result: ActionResult | null;
233
281
  try {
234
- result = await deps.dispatch(plugin, {
235
- action: "backup.target.describe",
236
- });
282
+ result = await dispatchWithin(
283
+ deps,
284
+ plugin,
285
+ { action: "backup.target.describe" },
286
+ plugin,
287
+ CONTROL_DEADLINE_MS,
288
+ );
237
289
  } catch (err) {
238
290
  logWarn(
239
291
  "backup",
@@ -478,6 +478,20 @@ const configSchema = z.object({
478
478
  planAlertThreshold: z.number().int().min(1).max(100).default(80),
479
479
  /** Chat that receives plan warnings. Defaults to `adminUserId`. */
480
480
  planAlertChatId: z.string().optional(),
481
+ /**
482
+ * Operator alerts (core/frontend-runtime/alerts.ts): faults that need a
483
+ * human — a full disk, a crash, an error spike, a dead frontend — sent
484
+ * to the admin chat, once per fault per `cooldownMinutes`, with a
485
+ * recovery notice when it clears. `enabled: false` keeps them in the
486
+ * log and in `talon status` only.
487
+ */
488
+ alerts: z
489
+ .object({
490
+ enabled: z.boolean().default(true),
491
+ cooldownMinutes: z.number().int().min(0).max(1440).default(30),
492
+ })
493
+ .strict()
494
+ .optional(),
481
495
  /** Background memory-consolidation (dream) runs. Mirrors `pulse`/`heartbeat`. */
482
496
  dream: z.boolean().default(true),
483
497
  /**
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Crash marker — how a daemon that died tells the next one.
3
+ *
4
+ * A dying process can't reliably message anyone: the uncaught-exception
5
+ * path runs `process.exit(1)` within the same tick, and an async send to
6
+ * Telegram never leaves. So the crash path writes one small file,
7
+ * synchronously, and the next boot — once the admin notifier and the
8
+ * frontends are up — turns it into a `daemon.crash` alert and deletes it.
9
+ *
10
+ * Three writers: the uncaught-exception handler (./crash.ts), the fatal
11
+ * startup path (app.ts), and the handoff watcher (./handoff.ts) when a
12
+ * `/restart` successor never served. Last write wins; the file is tiny on
13
+ * purpose and never grows. This module imports nothing from core so the
14
+ * handoff watcher's bare runtime can carry it.
15
+ */
16
+
17
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+ import { dirs } from "../../util/paths.js";
20
+
21
+ export type CrashKind = "uncaught" | "startup" | "handoff";
22
+
23
+ export type CrashMarker = {
24
+ kind: CrashKind;
25
+ /** ISO time of the crash. */
26
+ at: string;
27
+ message: string;
28
+ /** The first few stack lines, frames only. */
29
+ stack: string[];
30
+ pid: number;
31
+ };
32
+
33
+ const STACK_LINES = 5;
34
+ const MAX_MESSAGE = 500;
35
+
36
+ function crashMarkerPath(): string {
37
+ return resolve(dirs.data, "last-crash.json");
38
+ }
39
+
40
+ /** Error → message plus its top frames, bounded. */
41
+ function describe(err: unknown): { message: string; stack: string[] } {
42
+ if (!(err instanceof Error)) {
43
+ return { message: String(err).slice(0, MAX_MESSAGE), stack: [] };
44
+ }
45
+ const stack = (err.stack ?? "")
46
+ .split("\n")
47
+ .map((line) => line.trim())
48
+ .filter((line) => line.startsWith("at "))
49
+ .slice(0, STACK_LINES);
50
+ const name = err.name && err.name !== "Error" ? `${err.name}: ` : "";
51
+ return { message: `${name}${err.message}`.slice(0, MAX_MESSAGE), stack };
52
+ }
53
+
54
+ /**
55
+ * Record a crash. Synchronous by design — the caller is about to exit.
56
+ * Throws on failure (a full disk); crash-path callers wrap it in
57
+ * `crashStep`.
58
+ */
59
+ export function writeCrashMarker(
60
+ kind: CrashKind,
61
+ err: unknown,
62
+ opts: { path?: string; keepExisting?: boolean } = {},
63
+ ): void {
64
+ const marker: CrashMarker = {
65
+ kind,
66
+ at: new Date().toISOString(),
67
+ ...describe(err),
68
+ pid: process.pid,
69
+ };
70
+ // keepExisting: the handoff watcher must not clobber the more specific
71
+ // marker a successor that crashed on its own already left.
72
+ writeFileSync(opts.path ?? crashMarkerPath(), JSON.stringify(marker), {
73
+ mode: 0o600,
74
+ flag: opts.keepExisting ? "wx" : "w",
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Read and delete the marker. Null when there is none; a corrupt marker
80
+ * is deleted and reported as null. Never throws.
81
+ */
82
+ export function takeCrashMarker(
83
+ path: string = crashMarkerPath(),
84
+ ): CrashMarker | null {
85
+ let raw: string;
86
+ try {
87
+ raw = readFileSync(path, "utf-8");
88
+ } catch {
89
+ return null;
90
+ }
91
+ try {
92
+ unlinkSync(path);
93
+ } catch {
94
+ /* a marker we can't delete would re-announce next boot — acceptable */
95
+ }
96
+ try {
97
+ const parsed = JSON.parse(raw) as Partial<CrashMarker>;
98
+ if (typeof parsed.at !== "string" || typeof parsed.message !== "string")
99
+ return null;
100
+ return {
101
+ kind: parsed.kind ?? "uncaught",
102
+ at: parsed.at,
103
+ message: parsed.message,
104
+ stack: Array.isArray(parsed.stack) ? parsed.stack : [],
105
+ pid: typeof parsed.pid === "number" ? parsed.pid : 0,
106
+ };
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Cross-boot throttle. Under systemd's `Restart=on-failure` a crash loop
114
+ * boots a fresh process every few seconds, and each would otherwise send
115
+ * its own alert — the in-process cooldown can't see across restarts.
116
+ * Returns how many crashes were held back since the last announcement
117
+ * (fold them into the message), or null when this one is inside the
118
+ * cooldown and should only be counted. Never throws.
119
+ */
120
+ export function claimCrashAnnouncement(
121
+ cooldownMs: number,
122
+ path: string = resolve(dirs.data, "crash-announced.json"),
123
+ now: number = Date.now(),
124
+ ): number | null {
125
+ let prior = { at: 0, held: 0 };
126
+ try {
127
+ prior = { ...prior, ...JSON.parse(readFileSync(path, "utf-8")) };
128
+ } catch {
129
+ /* first crash, or an unreadable ledger — announce */
130
+ }
131
+ const inCooldown = now - prior.at < cooldownMs;
132
+ const next = inCooldown
133
+ ? { at: prior.at, held: prior.held + 1 }
134
+ : { at: now, held: 0 };
135
+ try {
136
+ writeFileSync(path, JSON.stringify(next), { mode: 0o600 });
137
+ } catch {
138
+ /* worst case the next crash announces too */
139
+ }
140
+ return inCooldown ? null : prior.held;
141
+ }
@@ -19,6 +19,8 @@
19
19
  */
20
20
 
21
21
  import { logError, logWarn } from "../../util/log.js";
22
+ import { writeCrashMarker } from "./crash-marker.js";
23
+ import { noteUnhandledRejection } from "./health-alerts.js";
22
24
  import { removePidRecordIfOwnedBy } from "./pidfile.js";
23
25
  import { spawnSuccessor } from "./respawn.js";
24
26
 
@@ -65,7 +67,9 @@ export function crashCleanup(hooks: CrashHooks): void {
65
67
 
66
68
  /**
67
69
  * `process.on("uncaughtException")` body. Cleanup happens before the
68
- * crash is reported, never after.
70
+ * crash is reported, never after. The crash marker sits between the two:
71
+ * it is how the operator hears about this crash (the next boot announces
72
+ * it), but it is not worth a pidfile or a successor.
69
73
  */
70
74
  export function handleUncaughtException(err: Error, hooks: CrashHooks): void {
71
75
  // EPIPE errors from network sockets (e.g. Telegram MTProto) are transient —
@@ -77,6 +81,7 @@ export function handleUncaughtException(err: Error, hooks: CrashHooks): void {
77
81
  return;
78
82
  }
79
83
  crashCleanup(hooks);
84
+ crashStep("crash marker", () => writeCrashMarker("uncaught", err));
80
85
  crashStep("crash report", () => logError("bot", "Uncaught exception", err));
81
86
  process.exit(1);
82
87
  }
@@ -86,7 +91,8 @@ export function handleUncaughtException(err: Error, hooks: CrashHooks): void {
86
91
  * the stack: a bare "Unhandled rejection: ENOSPC: no space left on device,
87
92
  * write" says nothing about which code path forgot its `.catch()`. Async fs
88
93
  * errors carry `path`/`syscall` rather than useful frames, so those ride
89
- * along in the message too.
94
+ * along in the message too. Repeats raise `daemon.unhandled`
95
+ * (./health-alerts.ts).
90
96
  */
91
97
  export function handleUnhandledRejection(reason: unknown): void {
92
98
  crashStep("rejection report", () => {
@@ -102,4 +108,5 @@ export function handleUnhandledRejection(reason: unknown): void {
102
108
  reason,
103
109
  );
104
110
  });
111
+ crashStep("rejection alarm", () => noteUnhandledRejection(reason));
105
112
  }
@@ -27,6 +27,7 @@
27
27
  import { dirname, resolve } from "node:path";
28
28
  import { log, logError, logWarn } from "../../util/log.js";
29
29
  import { startDaemon, type StartOutcome } from "./control.js";
30
+ import { writeCrashMarker } from "./crash-marker.js";
30
31
  import { findRunningInstance, type RunningInstance } from "./discovery.js";
31
32
  import { isProcessAlive } from "./pidfile.js";
32
33
 
@@ -130,6 +131,19 @@ function toOutcome(started: StartOutcome, why: string): HandoffOutcome {
130
131
  return { ok: false, reason: `${why}; restart ${describeStart(started)}` };
131
132
  }
132
133
 
134
+ /**
135
+ * Leave a crash marker so whichever daemon comes up next tells the
136
+ * operator the restart failed. A successor that crashed on its own left
137
+ * a more specific marker already; that one is kept.
138
+ */
139
+ function markHandoffFailure(why: string): void {
140
+ try {
141
+ writeCrashMarker("handoff", why, { keepExisting: true });
142
+ } catch {
143
+ /* EEXIST (the successor's own marker) or a full disk — nothing to add */
144
+ }
145
+ }
146
+
133
147
  /**
134
148
  * Verify the handoff, and repair it if it failed. Never throws: this
135
149
  * process exists only to make the outcome known.
@@ -153,6 +167,7 @@ export async function watchHandoff(
153
167
  "shutdown",
154
168
  `Handoff failed — ${why}; starting Talon the way \`talon start\` does`,
155
169
  );
170
+ markHandoffFailure(why);
156
171
  const start = opts.start ?? startDaemon;
157
172
  const started = await start({
158
173
  pkgRoot: opts.pkgRoot,