talon-agent 5.24.0 → 5.25.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 (79) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +30 -0
  3. package/src/backend/agy/process/orphans.ts +5 -1
  4. package/src/backend/claude-sdk/one-shot.ts +8 -1
  5. package/src/backend/codex/factory.ts +5 -1
  6. package/src/backend/codex/plan-usage.ts +12 -0
  7. package/src/backend/runtime/turn/turn-phases.ts +7 -1
  8. package/src/bootstrap.ts +1 -0
  9. package/src/cli.ts +18 -12
  10. package/src/core/agent-runtime/capabilities.ts +7 -0
  11. package/src/core/agents/index.ts +1 -1
  12. package/src/core/agents/prompt.ts +29 -2
  13. package/src/core/agents/runner.ts +7 -1
  14. package/src/core/agents/types.ts +6 -0
  15. package/src/core/background/cron/job-oneshot.ts +7 -1
  16. package/src/core/background/cron/scheduler.ts +28 -14
  17. package/src/core/background/cron/spec.ts +23 -3
  18. package/src/core/background/heartbeat/agent.ts +6 -0
  19. package/src/core/background/triggers/resume.ts +19 -4
  20. package/src/core/background/triggers/spawn.ts +1 -1
  21. package/src/core/config/index.ts +82 -0
  22. package/src/core/daemon/discovery.ts +160 -0
  23. package/src/core/daemon/pidfile.ts +107 -0
  24. package/src/core/daemon/respawn.ts +4 -1
  25. package/src/core/engine/backend-router/breaker.ts +158 -0
  26. package/src/core/engine/backend-router/headroom.ts +90 -22
  27. package/src/core/engine/backend-router/index.ts +11 -0
  28. package/src/core/engine/gateway-actions/agents/control.ts +9 -0
  29. package/src/core/engine/gateway-actions/agents/index.ts +4 -0
  30. package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
  31. package/src/core/engine/gateway-actions/cron.ts +5 -0
  32. package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
  33. package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
  34. package/src/core/engine/gateway-actions/mesh.ts +4 -0
  35. package/src/core/engine/gateway-actions/models.ts +19 -4
  36. package/src/core/engine/gateway-routes.ts +31 -2
  37. package/src/core/engine/gateway.ts +11 -1
  38. package/src/core/fetch/classify.ts +73 -0
  39. package/src/core/fetch/curl-impersonate.ts +447 -0
  40. package/src/core/fetch/errors.ts +14 -0
  41. package/src/core/fetch/index.ts +133 -0
  42. package/src/core/fetch/ladder.ts +401 -0
  43. package/src/core/fetch/rungs.ts +319 -0
  44. package/src/core/fetch/types.ts +105 -0
  45. package/src/core/frontend-runtime/admin-notify.ts +100 -12
  46. package/src/core/mcp-hub/child-guard.ts +215 -0
  47. package/src/core/mcp-hub/child-transport.ts +89 -39
  48. package/src/core/mcp-hub/children.ts +65 -9
  49. package/src/core/mcp-hub/guest-scope.ts +3 -2
  50. package/src/core/mcp-hub/index.ts +36 -16
  51. package/src/core/mcp-hub/launcher.ts +81 -37
  52. package/src/core/mcp-hub/proxy-server.ts +12 -8
  53. package/src/core/mcp-hub/reaper.ts +120 -0
  54. package/src/core/mesh/credentials/index.ts +1 -1
  55. package/src/core/mesh/credentials/store.ts +1 -1
  56. package/src/core/mesh/devices/service.ts +8 -0
  57. package/src/core/mesh/links/bridge-links.ts +37 -0
  58. package/src/core/mesh/links/companion-pairing.ts +2 -2
  59. package/src/core/plugin/mcp.ts +8 -8
  60. package/src/core/tools/bridge.ts +5 -0
  61. package/src/core/tools/content/web.ts +1 -1
  62. package/src/core/tools/index.ts +2 -1
  63. package/src/core/tools/ops/agents.ts +28 -0
  64. package/src/core/tools/ops/mesh.ts +21 -0
  65. package/src/core/tools/ops/scheduling.ts +15 -0
  66. package/src/frontend/native/bridge/credentials/claims.ts +11 -1
  67. package/src/frontend/telegram/actions/media.ts +164 -12
  68. package/src/index.ts +22 -19
  69. package/src/plugins/playwright/version-coupling.ts +172 -62
  70. package/src/storage/cron.ts +34 -2
  71. package/src/storage/db.ts +1 -0
  72. package/src/storage/repositories/cron-repo.ts +9 -0
  73. package/src/storage/sql/cron.sql +5 -5
  74. package/src/storage/sql/db.sql +5 -0
  75. package/src/storage/sql/schema.sql +2 -1
  76. package/src/storage/sql/statements.generated.ts +10 -6
  77. package/src/util/log.ts +2 -1
  78. package/src/util/paths.ts +2 -0
  79. package/src/core/background/triggers/pid.ts +0 -28
