talon-agent 5.18.2 → 5.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +2 -1
  2. package/package.json +2 -2
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/one-shot.ts +32 -2
  8. package/src/backend/claude-sdk/stream.ts +2 -2
  9. package/src/backend/codex/auth.ts +1 -1
  10. package/src/backend/codex/handler/message.ts +11 -11
  11. package/src/backend/codex/init.ts +4 -9
  12. package/src/backend/codex/mcp-config.ts +1 -2
  13. package/src/backend/codex/oauth-incompat.ts +8 -4
  14. package/src/backend/codex/one-shot.ts +1 -1
  15. package/src/backend/openai-agents/builtins.ts +55 -27
  16. package/src/backend/openai-agents/factory.ts +3 -3
  17. package/src/backend/openai-agents/handler/message.ts +3 -5
  18. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  19. package/src/backend/remote-server/chat-turn.ts +6 -6
  20. package/src/backend/remote-server/events.ts +1 -5
  21. package/src/backend/remote-server/index.ts +0 -1
  22. package/src/backend/remote-server/messages.ts +3 -7
  23. package/src/backend/remote-server/one-shot.ts +1 -3
  24. package/src/backend/remote-server/session-helpers.ts +1 -4
  25. package/src/backend/remote-server/sse-stream.ts +8 -9
  26. package/src/backend/runtime/metrics.ts +7 -13
  27. package/src/backend/runtime/sleep.ts +1 -2
  28. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  29. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  30. package/src/bootstrap.ts +9 -1
  31. package/src/cli/doctor.ts +3 -0
  32. package/src/cli/index.ts +8 -10
  33. package/src/cli/logs.ts +148 -9
  34. package/src/cli/setup.ts +9 -11
  35. package/src/cli/status.ts +29 -0
  36. package/src/core/agent-runtime/README.md +5 -19
  37. package/src/core/agent-runtime/events.ts +3 -39
  38. package/src/core/agent-runtime/model-ref.ts +0 -8
  39. package/src/core/agents/registry.ts +65 -2
  40. package/src/core/auth/expiry-monitor.ts +9 -1
  41. package/src/core/auth/login-flow.ts +9 -1
  42. package/src/core/auth/status.ts +31 -3
  43. package/src/core/background/cron/scheduler.ts +25 -5
  44. package/src/core/background/dream/index.ts +29 -10
  45. package/src/core/background/failure-backoff.ts +30 -0
  46. package/src/core/background/heartbeat/agent.ts +2 -27
  47. package/src/core/background/heartbeat/index.ts +0 -2
  48. package/src/core/background/heartbeat/scheduler.ts +21 -13
  49. package/src/core/background/heartbeat/state.ts +10 -2
  50. package/src/core/background/isolated-agent.ts +6 -2
  51. package/src/core/background/pulse/pulse.ts +9 -0
  52. package/src/core/background/triggers/exit.ts +54 -0
  53. package/src/core/background/triggers/index.ts +1 -3
  54. package/src/core/background/triggers/resume.ts +2 -4
  55. package/src/core/backup/archive/tar.ts +14 -4
  56. package/src/core/backup/plan.ts +1 -0
  57. package/src/core/backup/restore.ts +21 -13
  58. package/src/core/backup/scheduler.ts +28 -9
  59. package/src/core/backup/snapshot.ts +43 -10
  60. package/src/core/backup/store.ts +7 -19
  61. package/src/core/backup/targets.ts +69 -17
  62. package/src/core/config/index.ts +14 -0
  63. package/src/core/daemon/crash-marker.ts +141 -0
  64. package/src/core/daemon/crash.ts +9 -2
  65. package/src/core/daemon/handoff.ts +15 -0
  66. package/src/core/daemon/health-alerts.ts +297 -0
  67. package/src/core/daemon/log-reader.ts +289 -0
  68. package/src/core/doctor/index.ts +18 -2
  69. package/src/core/doctor/logs.ts +124 -0
  70. package/src/core/doctor/types.ts +1 -1
  71. package/src/core/engine/backend-controller/index.ts +1 -13
  72. package/src/core/engine/backend-router/router.ts +1 -1
  73. package/src/core/engine/dispatcher.ts +55 -2
  74. package/src/core/engine/fault-text.ts +40 -0
  75. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  76. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  77. package/src/core/engine/gateway-actions/history.ts +2 -4
  78. package/src/core/engine/gateway-actions/mesh.ts +26 -14
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway-actions/native/teleport.ts +1 -1
  81. package/src/core/engine/gateway.ts +60 -1
  82. package/src/core/engine/turn-health.ts +222 -0
  83. package/src/core/errors.ts +2 -2
  84. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  85. package/src/core/frontend-runtime/alerts.ts +130 -0
  86. package/src/core/mcp-hub/children.ts +78 -29
  87. package/src/core/mcp-hub/index.ts +21 -18
  88. package/src/core/mcp-hub/proxy-server.ts +8 -4
  89. package/src/core/mcp-hub/talon-server.ts +5 -12
  90. package/src/core/mesh/credentials/store.ts +16 -1
  91. package/src/core/mesh/devices/registry.ts +1 -17
  92. package/src/core/mesh/devices/service.ts +49 -17
  93. package/src/core/mesh/devices/teleport.ts +14 -2
  94. package/src/core/mesh/links/node-binaries.ts +13 -6
  95. package/src/core/mesh/persist.ts +22 -10
  96. package/src/core/mesh/transfers/device-files.ts +5 -23
  97. package/src/core/mesh/transfers/transfers.ts +16 -3
  98. package/src/core/models/active-model.ts +2 -55
  99. package/src/core/plugin/actions.ts +19 -20
  100. package/src/core/plugin/builtins.ts +80 -90
  101. package/src/core/plugin/index.ts +1 -4
  102. package/src/core/plugin/loader.ts +25 -33
  103. package/src/core/plugin/mcp.ts +3 -5
  104. package/src/core/plugin/registry.ts +19 -35
  105. package/src/core/plugin/types.ts +2 -5
  106. package/src/core/prompt/assemble.ts +15 -3
  107. package/src/core/scripts/lua.ts +6 -2
  108. package/src/core/tasks/table.ts +8 -2
  109. package/src/core/tools/bridge.ts +2 -4
  110. package/src/core/tools/chat/cross-send.ts +1 -1
  111. package/src/core/tools/chat/messaging.ts +1 -1
  112. package/src/core/tools/index.ts +2 -2
  113. package/src/core/tools/mcp-env.ts +2 -59
  114. package/src/core/tools/ops/agents.ts +22 -1
  115. package/src/core/tools/schemas.ts +4 -9
  116. package/src/core/vfs/fusefs.ts +0 -5
  117. package/src/core/vfs/index.ts +9 -2
  118. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  119. package/src/core/vfs/mounts/proc.ts +17 -1
  120. package/src/core/vfs/workspace.ts +7 -3
  121. package/src/core/weaver/shuttle.ts +8 -1
  122. package/src/core/weaver/turn-log.ts +320 -0
  123. package/src/core/weaver/weaver.ts +48 -5
  124. package/src/frontend/discord/actions/index.ts +8 -1
  125. package/src/frontend/discord/diagnostics.ts +82 -6
  126. package/src/frontend/discord/handlers/index.ts +0 -2
  127. package/src/frontend/discord/middleware.ts +7 -13
  128. package/src/frontend/discord/runtime.ts +1 -3
  129. package/src/frontend/health/delivery.ts +115 -0
  130. package/src/frontend/health/outage.ts +116 -0
  131. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  132. package/src/frontend/native/bridge/server.ts +106 -21
  133. package/src/frontend/native/index.ts +1 -1
  134. package/src/frontend/native/media/media.ts +5 -1
  135. package/src/frontend/native/runtime.ts +12 -7
  136. package/src/frontend/native/surface/handlers.ts +1 -1
  137. package/src/frontend/native/surface/memory.ts +1 -1
  138. package/src/frontend/native/surface/models.ts +3 -3
  139. package/src/frontend/native/surface/settings.ts +20 -8
  140. package/src/frontend/native/turn/context.ts +6 -8
  141. package/src/frontend/native/turn/turn-meta.ts +2 -5
  142. package/src/frontend/native/turn/turn.ts +8 -10
  143. package/src/frontend/presentation/format.ts +2 -4
  144. package/src/frontend/presentation/session-status.ts +2 -6
  145. package/src/frontend/teams/actions.ts +8 -1
  146. package/src/frontend/teams/graph.ts +0 -1
  147. package/src/frontend/teams/index.ts +1 -4
  148. package/src/frontend/teams/poll.ts +40 -2
  149. package/src/frontend/teams/runtime.ts +14 -5
  150. package/src/frontend/telegram/actions/index.ts +4 -1
  151. package/src/frontend/telegram/actions/send.ts +8 -0
  152. package/src/frontend/telegram/handlers/context.ts +13 -2
  153. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  154. package/src/frontend/telegram/handlers/index.ts +0 -2
  155. package/src/frontend/telegram/index.ts +35 -9
  156. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  157. package/src/frontend/telegram/userbot.ts +100 -36
  158. package/src/frontend/terminal/builtins/session.ts +2 -2
  159. package/src/frontend/terminal/index.ts +1 -3
  160. package/src/frontend/terminal/renderer.ts +2 -18
  161. package/src/frontend/whatsapp/actions/index.ts +12 -1
  162. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  163. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  164. package/src/frontend/whatsapp/connection/health.ts +89 -0
  165. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  166. package/src/frontend/whatsapp/runtime.ts +11 -5
  167. package/src/native/blake3.ts +28 -2
  168. package/src/native/fusefs.ts +23 -5
  169. package/src/native/registry.ts +1 -1
  170. package/src/native/warden.ts +33 -5
  171. package/src/plugins/github/index.ts +0 -1
  172. package/src/plugins/mempalace/index.ts +9 -3
  173. package/src/plugins/playwright/index.ts +2 -4
  174. package/src/plugins/playwright/provision.ts +12 -4
  175. package/src/storage/chat-settings.ts +6 -1
  176. package/src/storage/cron.ts +29 -4
  177. package/src/storage/daily-log.ts +43 -47
  178. package/src/storage/db.ts +61 -33
  179. package/src/storage/history.ts +6 -1
  180. package/src/storage/journal.ts +9 -2
  181. package/src/storage/kv.ts +19 -6
  182. package/src/storage/media-index.ts +28 -6
  183. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  184. package/src/storage/repositories/sessions-repo.ts +10 -3
  185. package/src/storage/scripts.ts +24 -13
  186. package/src/storage/sessions.ts +11 -2
  187. package/src/storage/skills.ts +21 -2
  188. package/src/storage/stickers.ts +17 -3
  189. package/src/storage/triggers.ts +8 -3
  190. package/src/storage/turn-meta.ts +25 -7
  191. package/src/util/log.ts +189 -6
  192. package/src/util/logging/turn-scope.ts +85 -0
  193. package/src/util/time.ts +3 -3
  194. package/src/util/watchdog.ts +30 -0
  195. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { readFileSync, readdirSync } from "node:fs";
