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
@@ -41,6 +41,19 @@ import type { Thread, ThreadSnapshot } from "./thread.js";
41
41
  import { startTurnCpu } from "./turn-cpu.js";
42
42
  import { startTypingLoop } from "./typing-loop.js";
43
43
  import { resolveWarp } from "./warp-resolver.js";
44
+ import {
45
+ createTurnTrace,
46
+ logTurnEnd,
47
+ logTurnFailure,
48
+ logTurnSettled,
49
+ logTurnStart,
50
+ type TurnTrace,
51
+ } from "./turn-log.js";
52
+ import {
53
+ closeTurnScope,
54
+ createTurnScope,
55
+ runInTurnScope,
56
+ } from "../../util/logging/turn-scope.js";
44
57
 
45
58
  export type WeaverDeps = {
46
59
  /**
@@ -84,6 +97,10 @@ export class Weaver {
84
97
  const chat = this.deps.getBackend(params.chatId).chat;
85
98
  const interrupt = chat?.interruptChatTurn?.bind(chat);
86
99
  const lifecycle = { started: false, killed: false, enqueuedAt: Date.now() };
100
+ // The turn id is minted at enqueue so a queued turn's wait is already
101
+ // attributable; the log scope goes live when the turn starts running.
102
+ const scope = createTurnScope(params.chatId);
103
+ const trace = createTurnTrace(scope.turnId, params, thread.inFlightCount);
87
104
  // Registered before enqueueing so a turn waiting in its chat's FIFO is
88
105
  // visible as `queued` in the task table, not invisible until it runs.
89
106
  const task = taskTable.enqueue({
@@ -99,7 +116,11 @@ export class Weaver {
99
116
  }
100
117
  : {}),
101
118
  });
102
- return thread.enqueue(() => this.run(thread, params, task, lifecycle));
119
+ return thread.enqueue(() =>
120
+ runInTurnScope(scope, () =>
121
+ this.run(thread, params, task, lifecycle, trace),
122
+ ).finally(() => closeTurnScope(scope)),
123
+ );
103
124
  }
104
125
 
105
126
  /** Number of turns currently running (not queued) across all chats. */
@@ -121,21 +142,28 @@ export class Weaver {
121
142
  params: ExecuteParams,
122
143
  task: TaskHandle,
123
144
  lifecycle: { started: boolean; killed: boolean; enqueuedAt: number },
145
+ trace: TurnTrace,
124
146
  ): Promise<ExecuteResult> {
125
147
  if (lifecycle.killed) {
126
148
  // Killed while queued — the turn never reaches the backend. The
127
149
  // caller still gets a resolved (empty) result; nothing is delivered
128
150
  // to the chat, which is the point of the kill.
129
151
  task.fail(new Error("killed while queued"));
152
+ logTurnEnd(trace, "aborted", { reason: "killed-while-queued" });
130
153
  return this.emptyResult("Turn killed before it started.", params);
131
154
  }
132
155
  lifecycle.started = true;
156
+ trace.startedAt = Date.now();
133
157
  this.activeCount++;
134
158
  task.start();
135
159
  try {
136
- const result = await this.executeInner(thread, params, task, {
137
- queueWait: Date.now() - lifecycle.enqueuedAt,
138
- });
160
+ const result = await this.executeInner(
161
+ thread,
162
+ params,
163
+ task,
164
+ { queueWait: Date.now() - lifecycle.enqueuedAt },
165
+ trace,
166
+ );
139
167
  const usage = {
140
168
  inputTokens: result.inputTokens,
141
169
  outputTokens: result.outputTokens,
@@ -152,9 +180,11 @@ export class Weaver {
152
180
  } else {
153
181
  task.succeed(usage);
154
182
  }
183
+ logTurnSettled(trace, lifecycle.killed, usage);
155
184
  return result;
156
185
  } catch (err) {
157
186
  task.fail(err);
187
+ logTurnFailure(trace, err, lifecycle.killed);
158
188
  if (lifecycle.killed) {
159
189
  // The backend didn't manage a clean interrupt-completion (some
160
190
  // SDK versions surface an interrupted turn as an error result).
@@ -168,6 +198,7 @@ export class Weaver {
168
198
  throw err;
169
199
  } finally {
170
200
  this.activeCount--;
201
+ trace.tools.close();
171
202
  }
172
203
  }
173
204
 
@@ -176,6 +207,7 @@ export class Weaver {
176
207
  params: ExecuteParams,
177
208
  task: TaskHandle,
178
209
  phases: Partial<Record<TurnPhase, number>>,
210
+ trace: TurnTrace,
179
211
  ): Promise<ExecuteResult> {
180
212
  const { context } = this.deps;
181
213
  const backend = this.deps.getBackend(params.chatId);
@@ -189,7 +221,12 @@ export class Weaver {
189
221
  reqId,
190
222
  });
191
223
  phases.warpResolve = Date.now() - warpStartedAt;
224
+ logTurnStart(trace, {
225
+ backendId: warp.backendId,
226
+ model: warp.ok ? warp.ref.id : undefined,
227
+ });
192
228
  if (!warp.ok) {
229
+ trace.refused = "no-model";
193
230
  await deliverRefusal(params, warp.message, "no-model");
194
231
  return this.emptyResult(warp.message, params);
195
232
  }
@@ -202,6 +239,7 @@ export class Weaver {
202
239
  "dispatcher",
203
240
  `[${reqId}] guest-scoped turn refused chat=${params.chatId}: backend "${backend.id}" cannot enforce the guest tool scope`,
204
241
  );
242
+ trace.refused = "guest-scope";
205
243
  await deliverRefusal(params, GUEST_BACKEND_REFUSAL, "guest-scope");
206
244
  return this.emptyResult(GUEST_BACKEND_REFUSAL, params);
207
245
  }
@@ -270,7 +308,12 @@ export class Weaver {
270
308
  // same path `phases.stream` is, so both cover one population.
271
309
  const stopCpu = startTurnCpu();
272
310
  const streamStartedAt = Date.now();
273
- const agentResult = await carryTurnEvents(stream, params.onEvent, timing);
311
+ const agentResult = await carryTurnEvents(
312
+ stream,
313
+ params.onEvent,
314
+ timing,
315
+ trace.tools,
316
+ );
274
317
  phases.stream = Date.now() - streamStartedAt;
275
318
  stopCpu();
276
319
  phases.delivery = timing.deliveryMs;
@@ -28,6 +28,12 @@ import { messagingHandlers, restoreScheduledMessages } from "./messaging.js";
28
28
  import { mediaHandlers } from "./media.js";
29
29
  import { chatInfoHandlers } from "./chat-info.js";
30
30
  import type { DiscordActionContext, DiscordActionHandlers } from "./types.js";
31
+ import {
32
+ createDeliveryTracker,
33
+ trackDeliveries,
34
+ } from "../../health/delivery.js";
35
+
36
+ const delivery = createDeliveryTracker("discord", "Discord", "discord");
31
37
 
32
38
  // Null-prototype so a request `action` of "toString" / "constructor" / etc.
33
39
  // can't resolve an inherited Object.prototype method via `handlers[action]`.
@@ -44,7 +50,7 @@ export function createDiscordActionHandler(client: Client, gateway: Gateway) {
44
50
  // shutdown — the timers died with the process, the store didn't.
45
51
  restoreScheduledMessages(client, scheduledMessages);
46
52
 
47
- return async (
53
+ const dispatch = async (
48
54
  body: Record<string, unknown>,
49
55
  chatId: number,
50
56
  ): Promise<ActionResult | null> => {
@@ -74,4 +80,5 @@ export function createDiscordActionHandler(client: Client, gateway: Gateway) {
74
80
  };
75
81
  return handler(body, chatId, ctx);
76
82
  };
83
+ return trackDeliveries(delivery, dispatch);
77
84
  }
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Client diagnostics — the error/warn/disconnect/rate-limit listeners that
3
- * only log. discord.js handles reconnects and retries itself; these exist
4
- * so a silent failure mode (a fatal close code, an approaching IP ban) is
5
- * visible in the daemon log.
2
+ * Client diagnostics — the error/warn/disconnect/rate-limit listeners.
3
+ * discord.js handles reconnects and retries itself; these exist so a
4
+ * silent failure mode (a fatal close code, an approaching IP ban) is
5
+ * visible in the daemon log, and so the operator hears when the gateway
6
+ * stays down: `discord.gateway` is raised once a shard has been
7
+ * disconnected for `GATEWAY_OUTAGE_MS` (at once, critically, for a close
8
+ * code discord.js will never recover from) and resolved on ready/resume.
6
9
  */
7
10
 
8
11
  import { type Client, Events } from "discord.js";
9
- import { logError, logWarn } from "../../util/log.js";
12
+ import { log, logError, logWarn } from "../../util/log.js";
13
+ import { createOutage, errorText, type Outage } from "../health/outage.js";
14
+
15
+ /** A gateway down this long reaches the operator. */
16
+ const GATEWAY_OUTAGE_MS = 5 * 60_000;
10
17
 
11
18
  function shardDisconnectLabel(code: number | undefined): string {
12
19
  return code === 4004
@@ -18,7 +25,76 @@ function shardDisconnectLabel(code: number | undefined): string {
18
25
  : `code=${code}`;
19
26
  }
20
27
 
21
- export function bindClientDiagnostics(client: Client): void {
28
+ function createGatewayOutage(thresholdMs: number): Outage {
29
+ return createOutage({
30
+ key: "discord.gateway",
31
+ thresholdMs,
32
+ describe: (err, mins) =>
33
+ `The Discord gateway has been disconnected for ${mins} min: ${err}. Messages are not being received.`,
34
+ recovered: "The Discord gateway is connected again.",
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Gateway connection health: which shards are down, and the outage that
40
+ * spans them. Reconnect attempts, errors and recoveries are logged with
41
+ * the shard, attempt number and time down.
42
+ */
43
+ function bindGatewayHealth(client: Client, thresholdMs: number): void {
44
+ const outage = createGatewayOutage(thresholdMs);
45
+ const down = new Set<number>();
46
+
47
+ const lost = (shardId: number, event: string, err: unknown): void => {
48
+ down.add(shardId);
49
+ const { attempt, downMs } = outage.fail(err);
50
+ logWarn(
51
+ "discord",
52
+ `gateway.${event} shard=${shardId} attempt=${attempt} down_ms=${downMs} err=${errorText(err)}`,
53
+ );
54
+ };
55
+ const back = (shardId: number, event: string): void => {
56
+ down.delete(shardId);
57
+ if (down.size > 0) return;
58
+ const ended = outage.ok();
59
+ if (ended) {
60
+ log(
61
+ "discord",
62
+ `gateway.${event} shard=${shardId} failed_attempts=${ended.attempts} down_ms=${ended.downMs}`,
63
+ );
64
+ }
65
+ };
66
+
67
+ client.on(Events.ShardReconnecting, (shardId) =>
68
+ lost(shardId, "reconnecting", "connection closed, reconnecting"),
69
+ );
70
+ client.on(Events.ShardError, (err, shardId) => {
71
+ // An error on a live shard is followed by a close if it matters; only
72
+ // an error during an outage is part of it (its text is the useful one).
73
+ if (down.has(shardId)) lost(shardId, "error", err);
74
+ });
75
+ client.on(Events.ShardDisconnect, (event, shardId) => {
76
+ const code = event?.code;
77
+ lost(shardId, "disconnect", shardDisconnectLabel(code));
78
+ if (code === 4004 || code === 4013 || code === 4014) {
79
+ outage.raiseNow(
80
+ `The Discord gateway closed and will not reconnect: ${shardDisconnectLabel(code)}. ` +
81
+ "Messages are not being received until this is fixed and Talon restarts.",
82
+ "critical",
83
+ );
84
+ }
85
+ });
86
+ client.on(Events.Invalidated, () =>
87
+ lost(0, "invalidated", "session invalidated"),
88
+ );
89
+ client.on(Events.ShardReady, (shardId) => back(shardId, "ready"));
90
+ client.on(Events.ShardResume, (shardId) => back(shardId, "resumed"));
91
+ }
92
+
93
+ export function bindClientDiagnostics(
94
+ client: Client,
95
+ gatewayOutageMs = GATEWAY_OUTAGE_MS,
96
+ ): void {
97
+ bindGatewayHealth(client, gatewayOutageMs);
22
98
  client.on("error", (err) => {
23
99
  logError("discord", "Client error", err);
24
100
  });
@@ -11,8 +11,6 @@
11
11
  * - `delivery` — the agent run + reply pipeline (processAndReply)
12
12
  * - `queue` — per-chat debounce queue
13
13
  * - `messages` — the messageCreate handler (handleMessage)
14
- *
15
- * Re-exports the same public surface the old single-file module exposed.
16
14
  */
17
15
 
18
16
  export { registerDiscordChat, lookupDiscordChat } from "./registry.js";
@@ -1,17 +1,11 @@
1
1
  /**
2
- * Discord middleware — wires up message events to handlers.ts.
2
+ * Discord middleware — wires up message events to handlers/.
3
3
  *
4
- * Equivalent to src/frontend/telegram/middleware.ts. We attach a single
5
- * messageCreate listener that filters out bots/system messages and delegates
6
- * to handleMessage. We also push every message into the in-memory history
7
- * buffer so /admin commands and /status reflect real activity.
8
- *
9
- * Discord-specific behavior:
10
- * - We don't have a separate "my_chat_member" event; instead, when the bot
11
- * is removed from a guild we get `guildDelete`. The handler in index.ts
12
- * handles that to revoke access.
13
- * - Every guild message gets the chat registered for pulse so periodic
14
- * check-ins work (DMs are excluded because we always respond).
4
+ * A single messageCreate listener filters out bots/system messages and
5
+ * delegates to handleMessage. Every message is also pushed into the
6
+ * in-memory history buffer so /admin commands and /status reflect real
7
+ * activity, and every guild message registers its chat for pulse (DMs are
8
+ * excluded because we always respond).
15
9
  */
16
10
 
17
11
  import type { Client, Message } from "discord.js";
@@ -24,7 +18,7 @@ import { handleMessage, getSenderName } from "./handlers/index.js";
24
18
 
25
19
  export function registerMiddleware(client: Client, config: TalonConfig): void {
26
20
  client.on("messageCreate", (msg: Message) => {
27
- // Ignore self/bots/system here too — handlers.ts will check again, but
21
+ // Ignore self/bots/system here too — handleMessage checks again, but
28
22
  // we don't even want to record those in history.
29
23
  if (msg.author.bot || msg.system) return;
30
24
  if (msg.author.id === client.user?.id) return;
@@ -1,9 +1,7 @@
1
1
  /**
2
2
  * Discord frontend runtime — the state every module of this frontend shares.
3
3
  *
4
- * `createDiscordFrontend` used to hold the client and config as closure
5
- * variables with every gateway-event handler nested inside `init`. It is
6
- * now one explicit object, constructed once, that each module (ready,
4
+ * One explicit object, constructed once, that each module (ready,
7
5
  * guild-policy, diagnostics, outbound, connection) takes as its first
8
6
  * parameter. The runtime carries state only; the modules own the behaviour.
9
7
  */
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Reply delivery health — the worst silent failure is a turn that ran but
3
+ * whose answer never reached the chat. The model sees a failed tool call;
4
+ * the person who asked sees nothing, and so does the operator.
5
+ *
6
+ * Each frontend wraps its action handler with `trackDeliveries`. The
7
+ * reply actions (`send_message`, `send_message_with_buttons`, `reply_to`)
8
+ * are counted per chat: `DELIVERY_ALERT_AFTER` failures in a row for one
9
+ * chat raise `delivery.<frontend>` with that chat and the error, and the
10
+ * next success to it resolves the alert once no chat is still failing.
11
+ */
12
+
13
+ import type { ActionResult } from "../../core/types.js";
14
+ import {
15
+ raiseAlert,
16
+ resolveAlert,
17
+ } from "../../core/frontend-runtime/alerts.js";
18
+ import { log, logWarn, type LogComponent } from "../../util/log.js";
19
+ import { errorText } from "./outage.js";
20
+
21
+ /** Consecutive failed replies to one chat before the operator hears of it. */
22
+ const DELIVERY_ALERT_AFTER = 3;
23
+
24
+ const REPLY_ACTIONS = new Set([
25
+ "send_message",
26
+ "send_message_with_buttons",
27
+ "reply_to",
28
+ ]);
29
+
30
+ export type DeliveryTracker = {
31
+ failed(chat: string | number, err: unknown): void;
32
+ delivered(chat: string | number): void;
33
+ };
34
+
35
+ /**
36
+ * `label` is the operator-facing platform name ("Telegram"); the alert key
37
+ * is `delivery.<frontend>`.
38
+ */
39
+ export function createDeliveryTracker(
40
+ frontend: string,
41
+ label: string,
42
+ component: LogComponent,
43
+ ): DeliveryTracker {
44
+ const key = `delivery.${frontend}`;
45
+ const streaks = new Map<string, number>();
46
+ /** Chats that crossed the threshold and have not delivered since. */
47
+ const alerted = new Set<string>();
48
+
49
+ return {
50
+ failed(chat, err) {
51
+ const id = String(chat);
52
+ const streak = (streaks.get(id) ?? 0) + 1;
53
+ streaks.set(id, streak);
54
+ const error = errorText(err);
55
+ logWarn(
56
+ component,
57
+ `delivery.fail frontend=${frontend} chat=${id} streak=${streak} err=${error}`,
58
+ );
59
+ if (streak < DELIVERY_ALERT_AFTER) return;
60
+ alerted.add(id);
61
+ raiseAlert(
62
+ key,
63
+ `${label} replies to chat ${id} have failed ${streak} times in a row: ${error}. ` +
64
+ "Answers are not reaching that chat.",
65
+ );
66
+ },
67
+ delivered(chat) {
68
+ const id = String(chat);
69
+ const streak = streaks.get(id);
70
+ if (streak === undefined) return;
71
+ streaks.delete(id);
72
+ log(
73
+ component,
74
+ `delivery.recovered frontend=${frontend} chat=${id} after_failures=${streak}`,
75
+ );
76
+ if (alerted.delete(id) && alerted.size === 0) {
77
+ resolveAlert(key, `${label} replies are being delivered again.`);
78
+ }
79
+ },
80
+ };
81
+ }
82
+
83
+ type ActionHandler = (
84
+ body: Record<string, unknown>,
85
+ chatId: number,
86
+ ) => Promise<ActionResult | null>;
87
+
88
+ /**
89
+ * Wrap a frontend action handler so its reply actions feed `tracker`.
90
+ * Results and thrown errors pass through untouched. `chatOf` names the
91
+ * destination when an action can target a chat other than `chatId`.
92
+ */
93
+ export function trackDeliveries(
94
+ tracker: DeliveryTracker,
95
+ handler: ActionHandler,
96
+ chatOf: (body: Record<string, unknown>, chatId: number) => string | number = (
97
+ _body,
98
+ chatId,
99
+ ) => chatId,
100
+ ): ActionHandler {
101
+ return async (body, chatId) => {
102
+ if (!REPLY_ACTIONS.has(body.action as string)) return handler(body, chatId);
103
+ const chat = chatOf(body, chatId);
104
+ let result: ActionResult | null;
105
+ try {
106
+ result = await handler(body, chatId);
107
+ } catch (err) {
108
+ tracker.failed(chat, err);
109
+ throw err;
110
+ }
111
+ if (result?.ok) tracker.delivered(chat);
112
+ else if (result) tracker.failed(chat, result.error ?? "unknown error");
113
+ return result;
114
+ };
115
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Connection outages — the line between "a reconnect failed" (log it) and
3
+ * "this chat channel is down" (tell the operator).
4
+ *
5
+ * A frontend reports every failed poll / reconnect with `fail` and every
6
+ * healthy one with `ok`. The first failure arms a timer; if nothing healthy
7
+ * arrives before `thresholdMs`, the alert is raised with the latest error.
8
+ * The timer — not the next failure — decides, because a link that dies
9
+ * silently (a gateway that never reconnects) produces no further events.
10
+ * `ok` ends the outage and, when an alert went out, sends the recovery.
11
+ */
12
+
13
+ import {
14
+ raiseAlert,
15
+ resolveAlert,
16
+ type AlertSeverity,
17
+ } from "../../core/frontend-runtime/alerts.js";
18
+
19
+ export type OutageOptions = {
20
+ /** Stable alert key, e.g. "telegram.polling". */
21
+ key: string;
22
+ /** How long failures must persist before the operator hears of it. */
23
+ thresholdMs: number;
24
+ severity?: AlertSeverity;
25
+ /** Operator text for the raise, given the latest error and minutes down. */
26
+ describe: (lastError: string, downMin: number) => string;
27
+ /** Operator text for the recovery notice. */
28
+ recovered: string;
29
+ };
30
+
31
+ export type Outage = {
32
+ /** Record a failure. Returns the streak so the caller can log it. */
33
+ fail(err: unknown): { attempt: number; downMs: number };
34
+ /** Record a healthy round-trip. Returns the outage it ended, if any. */
35
+ ok(): { attempts: number; downMs: number } | null;
36
+ /** Raise immediately — for failures that will not heal on their own. */
37
+ raiseNow(message: string, severity?: AlertSeverity): void;
38
+ /** True between the first failure and the next `ok`. */
39
+ readonly down: boolean;
40
+ /** Forget the outage without resolving it — shutdown. */
41
+ dispose(): void;
42
+ };
43
+
44
+ const MAX_ERROR_CHARS = 200;
45
+
46
+ /**
47
+ * One-line error text fit for an alert or a log line: the message only,
48
+ * bounded, with bot tokens and webhook signatures masked.
49
+ */
50
+ export function errorText(err: unknown): string {
51
+ const raw = err instanceof Error ? err.message : String(err);
52
+ const text = raw
53
+ .replace(/bot\d+:[\w-]+/g, "bot<redacted>")
54
+ .replace(/([?&]sig=)[^&\s]+/g, "$1<redacted>")
55
+ .replace(/\s+/g, " ")
56
+ .trim();
57
+ return text.length > MAX_ERROR_CHARS
58
+ ? `${text.slice(0, MAX_ERROR_CHARS)}…`
59
+ : text;
60
+ }
61
+
62
+ export function createOutage(opts: OutageOptions): Outage {
63
+ let since = 0;
64
+ let attempts = 0;
65
+ let lastError = "";
66
+ let raised = false;
67
+ let timer: ReturnType<typeof setTimeout> | null = null;
68
+
69
+ const clearTimer = (): void => {
70
+ if (timer) clearTimeout(timer);
71
+ timer = null;
72
+ };
73
+ const raise = (message: string, severity?: AlertSeverity): void => {
74
+ raised = true;
75
+ raiseAlert(opts.key, message, { severity: severity ?? opts.severity });
76
+ };
77
+
78
+ return {
79
+ get down() {
80
+ return attempts > 0;
81
+ },
82
+ fail(err) {
83
+ const now = Date.now();
84
+ if (attempts === 0) {
85
+ since = now;
86
+ timer = setTimeout(() => {
87
+ timer = null;
88
+ const mins = Math.max(1, Math.round((Date.now() - since) / 60_000));
89
+ raise(opts.describe(lastError, mins));
90
+ }, opts.thresholdMs);
91
+ timer.unref?.();
92
+ }
93
+ attempts++;
94
+ lastError = errorText(err);
95
+ return { attempt: attempts, downMs: now - since };
96
+ },
97
+ ok() {
98
+ if (attempts === 0 && !raised) return null;
99
+ const ended = { attempts, downMs: attempts ? Date.now() - since : 0 };
100
+ clearTimer();
101
+ attempts = 0;
102
+ if (raised) resolveAlert(opts.key, opts.recovered);
103
+ raised = false;
104
+ return ended;
105
+ },
106
+ raiseNow(message, severity) {
107
+ clearTimer();
108
+ raise(message, severity);
109
+ },
110
+ dispose() {
111
+ clearTimer();
112
+ attempts = 0;
113
+ raised = false;
114
+ },
115
+ };
116
+ }
@@ -132,11 +132,9 @@ export function chatRoutes(
132
132
  const startedAt = Date.now();
133
133
  try {
134
134
  const attachment = await h.upload(filename, contentType, req);
135
- // Uploads are the one client action whose failure used to be visible
136
- // only in the app: the route answered in JSON and logged nothing, so
137
- // "attaching a file doesn't work — check the logs" had nothing to
138
- // read. Both outcomes are logged now, with the size and duration
139
- // that distinguish a rejected upload from a dropped connection.
135
+ // Log both outcomes, with the size and duration that tell a rejected
136
+ // upload from a dropped connection — the JSON answer is otherwise
137
+ // only visible in the app.
140
138
  log(
141
139
  "native",
142
140
  `upload ok: ${attachment.name} (${attachment.size} bytes, ${attachment.mimeType}) in ${Date.now() - startedAt}ms`,