@@ -0,0 +1,215 @@
1
+ /**
2
+ * MCP child guard — orphan protection for hub children without a
3
+ * supervisor process per child.
4
+ *
5
+ * The hub used to spawn every MCP child through its own `_mcp-launch`
6
+ * supervisor (a full Talon runtime re-invocation, ~20 MB private memory
7
+ * each). With one child per plugin per chat that was dozens of idle
8
+ * supervisors whose only jobs were stdout filtering and killing the child
9
+ * if Talon died. The hub now does the filtering in-process
10
+ * (child-transport.ts) and spawns children directly; the kill-on-death
11
+ * guarantee moves to ONE reaper process per daemon:
12
+ *
13
+ * daemon ──stdin pipe──▶ reaper (`_mcp-reaper`)
14
+ * "+<pid>\n" a child was spawned — kill it if the daemon dies
15
+ * "-<pid>\n" that child exited (or was closed) — forget it
16
+ *
17
+ * When the reaper's stdin closes — the daemon exited, INCLUDING SIGKILL,
18
+ * because the kernel closes the pipe — or the bridge watchdog trips, it
19
+ * SIGTERMs every registered pid, SIGKILLs survivors after a grace period,
20
+ * and exits. Same shutdown signals, same SIGTERM→SIGKILL escalation and
21
+ * same tolerant BridgeWatchdog as the per-child supervisor (launcher.ts);
22
+ * pipe EOF and `process.kill` behave identically on Linux, macOS and
23
+ * Windows, so no platform-specific mechanism is involved. The reaper is
24
+ * launched through `selfInvocation`, the same re-invocation path the
25
+ * supervisor uses, so it works for tsx/node and bun source runs,
26
+ * bun-compiled binaries and npm installs alike.
27
+ *
28
+ * Fallback: if the reaper cannot be started, or keeps dying right after
29
+ * start, the guard switches to "supervisor" mode and new children are
30
+ * wrapped in the per-child supervisor exactly as before.
31
+ * `TALON_MCP_SUPERVISOR=per-child` forces that mode.
32
+ *
33
+ * The guard is off until the daemon enables it (initHub). Embedders and
34
+ * unit tests that drive the hub directly spawn children unguarded rather
35
+ * than re-invoking an entrypoint that does not dispatch `_mcp-reaper`.
36
+ */
37
+
38
+ import crossSpawn from "cross-spawn";
39
+ import type { ChildProcess } from "node:child_process";
40
+ import { logWarn } from "../../util/log.js";
41
+ import { selfInvocation, wrapMcpServer } from "./launcher.js";
42
+ import { MCP_REAPER_SUBCOMMAND } from "./reaper.js";
43
+
44
+ /** Opt-out env var: `per-child` restores one supervisor per MCP child. */
45
+ const SUPERVISOR_MODE_ENV = "TALON_MCP_SUPERVISOR";
46
+
47
+ // ── Daemon side ─────────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * - off: children spawn unguarded (guard never enabled — tests, embedders).
51
+ * - reaper: children spawn directly and are registered with the reaper.
52
+ * - supervisor: children are wrapped in the per-child supervisor.
53
+ */
54
+ export type ChildGuardMode = "off" | "reaper" | "supervisor";
55
+
56
+ let mode: ChildGuardMode = "off";
57
+ let reaper: ChildProcess | null = null;
58
+ let reaperStartedAt = 0;
59
+ let reaperBridgeUrl: string | undefined;
60
+ let stopping = false;
61
+ /** Consecutive reapers that died within REAPER_EARLY_DEATH_MS of start. */
62
+ let earlyDeaths = 0;
63
+ /** Children currently registered — replayed into a respawned reaper. */
64
+ const guarded = new Set<number>();
65
+
66
+ const REAPER_EARLY_DEATH_MS = 5_000;
67
+ const REAPER_EARLY_DEATHS_BEFORE_FALLBACK = 3;
68
+
69
+ /** Turn the guard on (daemon bootstrap). Idempotent. */
70
+ export function enableChildGuard(): void {
71
+ stopping = false;
72
+ if (mode !== "off") return;
73
+ mode =
74
+ process.env[SUPERVISOR_MODE_ENV] === "per-child" ? "supervisor" : "reaper";
75
+ }
76
+
77
+ export function childGuardMode(): ChildGuardMode {
78
+ return mode;
79
+ }
80
+
81
+ function fallBackToSupervisor(reason: string): void {
82
+ if (mode !== "reaper") return;
83
+ mode = "supervisor";
84
+ logWarn(
85
+ "gateway",
86
+ `mcp reaper unavailable (${reason}); new MCP children fall back to one supervisor each`,
87
+ );
88
+ }
89
+
90
+ function writeLine(line: string): void {
91
+ const stdin = reaper?.stdin;
92
+ if (!stdin || stdin.destroyed) return;
93
+ try {
94
+ stdin.write(`${line}\n`);
95
+ } catch {
96
+ /* reaper gone — its exit handler respawns and replays */
97
+ }
98
+ }
99
+
100
+ function onReaperExit(proc: ChildProcess): void {
101
+ if (reaper !== proc) return;
102
+ reaper = null;
103
+ if (stopping || mode !== "reaper") return;
104
+ const early = Date.now() - reaperStartedAt < REAPER_EARLY_DEATH_MS;
105
+ earlyDeaths = early ? earlyDeaths + 1 : 0;
106
+ if (earlyDeaths >= REAPER_EARLY_DEATHS_BEFORE_FALLBACK) {
107
+ fallBackToSupervisor(`exited ${earlyDeaths} times right after start`);
108
+ return;
109
+ }
110
+ logWarn(
111
+ "gateway",
112
+ `mcp reaper exited (code=${proc.exitCode} signal=${proc.signalCode}); restarting`,
113
+ );
114
+ // Live children must not go unguarded until the next spawn.
115
+ if (guarded.size > 0) ensureReaper();
116
+ }
117
+
118
+ function ensureReaper(): void {
119
+ if (reaper || mode !== "reaper") return;
120
+ let proc: ChildProcess;
121
+ try {
122
+ const inv = selfInvocation(MCP_REAPER_SUBCOMMAND);
123
+ const env: Record<string, string | undefined> = { ...process.env };
124
+ if (reaperBridgeUrl) env.TALON_BRIDGE_URL = reaperBridgeUrl;
125
+ else delete env.TALON_BRIDGE_URL;
126
+ proc = crossSpawn(inv.command, inv.args, {
127
+ stdio: ["pipe", "ignore", "pipe"],
128
+ env,
129
+ windowsHide: true,
130
+ });
131
+ } catch (err) {
132
+ fallBackToSupervisor(err instanceof Error ? err.message : String(err));
133
+ return;
134
+ }
135
+ reaper = proc;
136
+ reaperStartedAt = Date.now();
137
+ proc.stdin?.on("error", () => {});
138
+ proc.stderr?.setEncoding("utf-8");
139
+ proc.stderr?.on("data", (chunk: string) => {
140
+ const text = chunk.trim();
141
+ if (text) logWarn("gateway", `mcp reaper: ${text}`);
142
+ });
143
+ proc.once("error", (err) => {
144
+ // Spawn failure (ENOENT/EACCES): no reaper can ever start this way.
145
+ fallBackToSupervisor(err.message);
146
+ onReaperExit(proc);
147
+ });
148
+ proc.once("exit", () => onReaperExit(proc));
149
+ // Don't keep the daemon's event loop alive just for the reaper.
150
+ proc.unref();
151
+ (proc.stdin as { unref?: () => void } | null)?.unref?.();
152
+ (proc.stderr as { unref?: () => void } | null)?.unref?.();
153
+ for (const pid of guarded) writeLine(`+${pid}`);
154
+ }
155
+
156
+ /**
157
+ * The spawn spec to actually run for `spec`: unchanged when the reaper
158
+ * guards children (or the guard is off), supervisor-wrapped in
159
+ * supervisor mode.
160
+ */
161
+ export function guardedSpec<
162
+ T extends { command: string; args: string[]; env?: Record<string, string> },
163
+ >(spec: T): T {
164
+ return mode === "supervisor" ? wrapMcpServer(spec) : spec;
165
+ }
166
+
167
+ /**
168
+ * Register a freshly spawned child with the reaper. `bridgeUrl` arms the
169
+ * reaper's bridge watchdog (first one seen wins; it is the daemon's own
170
+ * gateway, identical for every child).
171
+ */
172
+ export function guardChild(pid: number, bridgeUrl?: string): void {
173
+ if (mode !== "reaper") return;
174
+ reaperBridgeUrl ??= bridgeUrl;
175
+ guarded.add(pid);
176
+ if (reaper) writeLine(`+${pid}`);
177
+ else ensureReaper(); // replays `guarded`, including this pid
178
+ }
179
+
180
+ /** Forget a child that has exited or been closed. */
181
+ export function releaseChild(pid: number): void {
182
+ if (!guarded.delete(pid)) return;
183
+ writeLine(`-${pid}`);
184
+ }
185
+
186
+ /**
187
+ * Daemon shutdown, after the hub closed its children: close the reaper's
188
+ * stdin so it reaps anything still registered and exits.
189
+ */
190
+ export function stopChildGuard(): void {
191
+ stopping = true;
192
+ const proc = reaper;
193
+ reaper = null;
194
+ proc?.stdin?.end();
195
+ }
196
+
197
+ /** Test-only: reset module state. */
198
+ export function _resetChildGuardForTesting(): void {
199
+ stopChildGuard();
200
+ guarded.clear();
201
+ mode = "off";
202
+ earlyDeaths = 0;
203
+ reaperBridgeUrl = undefined;
204
+ stopping = false;
205
+ }
206
+
207
+ /**
208
+ * Diagnostic: pid of the live reaper, if any.
209
+ *
210
+ * @public — read by the reaper functional test's harness (a separate
211
+ * process knip can't see), not by Talon's own graph.
212
+ */
213
+ export function reaperPid(): number | null {
214
+ return reaper?.pid ?? null;
215
+ }
@@ -11,21 +11,28 @@
11
11
  *
