talon-agent 5.18.2 → 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 (190) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  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/stream.ts +2 -2
  8. package/src/backend/codex/auth.ts +1 -1
  9. package/src/backend/codex/handler/message.ts +11 -11
  10. package/src/backend/codex/init.ts +4 -9
  11. package/src/backend/codex/mcp-config.ts +1 -2
  12. package/src/backend/codex/oauth-incompat.ts +8 -4
  13. package/src/backend/codex/one-shot.ts +1 -1
  14. package/src/backend/openai-agents/builtins.ts +55 -27
  15. package/src/backend/openai-agents/factory.ts +3 -3
  16. package/src/backend/openai-agents/handler/message.ts +3 -5
  17. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  18. package/src/backend/remote-server/chat-turn.ts +6 -6
  19. package/src/backend/remote-server/events.ts +1 -5
  20. package/src/backend/remote-server/index.ts +0 -1
  21. package/src/backend/remote-server/messages.ts +3 -7
  22. package/src/backend/remote-server/one-shot.ts +1 -3
  23. package/src/backend/remote-server/session-helpers.ts +1 -4
  24. package/src/backend/remote-server/sse-stream.ts +8 -9
  25. package/src/backend/runtime/metrics.ts +7 -13
  26. package/src/backend/runtime/sleep.ts +1 -2
  27. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  28. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  29. package/src/bootstrap.ts +9 -1
  30. package/src/cli/doctor.ts +3 -0
  31. package/src/cli/index.ts +8 -10
  32. package/src/cli/logs.ts +148 -9
  33. package/src/cli/setup.ts +9 -11
  34. package/src/cli/status.ts +29 -0
  35. package/src/core/agent-runtime/README.md +5 -19
  36. package/src/core/agent-runtime/events.ts +3 -39
  37. package/src/core/agent-runtime/model-ref.ts +0 -8
  38. package/src/core/agents/registry.ts +49 -2
  39. package/src/core/auth/expiry-monitor.ts +9 -1
  40. package/src/core/auth/login-flow.ts +9 -1
  41. package/src/core/auth/status.ts +31 -3
  42. package/src/core/background/cron/scheduler.ts +25 -5
  43. package/src/core/background/dream/index.ts +29 -10
  44. package/src/core/background/failure-backoff.ts +30 -0
  45. package/src/core/background/heartbeat/agent.ts +2 -27
  46. package/src/core/background/heartbeat/index.ts +0 -2
  47. package/src/core/background/heartbeat/scheduler.ts +21 -13
  48. package/src/core/background/heartbeat/state.ts +10 -2
  49. package/src/core/background/isolated-agent.ts +6 -2
  50. package/src/core/background/pulse/pulse.ts +9 -0
  51. package/src/core/background/triggers/exit.ts +54 -0
  52. package/src/core/background/triggers/index.ts +1 -3
  53. package/src/core/background/triggers/resume.ts +2 -4
  54. package/src/core/backup/archive/tar.ts +14 -4
  55. package/src/core/backup/restore.ts +21 -13
  56. package/src/core/backup/scheduler.ts +28 -9
  57. package/src/core/backup/snapshot.ts +43 -10
  58. package/src/core/backup/store.ts +7 -19
  59. package/src/core/backup/targets.ts +69 -17
  60. package/src/core/config/index.ts +14 -0
  61. package/src/core/daemon/crash-marker.ts +141 -0
  62. package/src/core/daemon/crash.ts +9 -2
  63. package/src/core/daemon/handoff.ts +15 -0
  64. package/src/core/daemon/health-alerts.ts +297 -0
  65. package/src/core/daemon/log-reader.ts +289 -0
  66. package/src/core/doctor/index.ts +18 -2
  67. package/src/core/doctor/logs.ts +124 -0
  68. package/src/core/doctor/types.ts +1 -1
  69. package/src/core/engine/backend-controller/index.ts +1 -13
  70. package/src/core/engine/backend-router/router.ts +1 -1
  71. package/src/core/engine/dispatcher.ts +55 -2
  72. package/src/core/engine/fault-text.ts +40 -0
  73. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  74. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  75. package/src/core/engine/gateway-actions/history.ts +2 -4
  76. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  77. package/src/core/engine/gateway.ts +60 -1
  78. package/src/core/engine/turn-health.ts +222 -0
  79. package/src/core/errors.ts +2 -2
  80. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  81. package/src/core/frontend-runtime/alerts.ts +130 -0
  82. package/src/core/mcp-hub/children.ts +78 -29
  83. package/src/core/mcp-hub/index.ts +21 -18
  84. package/src/core/mcp-hub/proxy-server.ts +8 -4
  85. package/src/core/mcp-hub/talon-server.ts +5 -12
  86. package/src/core/mesh/credentials/store.ts +16 -1
  87. package/src/core/mesh/devices/service.ts +26 -15
  88. package/src/core/mesh/devices/teleport.ts +14 -2
  89. package/src/core/mesh/links/node-binaries.ts +13 -6
  90. package/src/core/mesh/persist.ts +22 -10
  91. package/src/core/mesh/transfers/device-files.ts +5 -23
  92. package/src/core/mesh/transfers/transfers.ts +16 -3
  93. package/src/core/models/active-model.ts +2 -55
  94. package/src/core/plugin/actions.ts +19 -20
  95. package/src/core/plugin/builtins.ts +80 -90
  96. package/src/core/plugin/index.ts +1 -4
  97. package/src/core/plugin/loader.ts +25 -33
  98. package/src/core/plugin/mcp.ts +3 -5
  99. package/src/core/plugin/registry.ts +19 -35
  100. package/src/core/plugin/types.ts +2 -5
  101. package/src/core/prompt/assemble.ts +15 -3
  102. package/src/core/scripts/lua.ts +6 -2
  103. package/src/core/tasks/table.ts +8 -2
  104. package/src/core/tools/bridge.ts +2 -4
  105. package/src/core/tools/chat/cross-send.ts +1 -1
  106. package/src/core/tools/chat/messaging.ts +1 -1
  107. package/src/core/tools/index.ts +2 -2
  108. package/src/core/tools/mcp-env.ts +2 -59
  109. package/src/core/tools/ops/agents.ts +22 -1
  110. package/src/core/tools/schemas.ts +4 -9
  111. package/src/core/vfs/fusefs.ts +0 -5
  112. package/src/core/vfs/index.ts +9 -2
  113. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  114. package/src/core/vfs/mounts/proc.ts +17 -1
  115. package/src/core/vfs/workspace.ts +7 -3
  116. package/src/core/weaver/shuttle.ts +8 -1
  117. package/src/core/weaver/turn-log.ts +320 -0
  118. package/src/core/weaver/weaver.ts +48 -5
  119. package/src/frontend/discord/actions/index.ts +8 -1
  120. package/src/frontend/discord/diagnostics.ts +82 -6
  121. package/src/frontend/discord/handlers/index.ts +0 -2
  122. package/src/frontend/discord/middleware.ts +7 -13
  123. package/src/frontend/discord/runtime.ts +1 -3
  124. package/src/frontend/health/delivery.ts +115 -0
  125. package/src/frontend/health/outage.ts +116 -0
  126. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  127. package/src/frontend/native/bridge/server.ts +106 -21
  128. package/src/frontend/native/index.ts +1 -1
  129. package/src/frontend/native/media/media.ts +5 -1
  130. package/src/frontend/native/runtime.ts +12 -7
  131. package/src/frontend/native/surface/handlers.ts +1 -1
  132. package/src/frontend/native/surface/memory.ts +1 -1
  133. package/src/frontend/native/surface/models.ts +3 -3
  134. package/src/frontend/native/surface/settings.ts +20 -8
  135. package/src/frontend/native/turn/context.ts +6 -8
  136. package/src/frontend/native/turn/turn-meta.ts +2 -5
  137. package/src/frontend/native/turn/turn.ts +8 -10
  138. package/src/frontend/presentation/format.ts +2 -4
  139. package/src/frontend/presentation/session-status.ts +2 -6
  140. package/src/frontend/teams/actions.ts +8 -1
  141. package/src/frontend/teams/graph.ts +0 -1
  142. package/src/frontend/teams/index.ts +1 -4
  143. package/src/frontend/teams/poll.ts +40 -2
  144. package/src/frontend/teams/runtime.ts +14 -5
  145. package/src/frontend/telegram/actions/index.ts +4 -1
  146. package/src/frontend/telegram/actions/send.ts +8 -0
  147. package/src/frontend/telegram/handlers/context.ts +13 -2
  148. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  149. package/src/frontend/telegram/handlers/index.ts +0 -2
  150. package/src/frontend/telegram/index.ts +35 -9
  151. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  152. package/src/frontend/telegram/userbot.ts +100 -36
  153. package/src/frontend/terminal/builtins/session.ts +2 -2
  154. package/src/frontend/terminal/index.ts +1 -3
  155. package/src/frontend/terminal/renderer.ts +2 -18
  156. package/src/frontend/whatsapp/actions/index.ts +12 -1
  157. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  158. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  159. package/src/frontend/whatsapp/connection/health.ts +89 -0
  160. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  161. package/src/frontend/whatsapp/runtime.ts +11 -5
  162. package/src/native/blake3.ts +28 -2
  163. package/src/native/fusefs.ts +23 -5
  164. package/src/native/registry.ts +1 -1
  165. package/src/native/warden.ts +33 -5
  166. package/src/plugins/github/index.ts +0 -1
  167. package/src/plugins/mempalace/index.ts +9 -3
  168. package/src/plugins/playwright/index.ts +2 -4
  169. package/src/plugins/playwright/provision.ts +12 -4
  170. package/src/storage/chat-settings.ts +6 -1
  171. package/src/storage/cron.ts +29 -4
  172. package/src/storage/daily-log.ts +43 -47
  173. package/src/storage/db.ts +61 -33
  174. package/src/storage/history.ts +6 -1
  175. package/src/storage/journal.ts +9 -2
  176. package/src/storage/kv.ts +19 -6
  177. package/src/storage/media-index.ts +28 -6
  178. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  179. package/src/storage/repositories/sessions-repo.ts +10 -3
  180. package/src/storage/scripts.ts +24 -13
  181. package/src/storage/sessions.ts +11 -2
  182. package/src/storage/skills.ts +21 -2
  183. package/src/storage/stickers.ts +17 -3
  184. package/src/storage/triggers.ts +8 -3
  185. package/src/storage/turn-meta.ts +25 -7
  186. package/src/util/log.ts +189 -6
  187. package/src/util/logging/turn-scope.ts +85 -0
  188. package/src/util/time.ts +3 -3
  189. package/src/util/watchdog.ts +30 -0
  190. package/src/core/engine/backend-controller/legacy.ts +0 -111
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,
@@ -1,111 +0,0 @@
1
- /**
2
- * Legacy single-active aliases (chat role).
3
- *
4
- * Preserved so consumers that pre-date the pool refactor stay green.
5
- * All route to the chat role — the canonical "default" backend.
6
- */
7
-
8
- import type { Backend } from "../../agent-runtime/capabilities.js";
9
- import type { TalonConfig } from "../../config/index.js";
10
- import { type BackendInitContext } from "../../agent-runtime/backend-registry.js";
11
- import { log } from "../../../util/log.js";
12
- import { roleHolder } from "./holders.js";
13
- import { pool, bindings, ctx, ensurePoolEntry } from "./state.js";
14
- import {
15
- getBackendForRole,
16
- getBackendIdForRole,
17
- getBackendLabelForRole,
18
- cleanupBackendPool,
19
- resetBackendPoolForTest,
20
- } from "./pool.js";
21
- import { rebindRole } from "./rebind.js";
22
- import type { RebindResult } from "./types.js";
23
-
24
- /**
25
- * Initialise the controller with a single backend bound to the chat
26
- * role. Convenience for tests; production should use `initBackendPool`.
27
- */
28
- export async function initBackendController(
29
- id: string,
30
- config: TalonConfig,
31
- initContext: BackendInitContext,
32
- ): Promise<Backend> {
33
- ctx.initCtx = initContext;
34
- ctx.poolConfig = config;
35
- const entry = await ensurePoolEntry(id, config);
36
- const holder = roleHolder("chat");
37
- entry.holders.add(holder);
38
- bindings.set(holder, id);
39
- log("backend-controller", `Active backend (chat): ${entry.label} (${id})`);
40
- return entry.backend;
41
- }
42
-
43
- /** Equivalent to `getBackendForRole("chat")`. Legacy alias. */
44
- export function getActiveBackend(): Backend {
45
- if (!bindings.has(roleHolder("chat"))) {
46
- throw new Error(
47
- "Backend controller not initialised — call initBackendController first",
48
- );
49
- }
50
- return getBackendForRole("chat");
51
- }
52
-
53
- /** Whether the chat role has been bound. Legacy alias. */
54
- export function hasActiveBackend(): boolean {
55
- return bindings.has(roleHolder("chat"));
56
- }
57
-
58
- /** Like `getActiveBackend` but returns `null` instead of throwing. */
59
- export function getActiveBackendOrNull(): Backend | null {
60
- if (!bindings.has(roleHolder("chat"))) return null;
61
- try {
62
- return getBackendForRole("chat");
63
- } catch {
64
- return null;
65
- }
66
- }
67
-
68
- /** Id of the chat-role backend. Legacy alias. */
69
- export function getActiveBackendId(): string {
70
- if (!bindings.has(roleHolder("chat"))) {
71
- throw new Error("Backend controller not initialised");
72
- }
73
- return getBackendIdForRole("chat");
74
- }
75
-
76
- /** Label of the chat-role backend. Legacy alias. */
77
- export function getActiveBackendLabel(): string {
78
- if (!bindings.has(roleHolder("chat"))) {
79
- throw new Error("Backend controller not initialised");
80
- }
81
- return getBackendLabelForRole("chat");
82
- }
83
-
84
- /** Rebind the chat role. Legacy alias for `rebindRole("chat", id, config)`. */
85
- export async function switchBackend(
86
- id: string,
87
- config: TalonConfig,
88
- ): Promise<RebindResult> {
89
- if (!bindings.has(roleHolder("chat"))) {
90
- return { ok: false, error: "Backend controller not initialised" };
91
- }
92
- const current = bindings.get(roleHolder("chat"));
93
- if (current === id) {
94
- const entry = pool.get(current);
95
- return {
96
- ok: false,
97
- error: `Already on ${entry?.label ?? current}`,
98
- };
99
- }
100
- return rebindRole("chat", id, config);
101
- }
102
-
103
- /** Tear down the entire pool. Legacy alias for `cleanupBackendPool`. */
104
- export async function cleanupBackendController(): Promise<void> {
105
- await cleanupBackendPool();
106
- }
107
-
108
- /** Reset state. Test-only. Legacy alias. */
109
- export function resetBackendControllerForTest(): void {
110
- resetBackendPoolForTest();
111
- }