16
16
  import { resolve } from "node:path";
17
+ import { logWarn } from "../util/log.js";
17
18
  import { dirs } from "../util/paths.js";
18
19
 
19
20
  // ── Types ───────────────────────────────────────────────────────────────────
@@ -50,7 +51,15 @@ export function listSavedPacks(): SavedPack[] {
50
51
  let entries: string[];
51
52
  try {
52
53
  entries = readdirSync(dir).filter((f) => f.endsWith(".json"));
53
- } catch {
54
+ } catch (err) {
55
+ // No stickers dir yet is the normal empty library; anything else
56
+ // (EACCES, ENOTDIR) hides every saved pack.
57
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
58
+ logWarn(
59
+ "stickers",
60
+ `Could not list sticker packs dir=${dir}: ${err instanceof Error ? err.message : String(err)}`,
61
+ );
62
+ }
54
63
  return [];
55
64
  }
56
65
  const packs: SavedPack[] = [];
@@ -82,8 +91,13 @@ export function listSavedPacks(): SavedPack[] {
82
91
  stickers,
83
92
  savedAt: typeof raw.savedAt === "string" ? raw.savedAt : "",
84
93
  });
85
- } catch {
86
- /* skip malformed pack file */
94
+ } catch (err) {
95
+ // Skip the malformed pack file, but name it — it silently drops
96
+ // out of the library otherwise.
97
+ logWarn(
98
+ "stickers",
99
+ `Skipped unreadable sticker pack file=${file}: ${err instanceof Error ? err.message : String(err)}`,
100
+ );
87
101
  }
