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.
- package/package.json +2 -1
- package/src/app.ts +30 -0
- package/src/backend/agy/process/orphans.ts +5 -1
- package/src/backend/claude-sdk/one-shot.ts +8 -1
- package/src/backend/codex/factory.ts +5 -1
- package/src/backend/codex/plan-usage.ts +12 -0
- package/src/backend/runtime/turn/turn-phases.ts +7 -1
- package/src/bootstrap.ts +1 -0
- package/src/cli.ts +18 -12
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/index.ts +1 -1
- package/src/core/agents/prompt.ts +29 -2
- package/src/core/agents/runner.ts +7 -1
- package/src/core/agents/types.ts +6 -0
- package/src/core/background/cron/job-oneshot.ts +7 -1
- package/src/core/background/cron/scheduler.ts +28 -14
- package/src/core/background/cron/spec.ts +23 -3
- package/src/core/background/heartbeat/agent.ts +6 -0
- package/src/core/background/triggers/resume.ts +19 -4
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/config/index.ts +82 -0
- package/src/core/daemon/discovery.ts +160 -0
- package/src/core/daemon/pidfile.ts +107 -0
- package/src/core/daemon/respawn.ts +4 -1
- package/src/core/engine/backend-router/breaker.ts +158 -0
- package/src/core/engine/backend-router/headroom.ts +90 -22
- package/src/core/engine/backend-router/index.ts +11 -0
- package/src/core/engine/gateway-actions/agents/control.ts +9 -0
- package/src/core/engine/gateway-actions/agents/index.ts +4 -0
- package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
- package/src/core/engine/gateway-actions/cron.ts +5 -0
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
- package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
- package/src/core/engine/gateway-actions/mesh.ts +4 -0
- package/src/core/engine/gateway-actions/models.ts +19 -4
- package/src/core/engine/gateway-routes.ts +31 -2
- package/src/core/engine/gateway.ts +11 -1
- package/src/core/fetch/classify.ts +73 -0
- package/src/core/fetch/curl-impersonate.ts +447 -0
- package/src/core/fetch/errors.ts +14 -0
- package/src/core/fetch/index.ts +133 -0
- package/src/core/fetch/ladder.ts +401 -0
- package/src/core/fetch/rungs.ts +319 -0
- package/src/core/fetch/types.ts +105 -0
- package/src/core/frontend-runtime/admin-notify.ts +100 -12
- package/src/core/mcp-hub/child-guard.ts +215 -0
- package/src/core/mcp-hub/child-transport.ts +89 -39
- package/src/core/mcp-hub/children.ts +65 -9
- package/src/core/mcp-hub/guest-scope.ts +3 -2
- package/src/core/mcp-hub/index.ts +36 -16
- package/src/core/mcp-hub/launcher.ts +81 -37
- package/src/core/mcp-hub/proxy-server.ts +12 -8
- package/src/core/mcp-hub/reaper.ts +120 -0
- package/src/core/mesh/credentials/index.ts +1 -1
- package/src/core/mesh/credentials/store.ts +1 -1
- package/src/core/mesh/devices/service.ts +8 -0
- package/src/core/mesh/links/bridge-links.ts +37 -0
- package/src/core/mesh/links/companion-pairing.ts +2 -2
- package/src/core/plugin/mcp.ts +8 -8
- package/src/core/tools/bridge.ts +5 -0
- package/src/core/tools/content/web.ts +1 -1
- package/src/core/tools/index.ts +2 -1
- package/src/core/tools/ops/agents.ts +28 -0
- package/src/core/tools/ops/mesh.ts +21 -0
- package/src/core/tools/ops/scheduling.ts +15 -0
- package/src/frontend/native/bridge/credentials/claims.ts +11 -1
- package/src/frontend/telegram/actions/media.ts +164 -12
- package/src/index.ts +22 -19
- package/src/plugins/playwright/version-coupling.ts +172 -62
- package/src/storage/cron.ts +34 -2
- package/src/storage/db.ts +1 -0
- package/src/storage/repositories/cron-repo.ts +9 -0
- package/src/storage/sql/cron.sql +5 -5
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/statements.generated.ts +10 -6
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +2 -0
- 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
|
|
15
|
-
* newline-delimited JSON-RPC over stdin/stdout; `close()` ends stdin
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
message
|
|
172
|
-
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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:
|
|
221
|
-
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>), ...
|
|
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 &&
|
|
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
|
|
86
|
-
*
|
|
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. */
|