12
12
  * This transport owns the spawn so the hub can record the cause: exit
13
13
  * code + signal, and the last few stderr lines in a bounded ring
14
- * buffer. Wire behaviour matches the SDK transport exactly —
15
- * newline-delimited JSON-RPC over stdin/stdout; `close()` ends stdin,
16
- * then escalates SIGTERM → SIGKILL. Children still run under the
17
- * supervisor wrap (the spec's command IS the supervisor), so orphan
18
- * cleanup and stdout filtering are untouched.
14
+ * buffer. Wire behaviour matches the SDK transport —
15
+ * newline-delimited JSON-RPC over stdin/stdout; `close()` ends stdin and
16
+ * sends SIGTERM, then SIGKILL after a grace period.
17
+ *
18
+ * It also applies the MCP stdout filter in-process (the rule the
19
+ * per-child supervisor used to apply, shared via parseJsonRpcLine):
20
+ * only lines that are JSON objects reach the MCP client; banners and log
21
+ * lines some servers print on stdout are re-routed into the stderr tail,
22
+ * tagged, instead of surfacing as protocol parse errors. Orphan cleanup
23
+ * is the child guard's job (child-guard.ts), fed by onSpawned/onExited.
19
24
  */
20
25
 
21
26
  import crossSpawn from "cross-spawn";
22
27
  import type { ChildProcess } from "node:child_process";
23
- import {
24
- ReadBuffer,
25
- serializeMessage,
26
- } from "@modelcontextprotocol/sdk/shared/stdio.js";
28
+ import { StringDecoder } from "node:string_decoder";
29
+ import { serializeMessage } from "@modelcontextprotocol/sdk/shared/stdio.js";
27
30
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
28
- import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
31
+ import {
32
+ JSONRPCMessageSchema,
33
+ type JSONRPCMessage,
34
+ } from "@modelcontextprotocol/sdk/types.js";
35
+ import { parseJsonRpcLine, STDOUT_REROUTE_TAG } from "./launcher.js";
29
36
 
30
37
  /** How many trailing stderr lines a child keeps for its exit report. */
31
38
  export const STDERR_TAIL_LINES = 20;
@@ -78,8 +85,51 @@ export type HubChildTransportOptions = {
78
85
  command: string;
79
86
  args: string[];
80
87
  env: Record<string, string>;
88
+ /** Called with the pid once the process has spawned (orphan guard). */
89
+ onSpawned?: (pid: number) => void;
90
+ /** Called with the pid once the process has exited (orphan guard). */
91
+ onExited?: (pid: number) => void;
81
92
  };
82
93
 
94
+ /**
95
+ * Splits child stdout into lines and applies the MCP stdout filter:
96
+ * JSON-object lines go to `onLine` (already parsed, so each line is
97
+ * parsed once), everything else non-blank to `onReject`. A trailing
98
+ * fragment without a newline is held until more data arrives or
99
+ * `flush()` runs at end of stream.
100
+ */
101
+ export class StdoutLineFilter {
102
+ private buf = "";
103
+ private readonly decoder = new StringDecoder("utf8");
104
+
105
+ constructor(
106
+ private readonly onLine: (parsed: object) => void,
107
+ private readonly onReject: (line: string) => void,
108
+ ) {}
109
+
110
+ push(chunk: Buffer | string): void {
111
+ this.buf += typeof chunk === "string" ? chunk : this.decoder.write(chunk);
112
+ let nl;
113
+ while ((nl = this.buf.indexOf("\n")) !== -1) {
114
+ const line = this.buf.slice(0, nl);
115
+ this.buf = this.buf.slice(nl + 1);
116
+ this.emit(line);
117
+ }
118
+ }
119
+
120
+ flush(): void {
121
+ const rest = this.buf + this.decoder.end();
122
+ this.buf = "";
123
+ if (rest.length > 0) this.emit(rest);
124
+ }
125
+
126
+ private emit(line: string): void {
127
+ const parsed = parseJsonRpcLine(line);
128
+ if (parsed !== null) this.onLine(parsed);
129
+ else if (line.trim().length > 0) this.onReject(line.replace(/\r$/, ""));
130
+ }
131
+ }
132
+
83
133
  const CLOSE_GRACE_MS = 2_000;
84
134
 
85
135
  function delay(ms: number): Promise<void> {
@@ -94,8 +144,12 @@ export class HubChildTransport implements Transport {
94
144
  private child: ChildProcess | null = null;
95
145
  /** Kept past exit so the exit report can name the process. */
96
146
  private spawnedPid: number | null = null;
97
- private readonly readBuffer = new ReadBuffer();
98
147
  private readonly stderrTail = new StderrTail();
148
+ private readonly stdoutFilter = new StdoutLineFilter(
149
+ (parsed) => this.deliver(parsed),
150
+ // Not protocol: keep it visible where a failure report will look.
151
+ (line) => this.stderrTail.push(`${STDOUT_REROUTE_TAG}${line}\n`),
152
+ );
99
153
  private exitInfo: ChildExit | null = null;
100
154
  private closeRequested = false;
101
155
 
@@ -133,11 +187,18 @@ export class HubChildTransport implements Transport {
133
187
  });
134
188
  this.child = child;
135
189
  this.spawnedPid = child.pid ?? null;
190
+ const pid = child.pid;
191
+ if (pid !== undefined) this.opts.onSpawned?.(pid);
136
192
  child.once("spawn", () => resolve());
137
193
  child.on("error", (err) => {
138
194
  reject(err);
139
195
  this.onerror?.(err);
140
196
  });
197
+ // The guard forgets the pid as soon as the process is gone, so a
198
+ // recycled pid is never reaped on the daemon's behalf.
199
+ if (pid !== undefined) {
200
+ child.once("exit", () => this.opts.onExited?.(pid));
201
+ }
141
202
  // `close` (not `exit`) so the stderr tail is complete when the
142
203
  // exit report is read.
143
204
  child.on("close", (code, signal) => {
@@ -147,35 +208,24 @@ export class HubChildTransport implements Transport {
147
208
  });
148
209
  child.stdin?.on("error", (err) => this.onerror?.(err));
149
210
  child.stdout?.on("error", (err) => this.onerror?.(err));
150
- child.stdout?.on("data", (chunk: Buffer) => {
151
- try {
152
- this.readBuffer.append(chunk);
153
- this.drainMessages();
154
- } catch (err) {
155
- this.onerror?.(err as Error);
156
- void this.close();
157
- }
158
- });
211
+ child.stdout?.on("data", (chunk: Buffer) =>
212
+ this.stdoutFilter.push(chunk),
213
+ );
214
+ child.stdout?.on("end", () => this.stdoutFilter.flush());
159
215
  child.stderr?.setEncoding("utf-8");
160
216
  child.stderr?.on("data", (chunk: string) => this.stderrTail.push(chunk));
161
217
  child.stderr?.on("error", () => {});
162
218
  });
163
219
  }