88
102
  }
89
103
  return packs.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
@@ -19,7 +19,7 @@ import {
19
19
  } from "node:fs";
20
20
  import { randomUUID } from "node:crypto";
21
21
  import { dirname, resolve } from "node:path";
22
- import { log, logError } from "../util/log.js";
22
+ import { log, logError, logWarn } from "../util/log.js";
23
23
  import { dirs, files } from "../util/paths.js";
24
24
  import { importLegacyJson } from "./legacy-import.js";
25
25
  import { inTransaction } from "./db.js";
@@ -286,8 +286,13 @@ export function deleteTrigger(id: string): boolean {
286
286
  for (const path of [t.scriptPath, t.logPath]) {
287
287
  try {
288
288
  rmSync(path, { force: true });
289
- } catch {
290
- /* best effort */
289
+ } catch (err) {
290
+ // Best effort; `force` already absorbs a missing file, so this is
291
+ // a real fault that leaves an orphan on disk.
292
+ logWarn(
293
+ "triggers",
294
+ `Could not remove trigger file id=${id} path=${path}: ${err instanceof Error ? err.message : String(err)}`,
295
+ );
291
296
  }
292
297
  }
293
298
  return true;
@@ -14,11 +14,11 @@
14
14
  * actually fetch.
15
15
  */
16
16
 
17
- import { inTransaction } from "./db.js";
17
+ import { dbErrorFields, inTransaction } from "./db.js";
18
18
  import * as repo from "./repositories/turn-meta-repo.js";
19
19
  import { importLegacyJson } from "./legacy-import.js";
20
20
  import { files } from "../util/paths.js";
21
- import { logError } from "../util/log.js";
21
+ import { logError, logWarn } from "../util/log.js";
22
22
 
23
23
  /** Per-chat cap — matches the /history page ceiling. */
24
24
  const MAX_PER_CHAT = 500;
@@ -83,7 +83,11 @@ export function recordTurnMeta(
83
83
  repo.prune(chatId, MAX_PER_CHAT);
84
84
  });
