talon-agent 5.18.1 → 5.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/LICENSE +202 -21
  2. package/LICENSE-MIT +21 -0
  3. package/NOTICE +16 -0
  4. package/README.md +8 -3
  5. package/package.json +4 -2
  6. package/prompts/system/agent-brief.md +20 -3
  7. package/src/app.ts +13 -0
  8. package/src/backend/claude-sdk/handler.ts +4 -4
  9. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  10. package/src/backend/claude-sdk/stream.ts +2 -2
  11. package/src/backend/codex/auth.ts +1 -1
  12. package/src/backend/codex/handler/message.ts +11 -11
  13. package/src/backend/codex/init.ts +4 -9
  14. package/src/backend/codex/mcp-config.ts +1 -2
  15. package/src/backend/codex/oauth-incompat.ts +8 -4
  16. package/src/backend/codex/one-shot.ts +1 -1
  17. package/src/backend/openai-agents/builtins.ts +55 -27
  18. package/src/backend/openai-agents/factory.ts +3 -3
  19. package/src/backend/openai-agents/handler/message.ts +3 -5
  20. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  21. package/src/backend/remote-server/chat-turn.ts +6 -6
  22. package/src/backend/remote-server/events.ts +1 -5
  23. package/src/backend/remote-server/index.ts +0 -1
  24. package/src/backend/remote-server/messages.ts +3 -7
  25. package/src/backend/remote-server/one-shot.ts +1 -3
  26. package/src/backend/remote-server/session-helpers.ts +1 -4
  27. package/src/backend/remote-server/sse-stream.ts +8 -9
  28. package/src/backend/runtime/metrics.ts +7 -13
  29. package/src/backend/runtime/sleep.ts +1 -2
  30. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  31. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  32. package/src/bootstrap.ts +9 -1
  33. package/src/cli/doctor.ts +3 -0
  34. package/src/cli/index.ts +8 -10
  35. package/src/cli/logs.ts +148 -9
  36. package/src/cli/setup.ts +9 -11
  37. package/src/cli/status.ts +29 -0
  38. package/src/core/agent-runtime/README.md +5 -19
  39. package/src/core/agent-runtime/events.ts +3 -39
  40. package/src/core/agent-runtime/model-ref.ts +0 -8
  41. package/src/core/agents/registry.ts +49 -2
  42. package/src/core/auth/expiry-monitor.ts +9 -1
  43. package/src/core/auth/login-flow.ts +9 -1
  44. package/src/core/auth/status.ts +31 -3
  45. package/src/core/background/cron/scheduler.ts +25 -5
  46. package/src/core/background/dream/index.ts +29 -10
  47. package/src/core/background/failure-backoff.ts +30 -0
  48. package/src/core/background/heartbeat/agent.ts +2 -27
  49. package/src/core/background/heartbeat/index.ts +0 -2
  50. package/src/core/background/heartbeat/scheduler.ts +21 -13
  51. package/src/core/background/heartbeat/state.ts +10 -2
  52. package/src/core/background/isolated-agent.ts +6 -2
  53. package/src/core/background/pulse/pulse.ts +9 -0
  54. package/src/core/background/triggers/exit.ts +54 -0
  55. package/src/core/background/triggers/index.ts +1 -3
  56. package/src/core/background/triggers/resume.ts +2 -4
  57. package/src/core/backup/archive/tar.ts +14 -4
  58. package/src/core/backup/restore.ts +21 -13
  59. package/src/core/backup/scheduler.ts +28 -9
  60. package/src/core/backup/snapshot.ts +43 -10
  61. package/src/core/backup/store.ts +7 -19
  62. package/src/core/backup/targets.ts +69 -17
  63. package/src/core/config/index.ts +14 -0
  64. package/src/core/daemon/crash-marker.ts +141 -0
  65. package/src/core/daemon/crash.ts +9 -2
  66. package/src/core/daemon/handoff.ts +15 -0
  67. package/src/core/daemon/health-alerts.ts +297 -0
  68. package/src/core/daemon/log-reader.ts +289 -0
  69. package/src/core/doctor/index.ts +18 -2
  70. package/src/core/doctor/logs.ts +124 -0
  71. package/src/core/doctor/types.ts +1 -1
  72. package/src/core/engine/backend-controller/index.ts +1 -13
  73. package/src/core/engine/backend-router/router.ts +1 -1
  74. package/src/core/engine/dispatcher.ts +55 -2
  75. package/src/core/engine/fault-text.ts +40 -0
  76. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  77. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  78. package/src/core/engine/gateway-actions/history.ts +2 -4
  79. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  80. package/src/core/engine/gateway.ts +60 -1
  81. package/src/core/engine/turn-health.ts +222 -0
  82. package/src/core/errors.ts +2 -2
  83. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  84. package/src/core/frontend-runtime/alerts.ts +130 -0
  85. package/src/core/mcp-hub/children.ts +78 -29
  86. package/src/core/mcp-hub/index.ts +21 -18
  87. package/src/core/mcp-hub/proxy-server.ts +8 -4
  88. package/src/core/mcp-hub/talon-server.ts +5 -12
  89. package/src/core/mesh/credentials/store.ts +16 -1
  90. package/src/core/mesh/devices/service.ts +26 -15
  91. package/src/core/mesh/devices/teleport.ts +14 -2
  92. package/src/core/mesh/links/node-binaries.ts +13 -6
  93. package/src/core/mesh/persist.ts +22 -10
  94. package/src/core/mesh/transfers/device-files.ts +5 -23
  95. package/src/core/mesh/transfers/transfers.ts +16 -3
  96. package/src/core/models/active-model.ts +2 -55
  97. package/src/core/plugin/actions.ts +19 -20
  98. package/src/core/plugin/builtins.ts +80 -90
  99. package/src/core/plugin/index.ts +1 -4
  100. package/src/core/plugin/loader.ts +25 -33
  101. package/src/core/plugin/mcp.ts +3 -5
  102. package/src/core/plugin/registry.ts +19 -35
  103. package/src/core/plugin/types.ts +2 -5
  104. package/src/core/prompt/assemble.ts +15 -3
  105. package/src/core/scripts/lua.ts +6 -2
  106. package/src/core/tasks/table.ts +8 -2
  107. package/src/core/tools/bridge.ts +2 -4
  108. package/src/core/tools/chat/cross-send.ts +1 -1
  109. package/src/core/tools/chat/messaging.ts +1 -1
  110. package/src/core/tools/index.ts +2 -2
  111. package/src/core/tools/mcp-env.ts +2 -59
  112. package/src/core/tools/ops/agents.ts +22 -1
  113. package/src/core/tools/schemas.ts +4 -9
  114. package/src/core/vfs/fusefs.ts +0 -5
  115. package/src/core/vfs/index.ts +9 -2
  116. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  117. package/src/core/vfs/mounts/proc.ts +17 -1
  118. package/src/core/vfs/workspace.ts +7 -3
  119. package/src/core/weaver/shuttle.ts +8 -1
  120. package/src/core/weaver/turn-log.ts +320 -0
  121. package/src/core/weaver/weaver.ts +48 -5
  122. package/src/frontend/discord/actions/index.ts +8 -1
  123. package/src/frontend/discord/diagnostics.ts +82 -6
  124. package/src/frontend/discord/handlers/index.ts +0 -2
  125. package/src/frontend/discord/middleware.ts +7 -13
  126. package/src/frontend/discord/runtime.ts +1 -3
  127. package/src/frontend/health/delivery.ts +115 -0
  128. package/src/frontend/health/outage.ts +116 -0
  129. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  130. package/src/frontend/native/bridge/server.ts +106 -21
  131. package/src/frontend/native/index.ts +1 -1
  132. package/src/frontend/native/media/media.ts +5 -1
  133. package/src/frontend/native/runtime.ts +12 -7
  134. package/src/frontend/native/surface/handlers.ts +1 -1
  135. package/src/frontend/native/surface/memory.ts +1 -1
  136. package/src/frontend/native/surface/models.ts +3 -3
  137. package/src/frontend/native/surface/settings.ts +20 -8
  138. package/src/frontend/native/turn/context.ts +6 -8
  139. package/src/frontend/native/turn/turn-meta.ts +2 -5
  140. package/src/frontend/native/turn/turn.ts +8 -10
  141. package/src/frontend/presentation/format.ts +2 -4
  142. package/src/frontend/presentation/session-status.ts +2 -6
  143. package/src/frontend/teams/actions.ts +8 -1
  144. package/src/frontend/teams/graph.ts +0 -1
  145. package/src/frontend/teams/index.ts +1 -4
  146. package/src/frontend/teams/poll.ts +40 -2
  147. package/src/frontend/teams/runtime.ts +14 -5
  148. package/src/frontend/telegram/actions/index.ts +4 -1
  149. package/src/frontend/telegram/actions/outgoing-log.ts +70 -0
  150. package/src/frontend/telegram/actions/send.ts +12 -0
  151. package/src/frontend/telegram/handlers/context.ts +13 -2
  152. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  153. package/src/frontend/telegram/handlers/index.ts +0 -2
  154. package/src/frontend/telegram/index.ts +35 -9
  155. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  156. package/src/frontend/telegram/userbot.ts +147 -41
  157. package/src/frontend/terminal/builtins/session.ts +2 -2
  158. package/src/frontend/terminal/index.ts +1 -3
  159. package/src/frontend/terminal/renderer.ts +2 -18
  160. package/src/frontend/whatsapp/actions/index.ts +12 -1
  161. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  162. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  163. package/src/frontend/whatsapp/connection/health.ts +89 -0
  164. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  165. package/src/frontend/whatsapp/runtime.ts +11 -5
  166. package/src/native/blake3.ts +28 -2
  167. package/src/native/fusefs.ts +23 -5
  168. package/src/native/registry.ts +1 -1
  169. package/src/native/warden.ts +33 -5
  170. package/src/plugins/github/index.ts +0 -1
  171. package/src/plugins/mempalace/index.ts +9 -3
  172. package/src/plugins/playwright/index.ts +2 -4
  173. package/src/plugins/playwright/provision.ts +12 -4
  174. package/src/storage/chat-settings.ts +6 -1
  175. package/src/storage/cron.ts +29 -4
  176. package/src/storage/daily-log.ts +43 -47
  177. package/src/storage/db.ts +61 -33
  178. package/src/storage/history.ts +6 -1
  179. package/src/storage/journal.ts +9 -2
  180. package/src/storage/kv.ts +19 -6
  181. package/src/storage/media-index.ts +28 -6
  182. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  183. package/src/storage/repositories/sessions-repo.ts +10 -3
  184. package/src/storage/scripts.ts +24 -13
  185. package/src/storage/sessions.ts +11 -2
  186. package/src/storage/skills.ts +21 -2
  187. package/src/storage/stickers.ts +17 -3
  188. package/src/storage/triggers.ts +8 -3
  189. package/src/storage/turn-meta.ts +25 -7
  190. package/src/util/log.ts +189 -6
  191. package/src/util/logging/turn-scope.ts +85 -0
  192. package/src/util/time.ts +3 -3
  193. package/src/util/watchdog.ts +30 -0
  194. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Turn health — turns the per-turn outcomes the dispatcher sees into
