talon-agent 5.18.2 → 5.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/prompts/system/agent-brief.md +20 -3
  4. package/src/app.ts +13 -0
  5. package/src/backend/claude-sdk/handler.ts +4 -4
  6. package/src/backend/claude-sdk/mcp-ready.ts +16 -2
  7. package/src/backend/claude-sdk/stream.ts +2 -2
  8. package/src/backend/codex/auth.ts +1 -1
  9. package/src/backend/codex/handler/message.ts +11 -11
  10. package/src/backend/codex/init.ts +4 -9
  11. package/src/backend/codex/mcp-config.ts +1 -2
  12. package/src/backend/codex/oauth-incompat.ts +8 -4
  13. package/src/backend/codex/one-shot.ts +1 -1
  14. package/src/backend/openai-agents/builtins.ts +55 -27
  15. package/src/backend/openai-agents/factory.ts +3 -3
  16. package/src/backend/openai-agents/handler/message.ts +3 -5
  17. package/src/backend/openai-agents/mcp-pool.ts +10 -27
  18. package/src/backend/remote-server/chat-turn.ts +6 -6
  19. package/src/backend/remote-server/events.ts +1 -5
  20. package/src/backend/remote-server/index.ts +0 -1
  21. package/src/backend/remote-server/messages.ts +3 -7
  22. package/src/backend/remote-server/one-shot.ts +1 -3
  23. package/src/backend/remote-server/session-helpers.ts +1 -4
  24. package/src/backend/remote-server/sse-stream.ts +8 -9
  25. package/src/backend/runtime/metrics.ts +7 -13
  26. package/src/backend/runtime/sleep.ts +1 -2
  27. package/src/backend/runtime/turn/handle-retry.ts +48 -2
  28. package/src/backend/runtime/turn/handler-to-events.ts +3 -4
  29. package/src/bootstrap.ts +9 -1
  30. package/src/cli/doctor.ts +3 -0
  31. package/src/cli/index.ts +8 -10
  32. package/src/cli/logs.ts +148 -9
  33. package/src/cli/setup.ts +9 -11
  34. package/src/cli/status.ts +29 -0
  35. package/src/core/agent-runtime/README.md +5 -19
  36. package/src/core/agent-runtime/events.ts +3 -39
  37. package/src/core/agent-runtime/model-ref.ts +0 -8
  38. package/src/core/agents/registry.ts +49 -2
  39. package/src/core/auth/expiry-monitor.ts +9 -1
  40. package/src/core/auth/login-flow.ts +9 -1
  41. package/src/core/auth/status.ts +31 -3
  42. package/src/core/background/cron/scheduler.ts +25 -5
  43. package/src/core/background/dream/index.ts +29 -10
  44. package/src/core/background/failure-backoff.ts +30 -0
  45. package/src/core/background/heartbeat/agent.ts +2 -27
  46. package/src/core/background/heartbeat/index.ts +0 -2
  47. package/src/core/background/heartbeat/scheduler.ts +21 -13
  48. package/src/core/background/heartbeat/state.ts +10 -2
  49. package/src/core/background/isolated-agent.ts +6 -2
  50. package/src/core/background/pulse/pulse.ts +9 -0
  51. package/src/core/background/triggers/exit.ts +54 -0
  52. package/src/core/background/triggers/index.ts +1 -3
  53. package/src/core/background/triggers/resume.ts +2 -4
  54. package/src/core/backup/archive/tar.ts +14 -4
  55. package/src/core/backup/restore.ts +21 -13
  56. package/src/core/backup/scheduler.ts +28 -9
  57. package/src/core/backup/snapshot.ts +43 -10
  58. package/src/core/backup/store.ts +7 -19
  59. package/src/core/backup/targets.ts +69 -17
  60. package/src/core/config/index.ts +14 -0
  61. package/src/core/daemon/crash-marker.ts +141 -0
  62. package/src/core/daemon/crash.ts +9 -2
  63. package/src/core/daemon/handoff.ts +15 -0
  64. package/src/core/daemon/health-alerts.ts +297 -0
  65. package/src/core/daemon/log-reader.ts +289 -0
  66. package/src/core/doctor/index.ts +18 -2
  67. package/src/core/doctor/logs.ts +124 -0
  68. package/src/core/doctor/types.ts +1 -1
  69. package/src/core/engine/backend-controller/index.ts +1 -13
  70. package/src/core/engine/backend-router/router.ts +1 -1
  71. package/src/core/engine/dispatcher.ts +55 -2
  72. package/src/core/engine/fault-text.ts +40 -0
  73. package/src/core/engine/gateway-actions/agents/index.ts +3 -2
  74. package/src/core/engine/gateway-actions/agents/report.ts +62 -0
  75. package/src/core/engine/gateway-actions/history.ts +2 -4
  76. package/src/core/engine/gateway-actions/native/exec.ts +13 -16
  77. package/src/core/engine/gateway.ts +60 -1
  78. package/src/core/engine/turn-health.ts +222 -0
  79. package/src/core/errors.ts +2 -2
  80. package/src/core/frontend-runtime/admin-notify.ts +1 -1
  81. package/src/core/frontend-runtime/alerts.ts +130 -0
  82. package/src/core/mcp-hub/children.ts +78 -29
  83. package/src/core/mcp-hub/index.ts +21 -18
  84. package/src/core/mcp-hub/proxy-server.ts +8 -4
  85. package/src/core/mcp-hub/talon-server.ts +5 -12
  86. package/src/core/mesh/credentials/store.ts +16 -1
  87. package/src/core/mesh/devices/service.ts +26 -15
  88. package/src/core/mesh/devices/teleport.ts +14 -2
  89. package/src/core/mesh/links/node-binaries.ts +13 -6
  90. package/src/core/mesh/persist.ts +22 -10
  91. package/src/core/mesh/transfers/device-files.ts +5 -23
  92. package/src/core/mesh/transfers/transfers.ts +16 -3
  93. package/src/core/models/active-model.ts +2 -55
  94. package/src/core/plugin/actions.ts +19 -20
  95. package/src/core/plugin/builtins.ts +80 -90
  96. package/src/core/plugin/index.ts +1 -4
  97. package/src/core/plugin/loader.ts +25 -33
  98. package/src/core/plugin/mcp.ts +3 -5
  99. package/src/core/plugin/registry.ts +19 -35
  100. package/src/core/plugin/types.ts +2 -5
  101. package/src/core/prompt/assemble.ts +15 -3
  102. package/src/core/scripts/lua.ts +6 -2
  103. package/src/core/tasks/table.ts +8 -2
  104. package/src/core/tools/bridge.ts +2 -4
  105. package/src/core/tools/chat/cross-send.ts +1 -1
  106. package/src/core/tools/chat/messaging.ts +1 -1
  107. package/src/core/tools/index.ts +2 -2
  108. package/src/core/tools/mcp-env.ts +2 -59
  109. package/src/core/tools/ops/agents.ts +22 -1
  110. package/src/core/tools/schemas.ts +4 -9
  111. package/src/core/vfs/fusefs.ts +0 -5
  112. package/src/core/vfs/index.ts +9 -2
  113. package/src/core/vfs/mounts/diagnostics.ts +109 -0
  114. package/src/core/vfs/mounts/proc.ts +17 -1
  115. package/src/core/vfs/workspace.ts +7 -3
  116. package/src/core/weaver/shuttle.ts +8 -1
  117. package/src/core/weaver/turn-log.ts +320 -0
  118. package/src/core/weaver/weaver.ts +48 -5
  119. package/src/frontend/discord/actions/index.ts +8 -1
  120. package/src/frontend/discord/diagnostics.ts +82 -6
  121. package/src/frontend/discord/handlers/index.ts +0 -2
  122. package/src/frontend/discord/middleware.ts +7 -13
  123. package/src/frontend/discord/runtime.ts +1 -3
  124. package/src/frontend/health/delivery.ts +115 -0
  125. package/src/frontend/health/outage.ts +116 -0
  126. package/src/frontend/native/bridge/routes/chats.ts +3 -5
  127. package/src/frontend/native/bridge/server.ts +106 -21
  128. package/src/frontend/native/index.ts +1 -1
  129. package/src/frontend/native/media/media.ts +5 -1
  130. package/src/frontend/native/runtime.ts +12 -7
  131. package/src/frontend/native/surface/handlers.ts +1 -1
  132. package/src/frontend/native/surface/memory.ts +1 -1
  133. package/src/frontend/native/surface/models.ts +3 -3
  134. package/src/frontend/native/surface/settings.ts +20 -8
  135. package/src/frontend/native/turn/context.ts +6 -8
  136. package/src/frontend/native/turn/turn-meta.ts +2 -5
  137. package/src/frontend/native/turn/turn.ts +8 -10
  138. package/src/frontend/presentation/format.ts +2 -4
  139. package/src/frontend/presentation/session-status.ts +2 -6
  140. package/src/frontend/teams/actions.ts +8 -1
  141. package/src/frontend/teams/graph.ts +0 -1
  142. package/src/frontend/teams/index.ts +1 -4
  143. package/src/frontend/teams/poll.ts +40 -2
  144. package/src/frontend/teams/runtime.ts +14 -5
  145. package/src/frontend/telegram/actions/index.ts +4 -1
  146. package/src/frontend/telegram/actions/send.ts +8 -0
  147. package/src/frontend/telegram/handlers/context.ts +13 -2
  148. package/src/frontend/telegram/handlers/delivery.ts +12 -9
  149. package/src/frontend/telegram/handlers/index.ts +0 -2
  150. package/src/frontend/telegram/index.ts +35 -9
  151. package/src/frontend/telegram/polling/poll-health.ts +110 -0
  152. package/src/frontend/telegram/userbot.ts +100 -36
  153. package/src/frontend/terminal/builtins/session.ts +2 -2
  154. package/src/frontend/terminal/index.ts +1 -3
  155. package/src/frontend/terminal/renderer.ts +2 -18
  156. package/src/frontend/whatsapp/actions/index.ts +12 -1
  157. package/src/frontend/whatsapp/actions/messaging.ts +7 -2
  158. package/src/frontend/whatsapp/connection/connection.ts +29 -9
  159. package/src/frontend/whatsapp/connection/health.ts +89 -0
  160. package/src/frontend/whatsapp/connection/identity.ts +4 -4
  161. package/src/frontend/whatsapp/runtime.ts +11 -5
  162. package/src/native/blake3.ts +28 -2
  163. package/src/native/fusefs.ts +23 -5
  164. package/src/native/registry.ts +1 -1
  165. package/src/native/warden.ts +33 -5
  166. package/src/plugins/github/index.ts +0 -1
  167. package/src/plugins/mempalace/index.ts +9 -3
  168. package/src/plugins/playwright/index.ts +2 -4
  169. package/src/plugins/playwright/provision.ts +12 -4
  170. package/src/storage/chat-settings.ts +6 -1
  171. package/src/storage/cron.ts +29 -4
  172. package/src/storage/daily-log.ts +43 -47
  173. package/src/storage/db.ts +61 -33
  174. package/src/storage/history.ts +6 -1
  175. package/src/storage/journal.ts +9 -2
  176. package/src/storage/kv.ts +19 -6
  177. package/src/storage/media-index.ts +28 -6
  178. package/src/storage/repositories/chat-settings-repo.ts +8 -2
  179. package/src/storage/repositories/sessions-repo.ts +10 -3
  180. package/src/storage/scripts.ts +24 -13
  181. package/src/storage/sessions.ts +11 -2
  182. package/src/storage/skills.ts +21 -2
  183. package/src/storage/stickers.ts +17 -3
  184. package/src/storage/triggers.ts +8 -3
  185. package/src/storage/turn-meta.ts +25 -7
  186. package/src/util/log.ts +189 -6
  187. package/src/util/logging/turn-scope.ts +85 -0
  188. package/src/util/time.ts +3 -3
  189. package/src/util/watchdog.ts +30 -0
  190. package/src/core/engine/backend-controller/legacy.ts +0 -111