85
85
  } catch (err) {
86
- logError("db", "Failed to persist turn meta", err);
86
+ logError(
87
+ "db",
88
+ `Failed to persist turn meta chat=${chatId} msg=${msgId}${dbErrorFields(err)}`,
89
+ err,
90
+ );
87
91
  }
88
92
  }
89
93
 
@@ -96,14 +100,24 @@ export function getTurnMeta<T>(chatId: string, msgId: string): T | null {
96
100
  } catch (err) {
97
101
  // Reads are hydration-only — a storage fault must degrade to
98
102
  // "no meta", not break the caller (matching recordTurnMeta).
99
- logError("db", "Failed to read turn meta", err);
103
+ logError(
104
+ "db",
105
+ `Failed to read turn meta chat=${chatId} msg=${msgId}${dbErrorFields(err)}`,
106
+ err,
107
+ );
100
108
  return null;
101
109
  }
102
110
  if (raw === undefined) return null;
103
111
  try {
104
112
  return JSON.parse(raw) as T;
105
- } catch {
106
- return null; // corrupt row — treat as absent rather than throw
113
+ } catch (err) {
114
+ // Corrupt row — treat as absent rather than throw, but say so: the
115
+ // caller sees "no meta" and can't tell it from a message never tagged.
116
+ logWarn(
117
+ "db",
118
+ `Corrupt turn meta chat=${chatId} msg=${msgId} bytes=${raw.length}: ${err instanceof Error ? err.message : String(err)}`,
119
+ );
120
+ return null;
107
121
  }
108
122
  }
109
123
 
@@ -113,6 +127,10 @@ export function clearTurnMeta(chatId: string): void {
113
127
  try {
114
128
  repo.removeChat(chatId);
115
129
  } catch (err) {
116
- logError("db", "Failed to clear turn meta", err);
130
+ logError(
131
+ "db",
132
+ `Failed to clear turn meta chat=${chatId}${dbErrorFields(err)}`,
133
+ err,
134
+ );
117
135
  }
118
136
  }
package/src/util/log.ts CHANGED
@@ -19,8 +19,10 @@ import {
19
19
  openSync,
20
20
  writeSync,
21
21
  closeSync,
22
+ fstatSync,
22
23
  } from "node:fs";
23
24
  import { dirs, files } from "./paths.js";
25
+ import { currentTurnId } from "./logging/turn-scope.js";
24
26
 
25
27
  export type LogComponent =
26
28
  | "bot"
@@ -31,8 +33,10 @@ export type LogComponent =
31
33
  | "kv"
32
34
  | "media"
33
35
  | "notify"
36
+ | "alert"
34
37
  | "agent"
35
38
  | "agents"
39
+ | "tasks"
36
40
  | "pulse"
37
41
  | "userbot"
38
42
  | "users"