164
220
 
165
- private drainMessages(): void {
166
- for (;;) {
167
- let message: JSONRPCMessage | null;
168
- try {
169
- // readMessage consumes the line before parsing, so a malformed
170
- // line is skipped rather than re-read forever.
171
- message = this.readBuffer.readMessage();
172
- } catch (err) {
173
- this.onerror?.(err as Error);
174
- continue;
175
- }
176
- if (message === null) return;
177
- this.onmessage?.(message);
178
- }
221
+ /** Validate one parsed stdout line as JSON-RPC and hand it on. */
222
+ private deliver(parsed: object): void {
223
+ const result = JSONRPCMessageSchema.safeParse(parsed);
224
+ if (result.success) this.onmessage?.(result.data);
225
+ else
226
+ this.onerror?.(
227
+ new Error(`Invalid JSON-RPC message: ${result.error.message}`),
228
+ );
179
229
  }
180
230
 
181
231
  async close(): Promise<void> {
@@ -187,15 +237,15 @@ export class HubChildTransport implements Transport {
187
237
  child.once("close", () => resolve());
188
238
  });
189
239
  const alive = () => child.exitCode === null && child.signalCode === null;
240
+ // EOF and SIGTERM together, SIGKILL after the grace — what the
241
+ // per-child supervisor did when its stdin closed, so a child sees
242
+ // the same shutdown whether or not a supervisor sits in between
243
+ // (with one, it escalates within its own 1s, inside our grace).
190
244
  child.stdin?.end();
245
+ if (alive()) child.kill("SIGTERM");
191
246
  await Promise.race([closed, delay(CLOSE_GRACE_MS)]);
192
- if (alive()) {
193
- child.kill("SIGTERM");
194
- await Promise.race([closed, delay(CLOSE_GRACE_MS)]);
195
- }
196
247
  if (alive()) child.kill("SIGKILL");
197
248
  }