@@ -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 = () => {
@@ -31,14 +31,18 @@ export function buildProxyServer(
31
31
 
32
32
  server.setRequestHandler(ListToolsRequestSchema, async () => {
33
33
  const child = await getChild();
34
- child.touch();
35
34
  return { tools: await child.listTools() };
36
35
  });
37
36
 
38
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
37
+ // `extra.signal` fires when the upstream client cancels or its session
38
+ // closes; passing it on stops the child's work instead of orphaning it.
39
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
39
40
  const child = await getChild();
40
- child.touch();
41
- return child.callTool(request.params.name, request.params.arguments ?? {});
41
+ return child.callTool(
42
+ request.params.name,
43
+ request.params.arguments ?? {},
44
+ extra.signal,
45
+ );
42
46
  });
43
47
 
44
48
  return server;
@@ -1,17 +1,10 @@
1
1
  /**
2
- * In-process Talon tool server — the hub-side twin of
3
- * `core/tools/mcp-server.ts`.
2
+ * In-process Talon tool server: composeTools + createBridge + textResult,
3
+ * bound to one (frontend, chatId) that arrives per hub session from the
4
+ * request URL. One `McpServer` instance per session, zero processes.
4
5
  *
5
- * Same composition (composeTools + createBridge + textResult), but
6
- * instead of reading `TALON_CHAT_ID`/`TALON_FRONTEND` from the env of a
7
- * dedicated subprocess, the binding arrives per hub session from the
8
- * request URL. One `McpServer` instance per session, zero processes —
9
- * this replaces the two processes (supervisor + server) that every
10
- * chat previously paid per configured frontend.
11
- *
12
- * Tool-surface trimming (`disabledTools` / `disabledToolTags`) applies
13
- * exactly as in the subprocess version, including the `end_turn`
14
- * exemption — tool-only backends need it to close every turn.
6
+ * Tool-surface trimming (`disabledTools` / `disabledToolTags`) never
7
+ * removes `end_turn` — tool-only backends need it to close every turn.
15
8
  */