3
+ * operator alerts.
4
+ *
5
+ * Every chat turn on every backend settles through `execute`, so this is
6
+ * the one place that can tell "one bad turn" from "this chat is broken"
7
+ * or "this backend is down":
8
+ *
9
+ * - `backend.auth.<backend>` / `backend.quota.<backend>` — the backend
10
+ * lost its login or ran out of quota. Raised on the first such
11
+ * failure: nothing but a human (a re-login, a plan change, waiting
12
+ * out a window the operator should know about) clears it.
13
+ * - `turn.failing.<chatId>` — the chat's last 3 turns failed.
14
+ * - `backend.failing.<backend>` — the backend failed turns in 3
15
+ * different chats within 10 minutes.
16
+ * - `dispatcher.stuck` — the watchdog saw work arrive and nothing
17
+ * finish or make progress for 10 minutes (see util/watchdog.ts).
18
+ *
19
+ * Auth and quota failures carry their own alert, so they do not also
20
+ * count toward the two failure streaks. Every alert resolves on the next
21
+ * successful turn on the same chat / backend. A stopped turn is neither.
22
+ */
23
+
24
+ import { AgentRunError } from "../agent-runtime/events.js";
25
+ import { TalonError } from "../errors.js";
26
+ import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
27
+ import { taskTable } from "../tasks/index.js";
28
+ import { log, logWarn } from "../../util/log.js";
29
+ import type { StuckLoopListener } from "../../util/watchdog.js";
30
+ import { faultText } from "./fault-text.js";
31
+
32
+ const CHAT_STREAK_THRESHOLD = 3;
33
+ const BACKEND_CHAT_THRESHOLD = 3;
34
+ const BACKEND_WINDOW_MS = 10 * 60_000;
35
+
36
+ /**
37
+ * Login / credential failures across backends: Claude Code's "Not logged
38
+ * in · Please run /login", an expired OAuth token, Codex's refresh-token
39
+ * 401, Antigravity's "not authenticated", a bare 401/403.
40
+ */
41
+ const AUTH_RE =
42
+ /not logged in|please run \/login|not (?:yet )?authenticated|authentication (?:required|failed|error)|unauthori[sz]ed|invalid (?:api[ _-]?key|x-api-key|bearer|token|credentials)|(?:oauth |access )?token (?:has )?expired|login expired|refresh_token_invalidated|failed to refresh token|permission_error|\b40[13]\b/i;
43
+
44
+ /** Subscription / credit exhaustion — a limit a short retry won't clear. */
45
+ const QUOTA_RE =
46
+ /usage limit|usage exhausted|you['’]ve hit your .{0,40}limit|out of (?:extra usage|credits)|insufficient_quota|quota (?:exceeded|exhausted)|exceeded your current quota|credit balance is too low|no remaining credits/i;
47
+
48
+ type TurnFault = "auth" | "quota" | "error";
49
+
50
+ export type TurnBinding = {
51
+ chatId: string;
52
+ backendId: string;
53
+ model?: string;
54
+ source?: string;
55
+ durationMs: number;
56
+ };
57
+
58
+ /** chatId → consecutive failed turns. */
59
+ const chatStreaks = new Map<string, number>();
60
+ /** backend → chatId → time of that chat's latest failure. */
61
+ const backendFailures = new Map<string, Map<string, number>>();
62
+
63
+ /** What kind of failure `err` is — null when it is not a fault at all. */
64
+ function classifyTurnFault(err: unknown): TurnFault | null {
65
+ if (err instanceof TalonError) {
66
+ if (err.reason === "stopped") return null;
67
+ if (err.reason === "auth") return "auth";
68
+ if (err.reason === "usage_limit") return "quota";
69
+ }
70
+ if (err instanceof AgentRunError) {
71
+ if (err.kind === "aborted") return null;
72
+ if (err.kind === "auth") return "auth";
73
+ }
74
+ if (err instanceof Error && err.name === "AbortError") return null;
75
+ const text =
76
+ err instanceof AgentRunError
77
+ ? `${err.message} ${err.raw ?? ""}`
78
+ : err instanceof Error
79
+ ? err.message
80
+ : String(err);
81
+ if (QUOTA_RE.test(text)) return "quota";
82
+ if (AUTH_RE.test(text)) return "auth";
83
+ if (err instanceof AgentRunError && err.kind === "rate_limit") {
84
+ // A non-retryable rate-limit event is a usage limit, not a 429.
85
+ return err.retryable ? "error" : "quota";
86
+ }
87
+ return "error";
88
+ }
89
+
90
+ function describe(b: TurnBinding): string {
91
+ return b.model ? `${b.backendId}/${b.model}` : b.backendId;
92
+ }
93
+
94
+ /** Record a turn that completed. Clears every alert it can vouch for. */
95
+ export function noteTurnSucceeded(b: TurnBinding): void {
96
+ const streak = chatStreaks.get(b.chatId);
97
+ if (streak) {
98
+ chatStreaks.delete(b.chatId);
99
+ log(
100
+ "dispatcher",
101
+ `turn.recovered chat=${b.chatId} backend=${b.backendId} after_failures=${streak} ms=${b.durationMs}`,
102
+ );
103
+ resolveAlert(
104
+ `turn.failing.${b.chatId}`,
105
+ `Chat ${b.chatId} is answering again (${describe(b)}).`,
106
+ );
107
+ }
108
+ if (backendFailures.delete(b.backendId)) {
109
+ resolveAlert(
110
+ `backend.failing.${b.backendId}`,
111
+ `The ${b.backendId} backend is completing turns again.`,
112
+ );
113
+ }
114
+ resolveAlert(
115
+ `backend.auth.${b.backendId}`,
116
+ `The ${b.backendId} backend is signed in again.`,
117
+ );
118
+ resolveAlert(
119
+ `backend.quota.${b.backendId}`,
120
+ `The ${b.backendId} backend has quota again.`,
121
+ );
122
+ }
123
+
124
+ /** Record a turn that threw. Raises once a threshold is crossed. */
125
+ export function noteTurnFailed(b: TurnBinding, err: unknown): void {
126
+ const fault = classifyTurnFault(err);
127
+ if (fault === null) return;
128
+ const detail = faultText(err);
129
+ const prior = chatStreaks.get(b.chatId) ?? 0;
130
+ const count = fault === "error" ? prior + 1 : prior;
131
+ logWarn(
132
+ "dispatcher",
133
+ `turn.fail chat=${b.chatId} backend=${b.backendId} model=${b.model ?? "?"} ` +
134
+ `source=${b.source ?? "?"} cause=${fault} streak=${count} ` +
135
+ `ms=${b.durationMs} error="${detail}"`,
136
+ );
137
+ if (fault === "auth") {
138
+ raiseAlert(
139
+ `backend.auth.${b.backendId}`,
140
+ `The ${b.backendId} backend is not signed in: ${detail}. Every chat on it fails until it is re-authenticated.`,
141
+ { severity: "critical" },
142
+ );
143
+ return;
144
+ }
145
+ if (fault === "quota") {
146
+ raiseAlert(
147
+ `backend.quota.${b.backendId}`,
148
+ `The ${b.backendId} backend is out of quota: ${detail}. Chats on it fail until the limit resets.`,
149
+ );
150
+ return;
151
+ }
152
+ chatStreaks.set(b.chatId, count);
153
+ if (count >= CHAT_STREAK_THRESHOLD) {
154
+ raiseAlert(
155
+ `turn.failing.${b.chatId}`,
156
+ `Chat ${b.chatId} has failed its last ${count} turns on ${describe(b)}: ${detail}`,
157
+ );
158
+ }
159
+ noteBackendFailure(b, detail);
160
+ }
161
+
162
+ function noteBackendFailure(b: TurnBinding, detail: string): void {
163
+ const now = Date.now();
164
+ let chats = backendFailures.get(b.backendId);
165
+ if (!chats) {
166
+ chats = new Map();
167
+ backendFailures.set(b.backendId, chats);
168
+ }
169
+ chats.set(b.chatId, now);
170
+ for (const [chatId, at] of chats) {
171
+ if (now - at > BACKEND_WINDOW_MS) chats.delete(chatId);
172
+ }
173
+ if (chats.size >= BACKEND_CHAT_THRESHOLD) {
174
+ raiseAlert(
175
+ `backend.failing.${b.backendId}`,
176
+ `The ${b.backendId} backend failed turns in ${chats.size} chats in the last 10 min. Last error: ${detail}`,
177
+ );
178
+ }
179
+ }
180
+
181
+ /** The turns in flight, for the stuck-loop alert: what is wedged where. */
182
+ function describeTurnQueue(now = Date.now()): string {
183
+ const turns = taskTable
184
+ .list()
185
+ .filter(
186
+ (t) =>
187
+ t.kind === "turn" && (t.state === "running" || t.state === "queued"),
188
+ );
189
+ if (turns.length === 0) return "No turn is running or queued.";
190
+ const running = turns
191
+ .filter((t) => t.state === "running")
192
+ .map((t) => {
193
+ const mins = Math.round((now - (t.startedAt ?? t.queuedAt)) / 60_000);
194
+ const on = t.backendId
195
+ ? ` on ${t.backendId}${t.model ? `/${t.model}` : ""}`
196
+ : "";
197
+ return `chat ${t.chatId ?? "?"}${on} (${mins} min)`;
198
+ });
199
+ const queued = turns.length - running.length;
200
+ const parts = [
201
+ running.length ? `Running: ${running.join(", ")}.` : "Nothing is running.",
202
+ ];
203
+ if (queued) parts.push(`${queued} turn(s) queued.`);
204
+ return parts.join(" ");
205
+ }
206
+
207
+ /** Watchdog hook: the stuck message loop as an operator alert. */
208
+ export const stuckLoopAlert: StuckLoopListener = {
209
+ onStuck: ({ pendingMins, silentMins }) =>
210
+ raiseAlert(
211
+ "dispatcher.stuck",
212
+ `The message loop looks stuck: a message received ${pendingMins} min ago is unprocessed and no turn has made progress for ${silentMins} min. ${describeTurnQueue()}`,
213
+ ),
214
+ onRecovered: () =>
215
+ resolveAlert("dispatcher.stuck", "The message loop is processing again."),
216
+ };
217
+
218
+ /** Test seam: forget every streak. */
219
+ export function resetTurnHealthForTest(): void {
220
+ chatStreaks.clear();
221
+ backendFailures.clear();
222
+ }
@@ -247,8 +247,8 @@ export function classify(err: unknown): TalonError {
247
247
  }
248
248
 
249
249
  // 408 Request Timeout — a transient deadline like any other, but it
250
- // is neither 4xx-terminal nor 5xx, so it used to fall through to
251
- // `unknown`/non-retryable and strand the request.
250
+ // is neither 4xx-terminal nor 5xx, so without this branch it falls
251
+ // through to `unknown`/non-retryable and strands the request.
252
252
  if (status === 408) {
253
253
  return new TalonError(msg, {
254
254
  reason: "network",
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * First consumer: WhatsApp pairing. When WhatsApp unlinks the device,
12
12
  * recovery needs a human to type a pairing code into the phone — a code
13
- * that previously only ever appeared in the daemon log, which nobody
13
+ * that would otherwise only appear in the daemon log, which nobody
14
14
  * watches. Alerts about a dead frontend must travel over a LIVE one.
15
15
  */
16
16
 
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Operator alerts — "something is wrong" (and "it's fine again") on the
3
+ * admin's chat, deduplicated so a flapping fault can't flood it.
4
+ *
5
+ * Every alert has a stable key naming the fault ("telegram.polling",
6
+ * "backend.auth", "disk.low"). The first raise delivers; repeats inside
7
+ * the cooldown only count, and the next delivery after it says how many
8
+ * were folded in. `resolveAlert` sends one recovery notice for a key that
9
+ * was delivered, and nothing for one that never was. Delivery rides
10
+ * `notifyAdmin`, so alerts degrade to a log line when no frontend is up.
11
+ *
12
+ * Two exceptions to the cooldown: an escalation (warn → error → critical)
13
+ * always delivers, and a delivery that failed (no notifier wired, the
14
+ * admin's frontend down) doesn't count, so the next raise tries again.
15
+ */
16
+
17
+ import { log, logWarn } from "../../util/log.js";
18
+ import { notifyAdmin } from "./admin-notify.js";
19
+
20
+ export type AlertSeverity = "warn" | "error" | "critical";
21
+
22
+ type ActiveAlert = {
23
+ severity: AlertSeverity;
24
+ message: string;
25
+ firstAt: number;
26
+ lastSentAt: number;
27
+ /** Raises folded into the cooldown since the last delivery. */
28
+ suppressed: number;
29
+ };
30
+
31
+ const DEFAULT_COOLDOWN_MS = 30 * 60_000;
32
+ const ICON: Record<AlertSeverity, string> = {
33
+ warn: "⚠️",
34
+ error: "🔴",
35
+ critical: "🚨",
36
+ };
37
+ const RANK: Record<AlertSeverity, number> = { warn: 0, error: 1, critical: 2 };
38
+
39
+ const active = new Map<string, ActiveAlert>();
40
+ let cooldownMs = DEFAULT_COOLDOWN_MS;
41
+ let enabled = true;
42
+ let send: (text: string) => Promise<unknown> = notifyAdmin;
43
+
44
+ /** Apply operator settings (config `alerts`). */
45
+ export function configureAlerts(opts: {
46
+ enabled?: boolean;
47
+ cooldownMs?: number;
48
+ }): void {
49
+ if (opts.enabled !== undefined) enabled = opts.enabled;
50
+ if (opts.cooldownMs !== undefined && opts.cooldownMs >= 0)
51
+ cooldownMs = opts.cooldownMs;
52
+ }
53
+
54
+ /**
55
+ * Report a fault. Always logged; delivered to the admin unless the same
56
+ * key was delivered within the cooldown. Never throws.
57
+ */
58
+ export function raiseAlert(
59
+ key: string,
60
+ message: string,
61
+ opts: { severity?: AlertSeverity } = {},
62
+ ): void {
63
+ const severity = opts.severity ?? "error";
64
+ const now = Date.now();
65
+ logWarn("alert", `[${severity}] ${key}: ${message}`);
66
+ const prior = active.get(key);
67
+ const escalated =
68
+ prior !== undefined && RANK[severity] > RANK[prior.severity];
69
+ if (prior && !escalated && now - prior.lastSentAt < cooldownMs) {
70
+ prior.suppressed++;
71
+ prior.message = message;
72
+ return;
73
+ }
74
+ const folded = prior?.suppressed ?? 0;
75
+ const entry: ActiveAlert = {
76
+ severity,
77
+ message,
78
+ firstAt: prior?.firstAt ?? now,
79
+ lastSentAt: now,
80
+ suppressed: 0,
81
+ };
82
+ active.set(key, entry);
83
+ if (!enabled) return;
84
+ const repeat = folded > 0 ? `\n(+${folded} more since the last alert)` : "";
85
+ const undelivered = (): void => {
86
+ // Nobody heard it: don't let the cooldown swallow the next raise.
87
+ if (active.get(key) === entry) entry.lastSentAt = 0;
88
+ };
89
+ void send(`${ICON[severity]} ${message}${repeat}`).then((ok) => {
90
+ if (ok === false) undelivered();
91
+ }, undelivered);
92
+ }
93
+
94
+ /** Clear a fault; announces recovery only if its alert was delivered. */
95
+ export function resolveAlert(key: string, message?: string): void {
96
+ const prior = active.get(key);
97
+ if (!prior) return;
98
+ active.delete(key);
99
+ const mins = Math.max(1, Math.round((Date.now() - prior.firstAt) / 60_000));
100
+ log("alert", `resolved ${key} after ${mins} min`);
101
+ if (!enabled) return;
102
+ void send(`✅ ${message ?? `Recovered: ${key}`} (after ${mins} min)`).catch(
103
+ () => {},
104
+ );
105
+ }
106
+
107
+ /** Keys currently raised — for status surfaces and doctor. */
108
+ export function activeAlerts(): ReadonlyArray<{
109
+ key: string;
110
+ severity: AlertSeverity;
111
+ message: string;
112
+ since: number;
113
+ }> {
114
+ return [...active].map(([key, a]) => ({
115
+ key,
116
+ severity: a.severity,
117
+ message: a.message,
118
+ since: a.firstAt,
119
+ }));
120
+ }
121
+
122
+ /** Test seam: reset state and swap the delivery function. */
123
+ export function resetAlertsForTest(
124
+ deliver: (text: string) => Promise<unknown> = notifyAdmin,
125
+ ): void {
126
+ active.clear();
127
+ cooldownMs = DEFAULT_COOLDOWN_MS;
128
+ enabled = true;
129
+ send = deliver;
130
+ }
@@ -2,11 +2,8 @@
2
2
  * Hub child manager — the single owner of external MCP server
3
3
  * subprocesses (plugins, brave-search).
4
4
  *
5
- * Before the hub, every chat turn (claude-sdk, codex) or every chat
6
- * lifetime (openai-agents, kilo/opencode) spawned its own copy of every
7
- * plugin MCP server — memory grew linearly with chats. The hub instead
8
- * keeps one child per key and reaps it after an idle TTL, so resident
9
- * cost tracks *recently active* keys, not every chat ever seen.
5
+ * One child per key, reaped after an idle TTL, so resident cost tracks
6
+ * *recently active* keys, not every chat ever seen.
10
7
  *
11
8
  * Keys: chat-scoped plugins get `name + chatId` (they read
12
9
  * `TALON_CHAT_ID` at boot, so instances cannot be shared across chats
@@ -14,8 +11,8 @@
14
11
  * use a shared key. Either way the spec factory decides — this module
15
12
  * only manages lifecycles.
16
13
  *
17
- * Each child is spawned through the same supervisor wrap as before
18
- * (stdout JSON filtering + orphan cleanup if the daemon is SIGKILLed),
14
+ * Each child is spawned through the supervisor wrap (stdout JSON
15
+ * filtering + orphan cleanup if the daemon is SIGKILLed),
19
16
  * connected once over stdio, and shared by every hub session that
20
17
  * proxies to it. The tools list is cached per child lifetime — plugin
21
18
  * reload restarts children, which naturally invalidates the cache.
@@ -30,6 +27,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
30
27
  import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
31
28
  import { log, logError, logWarn } from "../../util/log.js";
32
29
  import { HubChildTransport, type ChildExit } from "./child-transport.js";
30
+ import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
31
+ import { faultText } from "../engine/fault-text.js";
33
32
 
34
33
  export type ChildSpec = {
35
34
  command: string;
@@ -40,13 +39,15 @@ export type ChildSpec = {
40
39
  export type ChildHandle = {
41
40
  /** Cached tools/list result — fetched once per child lifetime. */
42
41
  listTools(): Promise<Tool[]>;
43
- /** Forward one tool call; tracked so retirement can drain in-flight work. */
42
+ /**
43
+ * Forward one tool call; tracked so retirement can drain in-flight work.
44
+ * Aborting `signal` cancels the call on the child too.
45
+ */
44
46
  callTool(
45
47
  name: string,
46
48
  args: Record<string, unknown>,
49
+ signal?: AbortSignal,
47
50
  ): Promise<CallToolResult>;
48
- /** Mark activity so the idle reaper skips this child. */
49
- touch(): void;
50
51
  };
51
52
 
52
53
  type ChildEntry = {
@@ -151,6 +152,8 @@ type SpawnFailure = { at: number; count: number; error: unknown };
151
152
  const spawnFailures = new Map<string, SpawnFailure>();
152
153
  const FAILURE_BACKOFF_BASE_MS = 30_000;
153
154
  const FAILURE_BACKOFF_MAX_MS = 10 * 60_000;
155
+ /** Consecutive spawn failures of one child before the operator hears. */
156
+ const SPAWN_FAILURE_ALERT_THRESHOLD = 3;
154
157
 
155
158
  function failureBackoffMs(count: number): number {
156
159
  return Math.min(
@@ -159,6 +162,42 @@ function failureBackoffMs(count: number): number {
159
162
  );
160
163
  }
161
164
 
165
+ /** The MCP server name of a child key (keys are `server\0chat`). */
166
+ function serverOf(key: string): string {
167
+ const nul = key.indexOf("\u0000");
168
+ return nul === -1 ? key : key.slice(0, nul);
169
+ }
170
+
171
+ /**
172
+ * Count a failed spawn into the negative cache, log it with the child's
173
+ * last words, and alert once the same child has failed
174
+ * SPAWN_FAILURE_ALERT_THRESHOLD times running — its tools are gone for
175
+ * every turn until whatever backs it comes back.
176
+ */
177
+ function noteSpawnFailure(key: string, err: unknown): void {
178
+ const count = (spawnFailures.get(key)?.count ?? 0) + 1;
179
+ spawnFailures.set(key, { at: Date.now(), count, error: err });
180
+ const backoffMs = failureBackoffMs(count);
181
+ // The exit record is this attempt's only when it is fresh — an older
182
+ // one belongs to a child that ran and was reaped long ago.
183
+ const exit = lastExits.get(key);
184
+ const stderr =
185
+ exit && Date.now() - exit.at < 60_000 ? exit.stderr.at(-1) : undefined;
186
+ const detail = faultText(err);
187
+ logWarn(
188
+ "gateway",
189
+ `hub child spawn failed ${describeKey(key)} attempt=${count} backoff_ms=${backoffMs} ` +
190
+ `error="${detail}"${stderr ? ` stderr="${faultText(stderr)}"` : ""}`,
191
+ );
192
+ if (count < SPAWN_FAILURE_ALERT_THRESHOLD) return;
193
+ const server = serverOf(key);
194
+ raiseAlert(
195
+ `mcp.child.${server}`,
196
+ `MCP server "${server}" has failed to start ${count} times in a row: ${detail}` +
197
+ `${stderr ? ` (stderr: ${faultText(stderr, 120)})` : ""}. Its tools are unavailable until it starts.`,
198
+ );
199
+ }
200
+
162
201
  /** Idle TTL for hub children; tunable for tests / tight deployments. */
163
202
  function idleTtlMs(): number {
164
203
  const raw = Number(process.env.TALON_MCP_HUB_IDLE_MS);
@@ -166,6 +205,14 @@ function idleTtlMs(): number {
166
205
  }
167
206
 
168
207
  const REAP_INTERVAL_MS = 60_000;
208
+
209
+ /**
210
+ * Backstop for one forwarded tool call. The SDK's default (60s) would cut
211
+ * any longer plugin call, though the upstream client allows far more —
212
+ * its own timeout or cancel reaches the child through the forwarded
213
+ * signal. This only bounds a call nobody is waiting on any more.
214
+ */
215
+ const CHILD_CALL_TIMEOUT_MS = 65 * 60_000;
169
216
  let reaper: ReturnType<typeof setInterval> | null = null;
170
217
 
171
218
  async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
@@ -217,9 +264,6 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
217
264
  })());
218
265
  })(),
219
266
  handle: {
220
- touch: () => {
221
- entry.lastActivity = Date.now();
222
- },
223
267
  listTools: () =>
224
268
  track(async () => {
225
269
  if (toolsCache) return toolsCache;
@@ -227,12 +271,12 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
227
271
  toolsCache = result.tools;
228
272
  return toolsCache;
229
273
  }),
230
- callTool: (name, args) =>
274
+ callTool: (name, args, signal) =>
231
275
  track(
232
276
  () =>
233
- client.callTool({
234
- name,
235
- arguments: args,
277
+ client.callTool({ name, arguments: args }, undefined, {
278
+ signal,
279
+ timeout: CHILD_CALL_TIMEOUT_MS,
236
280
  }) as Promise<CallToolResult>,
237
281
  ),
238
282
  },
@@ -272,23 +316,26 @@ export function acquireChild(
272
316
  );
273
317
  }
274
318
 
275
- const promise = (async () => {
319
+ const spawn = async (): Promise<ChildHandle> => {
276
320
  try {
277
321
  const handle = await spawnChild(key, spec());
278
- spawnFailures.delete(key);
322
+ if (spawnFailures.delete(key)) {
323
+ const server = serverOf(key);
324
+ resolveAlert(
325
+ `mcp.child.${server}`,
326
+ `MCP server "${server}" is starting normally again.`,
327
+ );
328
+ }
279
329
  return handle;
280
330
  } catch (err) {
281
- const prior = spawnFailures.get(key);
282
- spawnFailures.set(key, {
283
- at: Date.now(),
284
- count: (prior?.count ?? 0) + 1,
285
- error: err,
286
- });
331
+ noteSpawnFailure(key, err);
287
332
  throw err;
288
- } finally {
289
- inflight.delete(key);
290
333
  }
291
- })();
334
+ };
335
+ // `.finally` always runs after the `set` below. A `finally` block inside
336
+ // `spawn` would not: when `spec()` throws synchronously it runs before
337
+ // the set, leaving the rejected promise in `inflight` for good.
338
+ const promise = spawn().finally(() => inflight.delete(key));
292
339
  inflight.set(key, promise);
293
340
  return promise;
294
341
  }
@@ -340,7 +387,9 @@ export function retireAllChildren(): void {
340
387
  function reapIdle(): void {
341
388
  const cutoff = Date.now() - idleTtlMs();
342
389
  for (const [key, entry] of children) {
343
- if (entry.lastActivity >= cutoff) continue;
390
+ // A call outliving the TTL is work, not idleness — closing the child
391
+ // would fail it mid-flight. The first sweep after it drains reaps.
392
+ if (entry.lastActivity >= cutoff || entry.pending > 0) continue;
344
393
  children.delete(key);
345
394
  entry.close().catch((err) => {
346
395
  logError("gateway", `hub reap of ${describeKey(key)} failed`, err);
@@ -1,12 +1,6 @@
1
1
  /**
2
2
  * MCP hub — daemon-hosted MCP over streamable HTTP, one endpoint for
3
- * every backend.
4
- *
5
- * Motivation: every backend used to spawn its own stdio copy of every
6
- * MCP server per chat (claude-sdk/codex per turn; openai-agents and
7
- * kilo/opencode per chat, held indefinitely) — two processes per server
8
- * per chat once the supervisor wrap is counted. Memory grew linearly
9
- * with chats. The hub inverts the ownership: the daemon hosts
3
+ * every backend. The daemon hosts
10
4
  *
11
5
  * /mcp/talon/<frontend>/<chatId> Talon's own tools, IN-PROCESS
12
6
  * (zero subprocesses; chat binding
@@ -20,13 +14,13 @@
20
14
  * (claude-sdk `type:"http"`, openai-agents `MCPServerStreamableHttp`,
21
15
  * codex `mcp_servers.<name>.url`, kilo/opencode `type:"remote"`).
22
16
  *
23
- * What stays exactly as before:
17
+ * Invariants:
24
18
  * - per-chat tool isolation (binding is per-session from the URL)
25
- * - plugin semantics (chat-scoped children keep TALON_CHAT_ID env)
19
+ * - chat-scoped plugin children keep TALON_CHAT_ID in their env
26
20
  * - tool-surface trimming (disabledTools / disabledToolTags)
27
- * - plugin reloading (reload closes children; next request respawns
28
- * from the current registry — see reloadHubChildren)
29
- * - orphan cleanup (children still run under the supervisor wrap)
21
+ * - plugin reload retires children; the next request respawns from
22
+ * the current registry (see reloadHubChildren)
23
+ * - children run under the supervisor wrap (orphan cleanup)
30
24
  *
31
25
  * Endpoints live on the gateway HTTP server (127.0.0.1-bound, behind the
32
26
  * same token and Host/Origin guard as /action — see engine/gateway-auth.ts;
@@ -112,8 +106,7 @@ export function pluginHubUrl(
112
106
  * Names of every hub-served plugin server — what backends enumerate to
113
107
  * build their per-chat URL maps. Registry-backed, so it reflects plugin
114
108
  * reloads immediately. (brave-search is served by the hub too, but each
115
- * backend adds it explicitly alongside its frontend tools, matching the
116
- * pre-hub structure.)
109
+ * backend adds it explicitly alongside its frontend tools.)
117
110
  */
118
111
  export function hubPluginServerNames(only?: string[]): string[] {
119
112
  // Specs are built with placeholder identity — only the names matter.
@@ -134,7 +127,6 @@ export async function listHubPluginToolNames(
134
127
  const child = await acquireChild(childKey(serverName, chatId), () =>
135
128
  pluginSpec(serverName, chatId, bridgeUrl),
136
129
  );
137
- child.touch();
138
130
  return (await child.listTools()).map((tool) => tool.name);
139
131
  }
140
132
 
@@ -253,6 +245,8 @@ function guestPluginDenied(target: { serverName: string; chatId: string }) {
253
245
  type SessionEntry = {
254
246
  transport: StreamableHTTPServerTransport;
255
247
  lastSeen: number;
248
+ /** Requests still open on this session, SSE streams included. */
249
+ open: number;
256
250
  };
257
251
 
258
252
  const sessions = new Map<string, SessionEntry>();
@@ -260,7 +254,11 @@ const sessions = new Map<string, SessionEntry>();
260
254
  /**
261
255
  * Sessions whose client vanished without a DELETE (crashed subprocess,
262
256
  * kill -9) are closed after this idle window. Every well-behaved client
263
- * terminates explicitly, so this only catches stragglers.
257
+ * terminates explicitly, so this only catches stragglers. A session with
258
+ * a request still open is never idle: a client that holds its event
259
+ * stream between turns (openai-agents keeps one per chat for good) is
260
+ * alive however quiet the chat, and it does not re-initialize on a 404.
261
+ * The idle clock restarts when its last open request ends.
264
262
  */
265
263
  const SESSION_IDLE_MS = 30 * 60_000;
266
264
  const SESSION_REAP_INTERVAL_MS = 5 * 60_000;
@@ -271,7 +269,7 @@ function startSessionReaper(): void {
271
269
  sessionReaper = setInterval(() => {
272
270
  const cutoff = Date.now() - SESSION_IDLE_MS;
273
271
  for (const [id, entry] of sessions) {
274
- if (entry.lastSeen >= cutoff) continue;
272
+ if (entry.open > 0 || entry.lastSeen >= cutoff) continue;
275
273
  sessions.delete(id);
276
274
  entry.transport.close().catch(() => {});
277
275
  log("gateway", `hub session reaped (idle): ${id.slice(0, 8)}…`);
@@ -330,6 +328,11 @@ export async function handleHubRequest(
330
328
  return;
331
329
  }
332
330
  entry.lastSeen = Date.now();
331
+ entry.open++;
332
+ res.once("close", () => {
333
+ entry.open--;
334
+ entry.lastSeen = Date.now();
335
+ });
333
336
  await entry.transport.handleRequest(req, res);
334
337
  return;
335
338
  }
@@ -369,7 +372,7 @@ export async function handleHubRequest(
369
372
  enableDnsRebindingProtection: true,
370
373
  allowedHosts: loopbackHosts(bridgeUrl),
371
374
  onsessioninitialized: (id) => {
372
- sessions.set(id, { transport, lastSeen: Date.now() });
375
+ sessions.set(id, { transport, lastSeen: Date.now(), open: 0 });
373
376
  },
374
377
  });
375
378
  transport.onclose = () => {