198
- this.readBuffer.clear();
199
249
  }
200
250
 
201
251
  send(message: JSONRPCMessage): Promise<void> {
@@ -11,11 +11,20 @@
11
11
  * use a shared key. Either way the spec factory decides — this module
12
12
  * only manages lifecycles.
13
13
  *
14
- * Each child is spawned through the supervisor wrap (stdout JSON
15
- * filtering + orphan cleanup if the daemon is SIGKILLed),
16
- * connected once over stdio, and shared by every hub session that
17
- * proxies to it. The tools list is cached per child lifetime — plugin
18
- * reload restarts children, which naturally invalidates the cache.
14
+ * Each child is spawned directly by the daemon — stdout JSON filtering
15
+ * happens in-process (child-transport.ts) and orphan cleanup if the
16
+ * daemon is SIGKILLed is the child guard's job (one reaper per daemon,
17
+ * child-guard.ts; per-child supervisor wrap as the fallback) — then
18
+ * connected once over stdio and shared by every hub session that
19
+ * proxies to it.
20
+ *
21
+ * Tool lists are cached per SERVER, not just per child: a chat-scoped
22
+ * plugin serves the same tools to every chat, so a new session's
23
+ * tools/list is answered from the cache and the chat's own child is only
24
+ * spawned when a tool is actually called. Without this every session —
25
+ * each chat, sub-agent and isolated cron/heartbeat run — spawned the
26
+ * whole plugin fleet just to enumerate tools at session start. Plugin
27
+ * reload restarts children and clears the cache.
19
28
  *