16
9
 
17
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -24,6 +24,8 @@ import { dirs } from "../../../util/paths.js";
24
24
  import { log, logWarn } from "../../../util/log.js";
25
25
  import { TalonError } from "../../errors.js";
26
26
  import { readArray, writePrivateJson } from "../persist.js";
27
+ import { faultText } from "../../engine/fault-text.js";
28
+ import { raiseAlert, resolveAlert } from "../../frontend-runtime/alerts.js";
27
29
  import {
28
30
  credentialIdOf,
29
31
  digestsEqual,
@@ -65,6 +67,8 @@ export type MintInput = {
65
67
  export type BindResult =
66
68
  { ok: true; credential: DeviceCredential } | { ok: false; error: string };
67
69
 
70
+ const PERSIST_ALERT = "mesh.credentials.persist";
71
+
68
72
  export class DeviceCredentialStore {
69
73
  private records = new Map<string, DeviceCredentialRecord>();
70
74
  private loading: Promise<void> | null = null;
@@ -418,7 +422,18 @@ export class DeviceCredentialStore {
418
422
 
419
423
  private async persist(): Promise<void> {
420
424
  this.lastPersist = this.now();
421
- await writePrivateJson(this.file, [...this.records.values()]);
425
+ try {
426
+ await writePrivateJson(this.file, [...this.records.values()]);
427
+ } catch (err) {
428
+ // A credential that never reached disk is gone after a restart —
429
+ // the device holding it is locked out. Disk faults need a human.
430
+ raiseAlert(
431
+ PERSIST_ALERT,
432
+ `Could not save mesh device credentials: ${faultText(err)}. Newly paired or rotated devices will lose access after a restart until this is fixed.`,
433
+ );
434
+ throw err;
435
+ }
436
+ resolveAlert(PERSIST_ALERT, "Mesh device credentials are saving again.");
422
437
  }
423
438
  }
424
439
 
@@ -69,8 +69,6 @@ export type MeshTransport = {
69
69
  command(command: DeviceCommand): void;
70
70
  };
71
71
 
72
- export type { MeshToolResult } from "../tool-surface.js";
73
-
74
72
  /** Outcome of pinging one device (see {@link MeshService.pingAll}). */
75
73
  export type MeshPingResult = {
76
74
  device: DeviceInfo;
@@ -101,8 +99,6 @@ export type MeshServiceOptions = {
101
99
  credentials?: DeviceCredentialStore;
102
100
  };
103
101
 
104
- export type { MeshBridgeInfo } from "../links/bridge-links.js";
105
-
106
102
  const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
107
103
  const DEFAULT_POLL_INTERVAL_MS = 1_000;
108
104
  const DEFAULT_COMMAND_TIMEOUT_MS = 12_000;
@@ -281,17 +277,14 @@ export class MeshService {
281
277
  * whose deviceId names a DIFFERENT device is dropped (a confused or
282
278
  * misbehaving companion must not be able to answer for its peers).
283
279
  *
284
- * An ABSENT deviceId is dropped too. It used to be tolerated "for older app
285
- * builds", but omitting the field skipped the ownership check entirely —
286
- * which is exactly what a spoofer would do to feed the model fabricated
287
- * exec stdout or a fake install success. The alternative (accept it when
288
- * only one command is pending) was rejected: it still cannot attribute the
289
- * reply, it merely narrows the window to whenever the mesh is idle, which
290
- * is most of the time. Nothing real is lost — `deviceId` has always been
291
- * part of the command-result wire contract (protocol/fixtures/mesh_v1.json,
292
- * asserted by daemon, node and companion alike) and both shipped clients
293
- * send it. An unattributable reply now leaves the command to time out with
294
- * the honest "did not answer" rather than resolving with someone's data.
280
+ * An ABSENT deviceId is dropped too: omitting the field would skip the
281
+ * ownership check, which is exactly what a spoofer would do to feed the
282
+ * model fabricated exec stdout or a fake install success. Accepting it
283
+ * when only one command is pending still cannot attribute the reply — it
284
+ * only narrows the window to whenever the mesh is idle. `deviceId` is part
285
+ * of the command-result wire contract (protocol/fixtures/mesh_v1.json) and
286
+ * every shipped client sends it, so an unattributable reply is left to
287
+ * time out with "did not answer" rather than resolving with someone's data.
295
288
  */
296
289
  completeCommand(body: Record<string, unknown>): boolean {
297
290
  const commandId = typeof body.commandId === "string" ? body.commandId : "";
@@ -317,6 +310,15 @@ export class MeshService {
317
310
  return true;
318
311
  }
319
312
 
313
+ /** Resolve every command still waiting on `deviceId` as failed. */
314
+ private failPendingCommands(deviceId: string, message: string): void {
315
+ for (const [commandId, pending] of this.pendingCommands) {
316
+ if (pending.deviceId !== deviceId) continue;
317
+ this.pendingCommands.delete(commandId);
318
+ pending.resolve({ commandId, deviceId, ok: false, message });
319
+ }
320
+ }
321
+
320
322
  /**
321
323
  * Fail a pending command early when its device stops heartbeating.
322
324
  *
@@ -511,6 +513,15 @@ export class MeshService {
511
513
  const revoked =
512
514
  (await this.credentials?.revokeDevice(removed.id, "device removed")) ??
513
515
  [];
516
+ // With its credential gone it can no longer post a result, and presence
517
+ // can't fail these for it (the registry entry is gone too) — without
518
+ // this, a command in flight waits out its whole budget.
519
+ if (revoked.length > 0) {
520
+ this.failPendingCommands(
521
+ removed.id,
522
+ `${removed.name} was removed from the mesh before it answered.`,
523
+ );
524
+ }
514
525
  return {
515
526
  ok: true,
516
527
  text:
@@ -44,6 +44,12 @@ function stateFile(): string {
44
44
  }
45
45
 
46
46
  let cache: TeleportStore | undefined;
47
+ /**
48
+ * The in-flight first read. Shared so concurrent cold callers all get the
49
+ * one store: separate reads would each build their own, and the last to
50
+ * finish would replace the cache — dropping any teleport set on another.
51
+ */
52
+ let loading: Promise<TeleportStore> | undefined;
47
53
 
48
54
  function emptyStore(): TeleportStore {
49
55
  return { chats: Object.create(null) as Record<string, TeleportState> };
@@ -69,8 +75,13 @@ function parseState(value: unknown): TeleportState | null {
69
75
  };
70
76
  }
71
77
 
72
- async function readStore(): Promise<TeleportStore> {
73
- if (cache !== undefined) return cache;
78
+ function readStore(): Promise<TeleportStore> {
79
+ if (cache !== undefined) return Promise.resolve(cache);
80
+ loading ??= loadStore();
81
+ return loading;
82
+ }
83
+
84
+ async function loadStore(): Promise<TeleportStore> {
74
85
  try {
75
86
  const raw = await readFile(stateFile(), "utf8");
76
87
  const parsed = JSON.parse(raw) as unknown;
@@ -155,4 +166,5 @@ export async function clearTeleport(
155
166
  /** Test seam — drop the in-memory cache so the next read hits disk. */
156
167
  export function resetTeleportCache(): void {
157
168
  cache = undefined;
169
+ loading = undefined;
158
170
  }
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { execFile } from "node:child_process";
25
25
  import { createHash } from "node:crypto";
26
+ import { createReadStream } from "node:fs";
26
27
  import {
27
28
  chmod,
28
29
  mkdir,
@@ -382,12 +383,18 @@ async function pruneStaleVersions(
382
383
  }
383
384
  }
384
385
 
385
- async function hashFile(
386
+ /** Stream a file through SHA-256 without loading it into memory (APKs are big
387
+ * and Buffer has a hard ceiling). Returns the hex digest and byte size. */
388
+ export async function hashFile(
386
389
  path: string,
387
390
  ): Promise<{ sha256: string; size: number }> {
388
- const data = await readFile(path);
389
- return {
390
- sha256: createHash("sha256").update(data).digest("hex"),
391
- size: data.length,
392
- };
391
+ const { size } = await stat(path);
392
+ const hash = createHash("sha256");
393
+ await new Promise<void>((resolve, reject) => {
394
+ createReadStream(path)
395
+ .on("data", (chunk) => hash.update(chunk))
396
+ .on("end", () => resolve())
397
+ .on("error", reject);
398
+ });
399
+ return { sha256: hash.digest("hex"), size };
393
400
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { randomBytes } from "node:crypto";
11
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
11
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
12
12
  import { dirname } from "node:path";
13
13
 
14
14
  /** Per-path write chain: new writes queue onto the tail promise. */
@@ -31,19 +31,31 @@ export async function writePrivateJson(
31
31
  value: unknown,
32
32
  ): Promise<void> {
33
33
  const prior = writeQueues.get(path) ?? Promise.resolve();
34
- const next = prior.catch(() => {}).then(() => atomicWriteJson(path, value));
35
- writeQueues.set(
36
- path,
37
- next.finally(() => {
38
- if (writeQueues.get(path) === next) writeQueues.delete(path);
39
- }),
40
- );
34
+ const next = prior.then(() => atomicWriteJson(path, value));
35
+ // The queued tail must never reject: a failed write is reported to its
36
+ // caller through `next`; a rejecting tail that no later writer happens to
37
+ // chain onto surfaces as a process-level unhandled rejection.
38
+ const tail: Promise<void> = next
39
+ .catch(() => {})
40
+ .then(() => {
41
+ if (writeQueues.get(path) === tail) writeQueues.delete(path);
42
+ });
43
+ writeQueues.set(path, tail);
41
44
  return next;
42
45
  }
43
46
 
44
47
  async function atomicWriteJson(path: string, value: unknown): Promise<void> {
45
48
  await mkdir(dirname(path), { recursive: true });
46
49
  const tmp = `${path}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
47
- await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
48
- await rename(tmp, path);
50
+ try {
51
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
52
+ mode: 0o600,
53
+ });
54
+ await rename(tmp, path);
55
+ } catch (err) {
56
+ // Each attempt names a fresh temp file, so one left behind is never
57
+ // reused — on a full disk every heartbeat would strand another.
58
+ await rm(tmp, { force: true }).catch(() => {});
59
+ throw err;
60
+ }
49
61
  }
@@ -15,8 +15,6 @@
15
15
  */
16
16
 
17
17
  import { randomUUID } from "node:crypto";
18
- import { createHash } from "node:crypto";
19
- import { createReadStream } from "node:fs";
20
18
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
21
19
  import { tmpdir } from "node:os";
22
20
  import { basename, dirname, join, resolve } from "node:path";
@@ -29,6 +27,7 @@ import {
29
27
  type MeshToolResult,
30
28
  } from "../tool-surface.js";
31
29
  import {
30
+ hashFile,
32
31
  normalizeGoarch,
33
32
  platformToGoos,
34
33
  type NodeBinaryResolver,
@@ -70,8 +69,8 @@ const FILE_CHUNK_BYTES = 1024 * 1024;
70
69
  * and never throws, and the command result it would have posted afterwards
71
70
  * is never posted. The dispatch timeout is the only thing that ends that
72
71
  * wait — and since turns serialize per chat, the whole chat's message loop
73
- * queues behind it. A flat hour therefore takes a chat offline for an hour
74
- * over one dropped TCP connection.
72
+ * queues behind it. A flat budget sized for the largest transfer would
73
+ * take a chat offline that long over one dropped TCP connection.
75
74
  *
76
75
  * So the budget is sized to the payload instead: a fixed grace for the
77
76
  * handshake and device-side setup, plus the body at a deliberately
@@ -83,9 +82,8 @@ const STREAM_TRANSFER_FLOOR_BYTES_PER_SEC = 32 * 1024;
83
82
  /**
84
83
  * Ceiling, so a payload large enough to out-scale the formula still can't
85
84
  * block a chat indefinitely. Deliberately well above what a big transfer
86
- * over a slow link needs — the common wedge is a SMALL transfer that used
87
- * to inherit the same budget as a huge one, and that case now resolves in
88
- * about a minute.
85
+ * over a slow link needs — the common wedge is a SMALL transfer, and its
86
+ * budget runs out in about a minute.
89
87
  */
90
88
  const STREAM_TRANSFER_MAX_MS = 30 * 60 * 1000;
91
89
 
@@ -745,19 +743,3 @@ function transferRate(bytes: number, startedAtMs: number): string {
745
743
  const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);
746
744
  return `${formatBytes(bytes / seconds)}/s over ${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)}s`;
747
745
  }
748
-
749
- /** Stream a file through SHA-256 without loading it into memory (APKs are big
750
- * and Buffer has a hard ceiling). Returns the hex digest and byte size. */
751
- async function hashFile(
752
- path: string,
753
- ): Promise<{ sha256: string; size: number }> {
754
- const { size } = await stat(path);
755
- const hash = createHash("sha256");
756
- await new Promise<void>((resolve, reject) => {
757
- createReadStream(path)
758
- .on("data", (chunk) => hash.update(chunk))
759
- .on("end", () => resolve())
760
- .on("error", reject);
761
- });
762
- return { sha256: hash.digest("hex"), size };
763
- }