@@ -107,6 +111,47 @@ function rotateIfLarge(path: string): void {
107
111
 
108
112
  rotateIfLarge(LOG_FILE);
109
113
 
114
+ /**
115
+ * Runtime retention for talon.log: once the live file passes the cap the
116
+ * sink shifts it to `talon.log.1` (…`.1` → `.2`, oldest dropped), so a
117
+ * long-running daemon keeps a bounded, numbered history instead of one
118
+ * ever-growing file. The start-time `.old` rule above stays as it was;
119
+ * readers treat `.old` as one more generation.
120
+ */
121
+ const LOG_ROTATE_KEEP = 5;
122
+
123
+ /** The disk name of rotated generation `n` (1 = newest) of `path`. */
124
+ function rotatedLogPath(path: string, n: number): string {
125
+ return `${path}.${n}`;
126
+ }
127
+
128
+ /**
129
+ * Shift `path` into the numbered generations: drop `.keep`, move each
130
+ * `.n` to `.n+1`, then `path` to `.1`. Every step is one rename, so a
131
+ * crash part-way leaves a gap in the numbering, never a lost line — the
132
+ * only file ever deleted is the oldest generation. Throws on the first
133
+ * failure other than a missing generation.
134
+ */
135
+ export function shiftLogGenerations(
136
+ path: string,
137
+ keep: number = LOG_ROTATE_KEEP,
138
+ ): void {
139
+ const skipMissing = (step: () => void): void => {
140
+ try {
141
+ step();
142
+ } catch (err) {
143
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
144
+ }
145
+ };
146
+ skipMissing(() => unlinkSync(rotatedLogPath(path, keep)));
147
+ for (let n = keep - 1; n >= 1; n--) {
148
+ skipMissing(() =>
149
+ renameSync(rotatedLogPath(path, n), rotatedLogPath(path, n + 1)),
150
+ );
151
+ }
152
+ renameSync(path, rotatedLogPath(path, 1));
153
+ }
154
+
110
155
  // Suppress console output for terminal frontend (stdout belongs to the REPL)
111
156
  let quiet = process.env.TALON_QUIET === "1";
112
157
  if (!quiet) {
@@ -169,6 +214,8 @@ const SINK_MAX_RETRY_MS = 5 * 60_000;
169
214
  export type SyncLogTarget = {
170
215
  /** Append one already-serialized line. Throws on failure. */
171
216
  write(line: string): void;
217
+ /** Bytes already in the file when it was opened, when known. */
218
+ readonly initialSize?: number;
172
219
  /** Release the underlying handle. Never throws. */
173
220
  close(): void;
174
221
  };
@@ -182,6 +229,13 @@ export type ResilientFileSinkOptions = {
182
229
  retryMs?: number;
183
230
  /** Backoff ceiling (default 5 min). */
184
231
  maxRetryMs?: number;
232
+ /**
233
+ * Rotate the file once it passes this many bytes (default 10 MB);
234
+ * 0 disables runtime rotation.
235
+ */
236
+ rotateAtBytes?: number;
237
+ /** Numbered generations kept by a rotation (default 5). */
238
+ keep?: number;
185
239
  };
186
240
 
187
241
  /**
@@ -221,6 +275,14 @@ export type ResilientFileSinkOptions = {
221
275
  * many lines were lost.
222
276
  * The console sink keeps working throughout, and carries the two
223
277
  * notices.
278
+ *
279
+ * It also owns retention: after a write carries the file past
280
+ * `rotateAtBytes` it shifts the generations (see
281
+ * {@link shiftLogGenerations}) and reopens a fresh file, all inside the
282
+ * same synchronous write — a few renames every 10 MB, no queue, no lost
283
+ * line. The size is re-read from disk before rotating, so a file another
284
+ * process already rotated is not rotated twice; a rotation that fails
285
+ * keeps appending to the current file and tries again one cap later.
224
286
  */
225
287
  export class ResilientFileSink {
226
288
  private readonly path: string;
@@ -233,6 +295,12 @@ export class ResilientFileSink {
233
295
  private retryTimer: ReturnType<typeof setTimeout> | null = null;
234
296
  private droppedWhileDown = 0;
235
297
  private down = false;
298
+ private readonly rotateAtBytes: number;
299
+ private readonly keep: number;
300
+ /** Bytes in the current file, as far as this process has seen. */
301
+ private bytes = 0;
302
+ /** Size at which the next rotation check runs. */
303
+ private rotateCheckAt: number;
236
304
 
237
305
  constructor(path: string, opts: ResilientFileSinkOptions = {}) {
238
306
  this.path = path;
@@ -241,6 +309,9 @@ export class ResilientFileSink {
241
309
  this.baseRetryMs = opts.retryMs ?? SINK_RETRY_MS;
242
310
  this.maxRetryMs = opts.maxRetryMs ?? SINK_MAX_RETRY_MS;
243
311
  this.retryMs = this.baseRetryMs;
312
+ this.rotateAtBytes = opts.rotateAtBytes ?? MAX_LOG_SIZE;
313
+ this.keep = opts.keep ?? LOG_ROTATE_KEEP;
314
+ this.rotateCheckAt = this.rotateAtBytes;
244
315
  this.openInner();
245
316
  }
246
317
 
@@ -269,6 +340,10 @@ export class ResilientFileSink {
269
340
  return;
270
341
  }
271
342
  this.markHealthy();
343
+ this.bytes += Buffer.byteLength(line, "utf-8");
344
+ if (this.rotateAtBytes > 0 && this.bytes > this.rotateCheckAt) {
345
+ this.rotate();
346
+ }
272
347
  }
273
348
 
274
349
  /** No-op: every write already reached the fd. Part of pino's shape. */
@@ -283,11 +358,59 @@ export class ResilientFileSink {
283
358
  private openInner(): void {
284
359
  try {
285
360
  this.target = this.openTarget(this.path);
361
+ this.bytes = this.target.initialSize ?? 0;
362
+ this.rotateCheckAt = this.rotateAtBytes;
286
363
  } catch (err) {
287
364
  this.fail(err);
288
365
  }
289
366
  }
290
367
 
368
+ /**
369
+ * The current file passed the cap: shift the generations and reopen.
370
+ * Never throws — a failed rename reopens the same file and appends
371
+ * on, a failed reopen goes through the ordinary pause/retry path.
372
+ */
373
+ private rotate(): void {
374
+ let onDisk: number;
375
+ try {
376
+ onDisk = statSync(this.path).size;
377
+ } catch {
378
+ // Unlinked under us: nothing to shift, just start a fresh file.
379
+ this.detachInner();
380
+ this.openInner();
381
+ return;
382
+ }
383
+ if (onDisk <= this.rotateAtBytes) {
384
+ // Someone else (another Talon process) already rotated it.
385
+ this.bytes = onDisk;
386
+ return;
387
+ }
388
+ // Close before renaming: a platform that refuses to rename an open
389
+ // file (Windows) then fails the shift cleanly instead of half-way.
390
+ this.detachInner();
391
+ let failure: unknown = null;
392
+ try {
393
+ shiftLogGenerations(this.path, this.keep);
394
+ } catch (err) {
395
+ failure = err;
396
+ }
397
+ this.openInner();
398
+ if (failure !== null) {
399
+ this.rotateCheckAt = this.bytes + this.rotateAtBytes;
400
+ const code = (failure as NodeJS.ErrnoException).code ?? String(failure);
401
+ this.emitNotice(
402
+ "warn",
403
+ `log.rotate failed file=${this.path} code=${code} — still appending`,
404
+ );
405
+ return;
406
+ }
407
+ this.emitNotice(
408
+ "info",
409
+ `log.rotate file=${this.path} bytes=${onDisk} keep=${this.keep} ` +
410
+ `previous=${rotatedLogPath(this.path, 1)}`,
411
+ );
412
+ }
413
+
291
414
  /** Give up on the current handle and arm a reopen. Never throws. */
292
415
  private fail(err: unknown): void {
293
416
  const firstFailure = !this.down;
@@ -378,7 +501,14 @@ function appendLine(fd: number, line: string): void {
378
501
  export function openSyncLogFile(path: string): SyncLogTarget {
379
502
  // 0600: turns and tool output land here — same sensitivity as history.
380
503
  const fd = openSync(path, "a", 0o600);
504
+ let initialSize = 0;
505
+ try {
506
+ initialSize = fstatSync(fd).size;
507
+ } catch {
508
+ /* unknown size: rotation counts from zero */
509
+ }
381
510
  return {
511
+ initialSize,
382
512
  write: (line) => appendLine(fd, line),
383
513
  close: () => {
384
514
  try {
@@ -443,8 +573,57 @@ function emit(write: () => void): void {
443
573
  }
444
574
  }
445
575
 
576
+ /**
577
+ * Append `turn=<id>` when the line is written from inside a running
578
+ * turn (see logging/turn-scope.ts), unless the caller already named it. The
579
+ * id goes in the message text, not a field, so a plain `grep` over
580
+ * talon.log and the pretty console both show it.
581
+ */
582
+ function tagTurn(message: string): string {
583
+ const turnId = currentTurnId();
584
+ if (!turnId) return message;
585
+ const tag = `turn=${turnId}`;
586
+ return message.includes(tag) ? message : `${message} ${tag}`;
587
+ }
588
+
446
589
  export function log(component: LogComponent, message: string): void {
447
- emit(() => logger.info({ component }, message));
590
+ emit(() => logger.info({ component }, tagTurn(message)));
591
+ }
592
+
593
+ type LogErrorListener = (
594
+ component: LogComponent,
595
+ message: string,
596
+ err?: unknown,
597
+ ) => void;
598
+
599
+ let errorListener: LogErrorListener | null = null;
600
+ let inErrorListener = false;
601
+
602
+ /**
603
+ * Register (or clear, with null) the one listener every `logError` call
604
+ * reaches — core's error-rate alarm (core/daemon/health-alerts.ts). util
605
+ * cannot import core, so core registers itself here. The listener runs
606
+ * synchronously and must be cheap; it cannot throw into the caller, and
607
+ * a `logError` it makes itself is not fed back to it.
608
+ */
609
+ export function onLogError(fn: LogErrorListener | null): void {
610
+ errorListener = fn;
611
+ }
612
+
613
+ function notifyErrorListener(
614
+ component: LogComponent,
615
+ message: string,
616
+ err: unknown,
617
+ ): void {
618
+ if (!errorListener || inErrorListener) return;
619
+ inErrorListener = true;
620
+ try {
621
+ errorListener(component, message, err);
622
+ } catch {
623
+ /* an alarm must never break the error path it watches */
624
+ } finally {
625
+ inErrorListener = false;
626
+ }
448
627
  }
449
628
 
450
629
  export function logError(
@@ -452,26 +631,30 @@ export function logError(
452
631
  message: string,
453
632
  err?: unknown,
454
633
  ): void {
634
+ notifyErrorListener(component, message, err);
455
635
  if (err instanceof Error) {
456
636
  // Capture both the concise message (for log consumers that look at `err`)
457
637
  // and the full stack (for diagnostics). pino-pretty renders the `stack`
458
638
  // field on its own line; JSON consumers can read either field.
459
639
  emit(() =>
460
- logger.error({ component, err: err.message, stack: err.stack }, message),
640
+ logger.error(
641
+ { component, err: err.message, stack: err.stack },
642
+ tagTurn(message),
643
+ ),
461
644
  );
462
645
  } else if (err !== undefined) {
463
- emit(() => logger.error({ component, err: String(err) }, message));
646
+ emit(() => logger.error({ component, err: String(err) }, tagTurn(message)));
464
647
  } else {
465
- emit(() => logger.error({ component }, message));
648
+ emit(() => logger.error({ component }, tagTurn(message)));
466
649
  }
467
650
  }
468
651
 
469
652
  export function logWarn(component: LogComponent, message: string): void {
470
- emit(() => logger.warn({ component }, message));
653
+ emit(() => logger.warn({ component }, tagTurn(message)));
471
654
  }
472
655
 
473
656
  export function logDebug(component: LogComponent, message: string): void {
474
- emit(() => logger.debug({ component }, message));
657
+ emit(() => logger.debug({ component }, tagTurn(message)));
475
658
  }
476
659
 
477
660
  // Expose logger to plugins running in the same process
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Log context — which turn a log line belongs to, carried implicitly.
3
+ *
4
+ * A turn fans out across the weaver, the backend adapter, the event
5
+ * shuttle and the gateway's tool actions; threading an id through every
6
+ * call site would touch dozens of signatures. Instead the Weaver runs
7
+ * each turn inside an AsyncLocalStorage scope and `log.ts` appends
8
+ * `turn=<id>` to every line written while that scope is live. One
9
+ * `grep turn=t-…` over talon.log then reconstructs the whole turn.
10
+ *
11
+ * Two traps shape the API:
12
+ *
13
+ * - **Stale scopes.** Async resources created during a turn (a
14
+ * long-lived subprocess, a timer a tool left behind) keep that turn's
15
+ * scope forever. A scope is therefore closed when its turn settles,
16
+ * and a closed scope tags nothing: a late line goes untagged rather
17
+ * than blamed on a turn that already ended.
18
+ * - **Tool actions arrive out of band.** The MCP subprocess calls the
19
+ * gateway over HTTP, so a tool action starts a fresh async root with
20
+ * no scope. The chat → open-scope map lets the gateway re-enter the
21
+ * chat's running turn by chat id.
22
+ */
23
+
24
+ import { AsyncLocalStorage } from "node:async_hooks";
25
+ import { randomBytes } from "node:crypto";
26
+
27
+ export type TurnLogScope = {
28
+ readonly turnId: string;
29
+ readonly chatId: string;
30
+ /** False once the turn has settled — the scope then tags nothing. */
31
+ open: boolean;
32
+ };
33
+
34
+ const storage = new AsyncLocalStorage<TurnLogScope>();
35
+ /** The running turn per chat (queued turns are not registered). */
36
+ const runningByChat = new Map<string, TurnLogScope>();
37
+
38
+ /** A short, greppable turn id: `t-` + 7 base36 chars. */
39
+ function mintTurnId(): string {
40
+ return `t-${randomBytes(4).readUInt32BE(0).toString(36).padStart(7, "0")}`;
41
+ }
42
+
43
+ /** A fresh scope for a turn that has been accepted but not yet started. */
44
+ export function createTurnScope(chatId: string): TurnLogScope {
45
+ return { turnId: mintTurnId(), chatId, open: true };
46
+ }
47
+
48
+ /**
49
+ * Run `fn` as the chat's running turn: every log line written from its
50
+ * async chain carries `turn=<id>` until {@link closeTurnScope}.
51
+ */
52
+ export function runInTurnScope<T>(scope: TurnLogScope, fn: () => T): T {
53
+ runningByChat.set(scope.chatId, scope);
54
+ return storage.run(scope, fn);
55
+ }
56
+
57
+ /** Mark the turn settled: its scope stops tagging lines. Idempotent. */
58
+ export function closeTurnScope(scope: TurnLogScope): void {
59
+ scope.open = false;
60
+ if (runningByChat.get(scope.chatId) === scope) {
61
+ runningByChat.delete(scope.chatId);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Run `fn` inside the running turn of the first chat id in `chatIds`
67
+ * that has one, or plainly when none does. Used by the gateway so a
68
+ * tool action's lines join the turn that issued it.
69
+ */
70
+ export function runInChatTurnScope<T>(
71
+ chatIds: readonly (string | null | undefined)[],
72
+ fn: () => T,
73
+ ): T {
74
+ for (const chatId of chatIds) {
75
+ const scope = chatId ? runningByChat.get(chatId) : undefined;
76
+ if (scope?.open) return storage.run(scope, fn);
77
+ }
78
+ return fn();
79
+ }
80
+
81
+ /** The live turn id for the current async chain, if any. */
82
+ export function currentTurnId(): string | undefined {
83
+ const scope = storage.getStore();
84
+ return scope?.open ? scope.turnId : undefined;
85
+ }
package/src/util/time.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Timezone-aware time formatting utilities.
3
3
  *
4
- * All functions accept a timezone string (IANA, e.g. "Europe/Warsaw").
5
- * If none is set, falls back to the system default.
4
+ * Every helper formats in the IANA timezone set via `setTimezone`
5
+ * (e.g. "Europe/Warsaw"), falling back to the system default.
6
6
  */
7
7
 
8
8
  let configuredTz: string | undefined;
@@ -94,7 +94,7 @@ export function formatRelativeAge(ts: number): string {
94
94
 
95
95
  /**
96
96
  * Full datetime for system prompt injection.
97
- * Example: "2026-03-21 14:32 (Europe/Warsaw, Fri)"
97
+ * Example: "2026-03-21 14:32 Fri (Europe/Warsaw)"
98
98
  */
99
99
  export function formatFullDatetime(): string {
100
100
  const now = new Date();
@@ -126,9 +126,33 @@ let watchdogTimer: ReturnType<typeof setInterval> | null = null;
126
126
  let stuckWarnBackoffMs = STUCK_WARN_MS;
127
127
  let nextStuckWarnAt = 0;
128
128
 
129
+ /**
130
+ * Who hears about a wedged loop beyond the log. util is a leaf, so the
131
+ * engine registers this (it raises the operator alert and knows what is
132
+ * queued); the watchdog only says when it starts and when it ends.
133
+ */
134
+ export type StuckLoopListener = {
135
+ onStuck(info: { pendingMins: number; silentMins: number }): void;
136
+ onRecovered(): void;
137
+ };
138
+ let stuckListener: StuckLoopListener | null = null;
139
+ let stuckReported = false;
140
+
141
+ export function setStuckLoopListener(listener: StuckLoopListener | null): void {
142
+ stuckListener = listener;
143
+ }
144
+
129
145
  function resetStuckWarnBackoff(): void {
130
146
  stuckWarnBackoffMs = STUCK_WARN_MS;
131
147
  nextStuckWarnAt = 0;
148
+ if (stuckReported) {
149
+ stuckReported = false;
150
+ try {
151
+ stuckListener?.onRecovered();
152
+ } catch {
153
+ /* a listener fault must not break turn bookkeeping */
154
+ }
155
+ }
132
156
  }
133
157
 
134
158
  export function startWatchdog(workspaceDir?: string): void {
@@ -150,6 +174,12 @@ export function startWatchdog(workspaceDir?: string): void {
150
174
  "watchdog",
151
175
  `Message received ${pendingMins} minutes ago is still unprocessed with no turn activity for ${silentMins} minutes — the message loop may be wedged`,
152
176
  );
177
+ stuckReported = true;
178
+ try {
179
+ stuckListener?.onStuck({ pendingMins, silentMins });
180
+ } catch (err) {
181
+ logWarn("watchdog", `stuck-loop listener failed: ${String(err)}`);
182
+ }
153
183
  nextStuckWarnAt = now + stuckWarnBackoffMs;
154
184
  stuckWarnBackoffMs = Math.min(
155
185
  stuckWarnBackoffMs * 2,