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
@@ -22,9 +22,14 @@ import {
22
22
  } from "node:http";
23
23
  import { createServer as createTlsServer } from "node:https";
24
24
  import { createHash, timingSafeEqual } from "node:crypto";
25
- import { createReadStream } from "node:fs";
25
+ import { createReadStream, type ReadStream } from "node:fs";
26
26
  import { stat } from "node:fs/promises";
27
- import { log, logError, logDebug } from "../../../util/log.js";
27
+ import { log, logError, logDebug, logWarn } from "../../../util/log.js";
28
+ import {
29
+ raiseAlert,
30
+ resolveAlert,
31
+ } from "../../../core/frontend-runtime/alerts.js";
32
+ import { errorText } from "../../health/outage.js";
28
33
  import {
29
34
  formatFingerprint,
30
35
  isLoopbackHost,
@@ -63,6 +68,14 @@ type StreamSession = {
63
68
  };
64
69
 
65
70
  const SSE_PING_MS = 25_000;
71
+ /**
72
+ * Unsent bytes a stream may hold before it counts as dead. A client that
73
+ * stops reading without closing (phone asleep, network switch) otherwise
74
+ * buffers every broadcast in memory until TCP gives up on it, minutes later.
75
+ * Far past anything a live client falls behind by; evicted, it reconnects
76
+ * and gets a fresh `hello`.
77
+ */
78
+ const SSE_MAX_BACKLOG_BYTES = 16 * 1024 * 1024;
66
79
  const MAX_BODY_BYTES = 256 * 1024;
67
80
  const PORT_FALLBACKS = 5;
68
81
 
@@ -94,6 +107,15 @@ export const DEFAULT_BRIDGE_TIMEOUTS: BridgeTimeouts = {
94
107
  checkIntervalMs: 30_000,
95
108
  };
96
109
 
110
+ /**
111
+ * `pipe` never closes its source when the destination goes away, so a
112
+ * client that hangs up mid-download (app backgrounded, image scrolled
113
+ * away) would leave the paused read stream holding its fd forever.
114
+ */
115
+ function releaseOnClose(res: ServerResponse, stream: ReadStream): void {
116
+ res.once("close", () => stream.destroy());
117
+ }
118
+
97
119
  export class BridgeServer {
98
120
  private server: Server | null = null;
99
121
  /**
@@ -225,14 +247,14 @@ export class BridgeServer {
225
247
  *
226
248
  * Device commands are not public: their params carry one-time transfer
227
249
  * tokens, exec command lines, remote paths, and — on the chunked fallback —
228
- * whole base64 file bodies. Broadcasting them handed every connected client
229
- * another device's secrets and relied on each client discarding what wasn't
230
- * addressed to it, which is courtesy, not enforcement.
250
+ * whole base64 file bodies. Broadcasting them would hand every connected
251
+ * client another device's secrets and rely on each client discarding what
252
+ * isn't addressed to it, which is courtesy, not enforcement.
231
253
  *
232
254
  * A claim is an ADDRESS, not a credential: any client holding the bridge
233
255
  * token could claim any id, and the bridge token is (still) the only trust
234
- * boundary here. What this buys is that a device no longer passively
235
- * receives traffic meant for its peers.
256
+ * boundary here. What this buys is that a device does not passively
257
+ * receive traffic meant for its peers.
236
258
  *
237
259
  * Clients that claimed nothing are the fallback audience, and only when the
238
260
  * target claimed nothing either: a companion build that predates the claim
@@ -265,12 +287,24 @@ export class BridgeServer {
265
287
 
266
288
  private write(targets: Iterable<ServerResponse>, event: BridgeEvent): void {
267
289
  const payload = `data: ${JSON.stringify(event)}\n\n`;
268
- for (const res of targets) {
269
- try {
270
- res.write(payload);
271
- } catch {
272
- // Write on a half-closed socket — the 'close' handler will evict it.
273
- }
290
+ for (const res of targets) this.send(res, payload);
291
+ }
292
+
293
+ /** Write one frame to a stream, evicting it if its backlog never drains. */
294
+ private send(res: ServerResponse, frame: string): void {
295
+ if (res.writableLength > SSE_MAX_BACKLOG_BYTES) {
296
+ this.clients.delete(res);
297
+ logWarn(
298
+ "native",
299
+ `Dropped an SSE client that stopped reading (${res.writableLength} bytes unsent)`,
300
+ );
301
+ res.destroy();
302
+ return;
303
+ }
304
+ try {
305
+ res.write(frame);
306
+ } catch {
307
+ // Write on a half-closed socket — the 'close' handler will evict it.
274
308
  }
275
309
  }
276
310
 
@@ -281,7 +315,7 @@ export class BridgeServer {
281
315
  loopback: isLoopbackHost(this.opts.host),
282
316
  allowWeakToken: this.opts.allowWeakToken,
283
317
  });
284
- this.tlsIdentity = this.opts.tls ? await this.opts.tls() : null;
318
+ this.tlsIdentity = this.opts.tls ? await this.loadTls(this.opts.tls) : null;
285
319
  const onRequest = (req: IncomingMessage, res: ServerResponse): void => {
286
320
  this.handle(req, res).catch((err) => {
287
321
  logError("native", "Bridge request handler threw", err);
@@ -312,29 +346,70 @@ export class BridgeServer {
312
346
  : createServer(serverOpts, onRequest);
313
347
 
314
348
  this.pingTimer = setInterval(() => {
315
- for (const res of this.clients.keys()) {
316
- try {
317
- res.write(": ping\n\n");
318
- } catch {
319
- /* evicted on close */
320
- }
321
- }
349
+ for (const res of this.clients.keys()) this.send(res, ": ping\n\n");
322
350
  }, SSE_PING_MS);
323
351
  this.pingTimer.unref?.();
324
352
  this.unsubscribeRevocations = this.opts.credentials?.authority.onRevoked(
325
353
  (ids) => this.dropCredentialSessions(ids),
326
354
  );
327
355
 
356
+ return this.bind(server);
357
+ }
358
+
359
+ /**
360
+ * The TLS identity, or a thrown boot failure the operator hears about:
361
+ * without it no companion app can connect.
362
+ */
363
+ private async loadTls(
364
+ load: () => Promise<BridgeTlsIdentity>,
365
+ ): Promise<BridgeTlsIdentity> {
366
+ try {
367
+ const identity = await load();
368
+ resolveAlert(
369
+ "bridge.tls",
370
+ "The client bridge TLS certificate loads again.",
371
+ );
372
+ return identity;
373
+ } catch (err) {
374
+ logError("native", `bridge.tls.fail err=${errorText(err)}`, err);
375
+ raiseAlert(
376
+ "bridge.tls",
377
+ `The client bridge could not load its TLS certificate: ${errorText(err)}. Companion apps cannot connect.`,
378
+ { severity: "critical" },
379
+ );
380
+ throw err;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Listen on the configured port, stepping up to PORT_FALLBACKS ports past
386
+ * it when one is taken. A bind that fails for good raises `bridge.listen`
387
+ * — the frontend has no surface at all without it.
388
+ */
389
+ private bind(server: Server): Promise<number> {
328
390
  return new Promise<number>((resolve, reject) => {
329
391
  let attempt = 0;
330
392
  const tryPort = (p: number): void => {
331
393
  server.once("error", (err: NodeJS.ErrnoException) => {
332
394
  if (err.code === "EADDRINUSE" && attempt < PORT_FALLBACKS) {
333
395
  attempt++;
396
+ logWarn(
397
+ "native",
398
+ `bridge.listen port=${p} in use — trying port=${p + 1} attempt=${attempt}/${PORT_FALLBACKS}`,
399
+ );
334
400
  server.removeAllListeners("error");
335
401
  server.removeAllListeners("listening");
336
402
  tryPort(p + 1);
337
403
  } else {
404
+ logError(
405
+ "native",
406
+ `bridge.listen.fail host=${this.opts.host} port=${p} attempt=${attempt} err=${errorText(err)}`,
407
+ );
408
+ raiseAlert(
409
+ "bridge.listen",
410
+ `The client bridge could not listen on ${this.opts.host}:${p}: ${errorText(err)}. Companion apps cannot connect.`,
411
+ { severity: "critical" },
412
+ );
338
413
  reject(err);
339
414
  }
340
415
  });
@@ -362,6 +437,10 @@ export class BridgeServer {
362
437
  `Bridge certificate fingerprint ${formatFingerprint(this.tlsIdentity.fingerprint)}`,
363
438
  );
364
439
  }
440
+ resolveAlert(
441
+ "bridge.listen",
442
+ "The client bridge is listening again.",
443
+ );
365
444
  resolve(this.port);
366
445
  });
367
446
  };
@@ -507,6 +586,7 @@ export class BridgeServer {
507
586
  });
508
587
  const stream = createReadStream(file.path);
509
588
  stream.on("error", () => res.destroy());
589
+ releaseOnClose(res, stream);
510
590
  stream.pipe(res);
511
591
  }
512
592
 
@@ -532,6 +612,7 @@ export class BridgeServer {
532
612
  if (!res.headersSent) res.writeHead(500);
533
613
  res.end();
534
614
  });
615
+ releaseOnClose(res, stream);
535
616
  stream.pipe(res);
536
617
  } catch {
537
618
  return this.json(res, 404, { ok: false, error: "No such media" });
@@ -599,6 +680,10 @@ export class BridgeServer {
599
680
  const ms = Math.round(max * (0.9 + Math.random() * 0.2));
600
681
  const timer = setTimeout(() => {
601
682
  logDebug("native", "bridge.sse event=max_lifetime reason=expired");
683
+ // Out of the fan-out before end(): a stream still flushing a backlog
684
+ // stays open until it drains, and a broadcast in that window is a
685
+ // write after end — an unhandled 'error' that takes the daemon down.
686
+ this.clients.delete(res);
602
687
  res.end();
603
688
  }, ms);
604
689
  timer.unref?.();
@@ -191,7 +191,7 @@ export function createNativeFrontend(
191
191
  await mesh.load();
192
192
  unregisterMeshTransport = registerMeshTransport(runtime, server);
193
193
  // Mesh tool actions (list_devices / get_device_location) are shared
194
- // gateway actions now — no native-only cases here.
194
+ // gateway actions — no native-only cases here.
195
195
  gateway.registerFrontendHandler(
196
196
  "native",
197
197
  createNativeActionHandler({
@@ -20,13 +20,17 @@ import type { NativeRuntime } from "../runtime.js";
20
20
  */
21
21
  export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
22
22
 
23
- /** Register a file for serving and return its short media id. */
23
+ /** Register a file for serving and return its short media id (stable per
24
+ * path for the life of the daemon). */
24
25
  export function registerMedia(
25
26
  runtime: NativeRuntime,
26
27
  filePath: string,
27
28
  ): string {
29
+ const known = runtime.mediaIds.get(filePath);
30
+ if (known) return known;
28
31
  const id = `m${runtime.nextId().toString(36)}`;
29
32
  runtime.media.set(id, filePath);
33
+ runtime.mediaIds.set(filePath, id);
30
34
  return id;
31
35
  }
32
36
 
@@ -1,11 +1,9 @@
1
1
  /**
2
- * Native frontend runtime — the state every bridge module shares.
3
- *
4
- * `createNativeFrontend` used to hold all of this as closure variables with
5
- * every handler nested inside it. It is now one explicit object, constructed
6
- * once, that each module (chat-wire, context, emit, turn, …) takes as its
7
- * first parameter. The runtime carries state plus two primitives — the
8
- * message-id minter and the broadcast sink; the modules own the behaviour.
2
+ * Native frontend runtime — the state every bridge module shares: one
3
+ * object, constructed once, that each module (chat-wire, context, emit,
4
+ * turn, …) takes as its first parameter. It carries state plus two
5
+ * primitives — the message-id minter and the broadcast sink; the modules
6
+ * own the behaviour.
9
7
  */
10
8
 
11
9
  import type { TalonConfig } from "../../core/config/index.js";
@@ -55,6 +53,12 @@ export type NativeRuntime = {
55
53
  * frontends behave).
56
54
  */
57
55
  readonly media: Map<string, string>;
56
+ /**
57
+ * `media` reversed (path → id). Every history page re-registers its
58
+ * attachments, so without this each fetch — and every client reconnect
59
+ * re-fetches — grew `media` by one entry per attachment, forever.
60
+ */
61
+ readonly mediaIds: Map<string, string>;
58
62
  /**
59
63
  * Uploads this daemon run has accepted, keyed by their media id. `/send`
60
64
  * resolves a client's attachment references through here rather than
@@ -110,6 +114,7 @@ export function createNativeRuntime(
110
114
  chats: new NativeChats(),
111
115
  mesh: getMeshService(),
112
116
  media: new Map(),
117
+ mediaIds: new Map(),
113
118
  uploads: new Map(),
114
119
  contextByChat: new Map(),
115
120
  liveTurns: new Map(),
@@ -79,7 +79,7 @@ function resolveAttachments(
79
79
  }
80
80
  }
81
81
  // A dropped reference is how "I attached a file and the agent never saw it"
82
- // happens, and it used to be entirely silent. The usual cause is a media id
82
+ // happens, so it must not be silent. The usual cause is a media id
83
83
  // minted by a previous daemon run — `runtime.uploads` is per-run, so a
84
84
  // client that staged a file before a restart and sent it after points at
85
85
  // nothing.
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * Read-only on purpose: a bridge client can ask what Talon remembers and
7
7
  * why, and nothing more. Asserting, superseding and dropping stay with
8
- * the write path (rollout PR 6), so a paired phone can never quietly
8
+ * the write path, so a paired phone can never quietly
9
9
  * rewrite the operator's memory.
10
10
  *
11
11
  * The one piece of policy here is the limit cap: whatever a client asks
@@ -35,9 +35,9 @@ export async function listModels(
35
35
  ): Promise<{ active: string; models: ModelOption[] }> {
36
36
  const { config } = runtime;
37
37
  // Resolve the chat's *own* backend so the model list tracks whatever
38
- // backend the chat is currently bound to (fixes the list staying on the
39
- // previous backend's models after a switch). Fall back to the global
40
- // default backend when there's no chat / the pool isn't ready yet.
38
+ // backend the chat is currently bound to, not the one before a switch.
39
+ // Fall back to the global default backend when there's no chat / the
40
+ // pool isn't ready yet.
41
41
  let backendId: string = config.backend;
42
42
  let active = config.model;
43
43
  if (chatId) {
@@ -186,19 +186,31 @@ export function applyConfigUpdate(
186
186
  * Merge a partial update into talon.json on disk, preserving everything
187
187
  * else. Shared by every bridge surface that persists config changes
188
188
  * (settings sync here, plugin toggles in extensions.ts).
189
+ *
190
+ * An existing file that can't be read or isn't a JSON object throws and
191
+ * is left untouched: merging into `{}` would replace the operator's whole
192
+ * config (tokens included) with just this patch.
189
193
  */
190
194
  export function persistConfigPatch(update: Record<string, unknown>): void {
191
195
  const file = pathFiles.config;
192
196
  let current: Record<string, unknown> = {};
193
- try {
194
- if (existsSync(file)) {
195
- current = JSON.parse(readFileSync(file, "utf-8")) as Record<
196
- string,
197
- unknown
198
- >;
197
+ if (existsSync(file)) {
198
+ let parsed: unknown;
199
+ try {
200
+ parsed = JSON.parse(readFileSync(file, "utf-8"));
201
+ } catch (err) {
202
+ throw new Error(
203
+ `Refusing to write ${file}: cannot read the existing file ` +
204
+ `(${err instanceof Error ? err.message : err}). It was left untouched.`,
205
+ );
206
+ }
207
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
208
+ throw new Error(
209
+ `Refusing to write ${file}: the top level is not a JSON object. ` +
210
+ `It was left untouched.`,
211
+ );
199
212
  }
200
- } catch {
201
- /* corrupt/absent — start from empty */
213
+ current = parsed as Record<string, unknown>;
202
214
  }
203
215
  for (const [k, v] of Object.entries(update)) {
204
216
  if (v === undefined) delete current[k];
@@ -88,14 +88,12 @@ export async function refreshContext(
88
88
  /**
89
89
  * Fill the context cache for chats restored at startup.
90
90
  *
91
- * The readout is served from an in-memory map that was only ever written at
92
- * turn end, so after a restart every existing chat reported no context at
93
- * all — the header chip vanished until that chat ran another turn, which
94
- * read as "context usage doesn't save". The numbers themselves are
95
- * persisted with the session; only the cache was cold. This re-reads them
96
- * for the most recently active chats (bounded, and on the cheap path that
97
- * never touches the backend pool); anything it skips or can't resolve is
98
- * filled in the moment the chat is opened.
91
+ * The readout is served from an in-memory map otherwise written only at
92
+ * turn end, so after a restart every chat would show no context until its
93
+ * next turn, though the numbers are persisted with the session. This
94
+ * re-reads them for the most recently active chats (bounded, and on the
95
+ * cheap path that never touches the backend pool); anything it skips or
96
+ * can't resolve is filled in the moment the chat is opened.
99
97
  */
100
98
  const CONTEXT_WARM_LIMIT = 40;
101
99
 
@@ -2,11 +2,8 @@
2
2
  * Turn-meta sidecar — thin native-frontend façade over the shared
3
3
  * storage/turn-meta store. It owns the TurnMeta *shape* (tool calls,
4
4
  * duration, token usage — the companion app's tool timeline and stats
5
- * footer); the storage layer treats each record as opaque JSON.
6
- *
7
- * The data used to live in a debounced JSON file under the data dir;
8
- * it now rides the `turn_meta` SQLite table shared by every frontend.
9
- * SQLite commits on every write, so the old flush timer is gone.
5
+ * footer); the storage layer treats each record as opaque JSON in the
6
+ * `turn_meta` SQLite table shared by every frontend.
10
7
  */
11
8
 
12
9
  import {
@@ -102,10 +102,8 @@ export function liveTurnEvents(runtime: NativeRuntime): BridgeEvent[] {
102
102
 
103
103
  /**
104
104
  * Safety net: any tool the backend announced but never resolved (a crash can
105
- * eat a tool_result; callback backends historically never emitted one —
106
- * handler-to-events now pairs each tool_call with an immediate synthetic
107
- * result) gets a synthetic result at turn end — a spinner the app opened on
108
- * phase:"call" must always see a phase:"result".
105
+ * eat a tool_result) gets a synthetic result at turn end — a spinner the app
106
+ * opened on phase:"call" must always see a phase:"result".
109
107
  */
110
108
  function flushOpenTools(
111
109
  runtime: NativeRuntime,
@@ -312,14 +310,14 @@ async function runTurn(
312
310
  // clients. Any late tool spinner has already been flushed above.
313
311
  runtime.liveTurns.delete(entry.id);
314
312
  // Flush a queued follow-up as a fresh turn: "once it's done it will
315
- // send". Deferred so we don't re-enter runTurn inside its own finally.
313
+ // send". Started synchronously so the chat never looks idle in between —
314
+ // a /send landing in a deferred gap would start its own turn and run
315
+ // ahead of the message that was queued first.
316
316
  const queued = takeQueued(runtime, entry);
317
317
  if (queued) {
318
- setImmediate(() =>
319
- startTurn(runtime, entry, queued.text, {
320
- attachments: queued.attachments,
321
- }),
322
- );
318
+ startTurn(runtime, entry, queued.text, {
319
+ attachments: queued.attachments,
320
+ });
323
321
  }
324
322
  }
325
323
  }
@@ -1,10 +1,8 @@
1
1
  /**
2
2
  * Frontend-agnostic display formatters shared by all chat frontends.
3
3
  *
4
- * These were previously duplicated per-frontend (the old
5
- * telegram/helpers/format.ts, discord/helpers.ts) and had started to drift —
6
- * parseInterval accepted days on Discord but not Telegram. One definition
7
- * here keeps them in lockstep; frontends import this module directly.
4
+ * One definition here keeps the frontends in lockstep — per-frontend copies
5
+ * drift (parseInterval once accepted days on Discord but not Telegram).
8
6
  */
9
7
 
10
8
  import { resolveModel } from "../../core/models/catalog.js";
@@ -1,12 +1,8 @@
1
1
  /**
2
2
  * Shared /reset and /status logic for chat frontends.
3
3
  *
4
- * Telegram and Discord previously each carried a full copy of the session
5
- * reset sequence and the /status data-gathering pipeline, and the copies had
6
- * drifted (Discord's /status no longer re-fetched the context window for the
7
- * model that actually served the session). All the frontend-agnostic work
8
- * lives here now; frontends only render `SessionStatusData` in their native
9
- * markup.
4
+ * All the frontend-agnostic work lives here so per-frontend copies can't
5
+ * drift; frontends only render `SessionStatusData` in their native markup.
10
6
  */
11
7
 
12
8
  import type { TalonConfig } from "../../core/config/index.js";
@@ -7,6 +7,9 @@ import type { Gateway } from "../../core/engine/gateway.js";
7
7
  import { buildAdaptiveCard, splitTeamsMessage } from "./formatting.js";
8
8
  import { log, logError } from "../../util/log.js";
9
9
  import { proxyFetch } from "./proxy-fetch.js";
10
+ import { createDeliveryTracker, trackDeliveries } from "../health/delivery.js";
11
+
12
+ const delivery = createDeliveryTracker("teams", "Teams", "teams");
10
13
 
11
14
  /**
12
15
  * POST an Adaptive Card to the Power Automate workflow webhook URL.
@@ -32,7 +35,10 @@ export function createTeamsActionHandler(
32
35
  webhookUrl: string,
33
36
  gateway: Gateway,
34
37
  ): FrontendActionHandler {
35
- return async (body, chatId): Promise<ActionResult | null> => {
38
+ const dispatch: FrontendActionHandler = async (
39
+ body,
40
+ chatId,
41
+ ): Promise<ActionResult | null> => {
36
42
  const action = body.action as string;
37
43
 
38
44
  switch (action) {
@@ -100,6 +106,7 @@ export function createTeamsActionHandler(
100
106
  return null;
101
107
  }
102
108
  };
109
+ return trackDeliveries(delivery, dispatch);
103
110
  }
104
111
 
105
112
  export { postToTeams };
@@ -302,7 +302,6 @@ export class GraphClient {
302
302
  * Initialize the Graph client — loads stored tokens or runs device code flow.
303
303
  */
304
304
  export async function initGraphClient(): Promise<GraphClient> {
305
- // Clear old tokens that used channel scopes
306
305
  const stored = loadTokens();
307
306
 
308
307
  if (stored && stored.refreshToken) {
@@ -76,9 +76,7 @@ export function createTeamsFrontend(
76
76
  const graphClient = await initGraphClient();
77
77
  runtime.graphClient = graphClient;
78
78
 
79
- // Get our own user ID (to filter out our own messages)
80
79
  const me = await graphClient.getMe();
81
- runtime.myUserId = me.id;
82
80
  log("teams", `Authenticated as: ${me.displayName} (${me.id})`);
83
81
 
84
82
  const chatId = await resolveChatId(runtime, graphClient, me.id);
@@ -99,8 +97,7 @@ export function createTeamsFrontend(
99
97
 
100
98
  // The receive side is a timer on the runtime, not a loop to sit in:
101
99
  // once the first poll is done the frontend is listening, and
102
- // start() is finished. (It used to park on a promise that never
103
- // resolved, which made the boot end at shutdown.)
100
+ // start() is finished.
104
101
  await startPolling(runtime, chatId);
105
102
  },
106
103
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { log, logError } from "../../util/log.js";
9
+ import { errorText } from "../health/outage.js";
9
10
  import { handleSlashCommand } from "./commands.js";
10
11
  import type { ChatMessage } from "./graph.js";
11
12
  import type { TeamsRuntime } from "./runtime.js";
@@ -51,13 +52,47 @@ async function handleMessage(
51
52
  runTurn(runtime, msg, talonChatId);
52
53
  }
53
54
 
55
+ /**
56
+ * Fetch the newest messages, feeding the poll outage: failures are logged
57
+ * with their attempt number and time down, and the first success after
58
+ * them logs the recovery. Returns null when the fetch failed.
59
+ */
60
+ async function fetchMessages(
61
+ runtime: TeamsRuntime,
62
+ graph: NonNullable<TeamsRuntime["graphClient"]>,
63
+ chatId: string,
64
+ ): Promise<ChatMessage[] | null> {
65
+ let messages: ChatMessage[];
66
+ try {
67
+ messages = await graph.getChatMessages(chatId, 20);
68
+ } catch (err) {
69
+ const { attempt, downMs } = runtime.pollOutage.fail(err);
70
+ logError(
71
+ "teams",
72
+ `Poll error: poll.fail chat=${chatId} attempt=${attempt} down_ms=${downMs} ` +
73
+ `next_poll_ms=${runtime.pollIntervalMs} err=${errorText(err)}`,
74
+ );
75
+ return null;
76
+ }
77
+ const ended = runtime.pollOutage.ok();
78
+ if (ended) {
79
+ log(
80
+ "teams",
81
+ `poll.recovered chat=${chatId} failed_attempts=${ended.attempts} down_ms=${ended.downMs}`,
82
+ );
83
+ }
84
+ return messages;
85
+ }
86
+
54
87
  async function poll(runtime: TeamsRuntime, chatId: string): Promise<void> {
55
88
  if (runtime.polling) return;
56
89
  runtime.polling = true;
90
+ let current: ChatMessage | null = null;
57
91
 
58
92
  try {
59
93
  if (!runtime.graphClient) return;
60
- const messages = await runtime.graphClient.getChatMessages(chatId, 20);
94
+ const messages = await fetchMessages(runtime, runtime.graphClient, chatId);
95
+ if (!messages) return;
61
96
  const newMessages = selectNewMessages(messages, runtime.lastSeenMessageId);
62
97
 
63
98
  if (newMessages.length > 0) {
@@ -69,12 +104,14 @@ async function poll(runtime: TeamsRuntime, chatId: string): Promise<void> {
69
104
  if (!msg.text.trim()) continue;
70
105
  if (msg.edited) continue;
71
106
  if (isBotEcho(runtime, msg)) continue;
107
+ current = msg;
72
108
  await handleMessage(runtime, msg);
73
109
  }
74
110
  } catch (err) {
75
111
  logError(
76
112
  "teams",
77
- `Poll error: ${err instanceof Error ? err.message : err}`,
113
+ `Poll error: message=${current?.id ?? "?"} chat=${current?.chatId ?? chatId} ` +
114
+ `${err instanceof Error ? err.message : err}`,
78
115
  );
79
116
  } finally {
80
117
  runtime.polling = false;
@@ -94,6 +131,7 @@ export async function startPolling(
94
131
  }
95
132
 
96
133
  export function stopPolling(runtime: TeamsRuntime): void {
134
+ runtime.pollOutage.dispose();
97
135
  if (runtime.pollTimer) {
98
136
  clearInterval(runtime.pollTimer);
99
137
  runtime.pollTimer = null;
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Teams frontend runtime — the state every module shares.
3
3
  *
4
- * `createTeamsFrontend` used to hold all of this as closure variables, with
5
- * the poll loop and the slash commands nested inside `.start`. It is now one
6
- * explicit object, constructed once, that each module (chat-discovery,
4
+ * One explicit object, constructed once, that each module (chat-discovery,
7
5
  * poll, commands, turn, outbound) takes as its first parameter.
8
6
  */
9
7
 
10
8
  import type { TalonConfig } from "../../core/config/index.js";
11
9
  import type { Gateway } from "../../core/engine/gateway.js";
12
10
  import type { GraphClient } from "./graph.js";
11
+ import { createOutage, type Outage } from "../health/outage.js";
12
+
13
+ /** Graph polling must fail this long before `teams.poll` is raised. */
14
+ const POLL_OUTAGE_MS = 10 * 60_000;
13
15
 
14
16
  export type TeamsRuntime = {
15
17
  readonly config: TalonConfig;
@@ -25,9 +27,10 @@ export type TeamsRuntime = {
25
27
  pollTimer: ReturnType<typeof setInterval> | null;
26
28
  /** Newest message id already handled — the poll loop cuts at it. */
27
29
  lastSeenMessageId: string | null;
28
- myUserId: string | null;
29
30
  /** Re-entrancy guard: a slow poll never overlaps the next tick. */
30
31
  polling: boolean;
32
+ /** Failed Graph fetches, and the `teams.poll` alert they raise. */
33
+ readonly pollOutage: Outage;
31
34
  };
32
35
 
33
36
  export function createTeamsRuntime(
@@ -45,7 +48,13 @@ export function createTeamsRuntime(
45
48
  graphClient: null,
46
49
  pollTimer: null,
47
50
  lastSeenMessageId: null,
48
- myUserId: null,
49
51
  polling: false,
52
+ pollOutage: createOutage({
53
+ key: "teams.poll",
54
+ thresholdMs: POLL_OUTAGE_MS,
55
+ describe: (err, mins) =>
56
+ `Teams polling has failed for ${mins} min: ${err}. Messages are not being received.`,
57
+ recovered: "Teams polling is working again.",
58
+ }),
50
59
  };
51
60
  }