20
29
  * Every exit is accounted for: the child's exit code, signal and
21
30
  * stderr tail are logged when it goes away unasked (handshake death,
@@ -29,11 +38,14 @@ import { log, logError, logWarn } from "../../util/log.js";
29
38
  import { HubChildTransport, type ChildExit } from "./child-transport.js";
30
39
  import { raiseAlert, resolveAlert } from "../frontend-runtime/alerts.js";
31
40
  import { faultText } from "../engine/fault-text.js";
41
+ import { guardChild, guardedSpec, releaseChild } from "./child-guard.js";
32
42
 
33
43
  export type ChildSpec = {
34
44
  command: string;
35
45
  args: string[];
36
46
  env?: Record<string, string>;
47
+ /** The daemon gateway's base URL — arms the reaper's bridge watchdog. */
48
+ bridgeUrl?: string;
37
49
  };
38
50
 
39
51
  export type ChildHandle = {
@@ -69,6 +81,11 @@ type ChildEntry = {
69
81
  const children = new Map<string, ChildEntry>();
70
82
  const inflight = new Map<string, Promise<ChildHandle>>();
71
83
 
84
+ /** Server name → its tools, as last listed by any of its children. */
85
+ const toolListCache = new Map<string, Tool[]>();
86
+ /** Bumped on reload/shutdown so an old child can't refill the cache. */
87
+ let toolListGeneration = 0;
88
+
72
89
  /**
73
90
  * Most recent exit per key, whether the child died during the
74
91
  * handshake (never registered) or later. Consulted by the backends'
@@ -216,12 +233,16 @@ const CHILD_CALL_TIMEOUT_MS = 65 * 60_000;
216
233
  let reaper: ReturnType<typeof setInterval> | null = null;
217
234
 
218
235
  async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
236
+ const run = guardedSpec(spec);
237
+ const generation = toolListGeneration;
219
238
  const transport = new HubChildTransport({
220
- command: spec.command,
221
- args: spec.args,
239
+ command: run.command,
240
+ args: run.args,
222
241
  // Merge over the daemon env — same visibility the SDK-spawned
223
242
  // subprocesses had (PATH, HOME, proxy vars, …).
224
- env: { ...(process.env as Record<string, string>), ...spec.env },
243
+ env: { ...(process.env as Record<string, string>), ...run.env },
244
+ onSpawned: (pid) => guardChild(pid, spec.bridgeUrl),
245
+ onExited: releaseChild,
225
246
  });
226
247
  transport.onclose = () => onChildClosed(key, transport);
227
248
  const client = new Client(
@@ -269,6 +290,9 @@ async function spawnChild(key: string, spec: ChildSpec): Promise<ChildHandle> {
269
290
  if (toolsCache) return toolsCache;
270
291
  const result = await client.listTools();
271
292
  toolsCache = result.tools;
293
+ if (generation === toolListGeneration) {
294
+ toolListCache.set(serverOf(key), result.tools);
295
+ }
272
296
  return toolsCache;
273
297
  }),
274
298
  callTool: (name, args, signal) =>
@@ -308,7 +332,7 @@ export function acquireChild(
308
332
  if (pending) return pending;
309
333
 
310
334
  const failure = spawnFailures.get(key);
311
- if (failure && Date.now() - failure.at < failureBackoffMs(failure.count)) {
335
+ if (failure && inSpawnBackoff(key)) {
312
336
  return Promise.reject(
313
337
  failure.error instanceof Error
314
338
  ? failure.error
@@ -340,10 +364,39 @@ export function acquireChild(
340
364
  return promise;
341
365
  }
342
366
 
367
+ /**
368
+ * tools/list for `key` without spawning when avoidable: served from the
369
+ * per-server cache once any child of that server has listed its tools
370
+ * (see header). Falls through to the chat's own child when nothing is
371
+ * cached yet, or when this key is inside its spawn-failure backoff —
372
+ * a server that cannot start keeps failing its listing, as before,
373
+ * rather than advertising tools every call to which would fail.
374
+ */
375
+ export async function listChildTools(
376
+ key: string,
377
+ spec: () => ChildSpec,
378
+ ): Promise<Tool[]> {
379
+ const live = children.get(key);
380
+ const cached = toolListCache.get(serverOf(key));
381
+ if (!live && cached && !inSpawnBackoff(key)) return cached;
382
+ const child = await acquireChild(key, spec);
383
+ return child.listTools();
384
+ }
385
+
386
+ function inSpawnBackoff(key: string): boolean {
387
+ const failure = spawnFailures.get(key);
388
+ return (
389
+ failure !== undefined &&
390
+ Date.now() - failure.at < failureBackoffMs(failure.count)
391
+ );
392
+ }
393
+
343
394
  /** Close every child immediately. Daemon shutdown path. */
344
395
  export async function closeAllChildren(): Promise<void> {
345
396
  const entries = [...children.values()];
346
397
  children.clear();
398
+ toolListCache.clear();
399
+ toolListGeneration++;
347
400
  await Promise.allSettled(entries.map((entry) => entry.close()));
348
401
  if (entries.length > 0) {
349
402
  log("gateway", `hub: closed ${entries.length} MCP child(ren)`);
@@ -366,6 +419,9 @@ const RETIRE_GRACE_MS = 60_000;
366
419
  export function retireAllChildren(): void {
367
420
  const entries = [...children.values()];
368
421
  children.clear();
422
+ // Reloaded plugin code may expose different tools.
423
+ toolListCache.clear();
424
+ toolListGeneration++;
369
425
  for (const entry of entries) {
370
426
  entry.retired = true;
371
427
  if (entry.pending === 0) {
@@ -82,12 +82,13 @@ export const GUEST_TOOL_ALLOWLIST: ReadonlySet<string> = new Set([
82
82
  ]);
83
83
 
84
84
  /**
85
- * Tools whose output is a live credential (an install command embedding
86
- * the bridge bearer token). Operator-only, and their output is delivered
85
+ * Tools whose output is a live credential (an install command or pairing
86
+ * link embedding a bridge bearer token). Operator-only, and their output is delivered
87
87
  * to the operator's private chat, never into a shared one.
88
88
  */
89
89
  export const OPERATOR_PRIVATE_OUTPUT_TOOLS: ReadonlySet<string> = new Set([
90
90
  "make_node_install_link",
91
+ "make_companion_pair_link",
91
92
  ]);
92
93
 
93
94
  /** Parameters that name another chat. A guest may only name its own. */