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
@@ -20,7 +20,10 @@
20
20
  * - tool-surface trimming (disabledTools / disabledToolTags)
21
21
  * - plugin reload retires children; the next request respawns from
22
22
  * the current registry (see reloadHubChildren)
23
- * - children run under the supervisor wrap (orphan cleanup)
23
+ * - children are orphan-guarded (one reaper per daemon, falling back
24
+ * to the per-child supervisor wrap — see child-guard.ts)
25
+ * - a session's tools/list does not spawn its chat's child when any
26
+ * chat already listed that server (see listChildTools)
24
27
  *
25
28
  * Endpoints live on the gateway HTTP server (127.0.0.1-bound, behind the
26
29
  * same token and Host/Origin guard as /action — see engine/gateway-auth.ts;
@@ -33,7 +36,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
33
36
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
34
37
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
35
38
  import { getPluginMcpServers } from "../plugin/index.js";
36
- import { wrapMcpServer } from "./launcher.js";
39
+ import { enableChildGuard, stopChildGuard } from "./child-guard.js";
37
40
  import { log, logError } from "../../util/log.js";
38
41
  import { buildTalonToolServer, VALID_TOOL_FRONTENDS } from "./talon-server.js";
39
42
  import { buildProxyServer } from "./proxy-server.js";
@@ -42,6 +45,7 @@ import {
42
45
  closeAllChildren,
43
46
  formatChildExit,
44
47
  getLastChildExit,
48
+ listChildTools,
45
49
  retireAllChildren,
46
50
  startChildReaper,
47
51
  stopChildReaper,
@@ -71,6 +75,12 @@ export type HubConfig = {
71
75
  adminUserId?: number;
72
76
  /** Further operator sender keys (see guest-scope.ts). */
73
77
  operatorIds?: readonly string[];
78
+ /**
79
+ * Orphan-guard hub children with the reaper (child-guard.ts). Set by the
80
+ * daemon bootstrap, whose entrypoint dispatches `_mcp-reaper`; left off
81
+ * by embedders/tests that drive the hub from an entry that doesn't.
82
+ */
83
+ guardChildren?: boolean;
74
84
  };
75
85
 
76
86
  let hubConfig: HubConfig = {};
@@ -79,6 +89,7 @@ let hubConfig: HubConfig = {};
79
89
  export function initHub(config: HubConfig): void {
80
90
  hubConfig = config;
81
91
  initGuestDmScope(config.guestDmScope, config.adminUserId, config.operatorIds);
92
+ if (config.guardChildren) enableChildGuard();
82
93
  startChildReaper();
83
94
  }
84
95
 
@@ -124,10 +135,10 @@ export async function listHubPluginToolNames(
124
135
  chatId: string,
125
136
  bridgeUrl: string,
126
137
  ): Promise<string[]> {
127
- const child = await acquireChild(childKey(serverName, chatId), () =>
138
+ const tools = await listChildTools(childKey(serverName, chatId), () =>
128
139
  pluginSpec(serverName, chatId, bridgeUrl),
129
140
  );
130
- return (await child.listTools()).map((tool) => tool.name);
141
+ return tools.map((tool) => tool.name);
131
142
  }
132
143
 
133
144
  /** Stderr lines quoted in a registration-failure warning (full tail is in the exit log line). */
@@ -181,15 +192,16 @@ function parseHubPath(rawUrl: string): HubTarget | null {
181
192
  return null;
182
193
  }
183
194
 
184
- function braveSpec(): ChildSpec {
185
- return wrapMcpServer({
195
+ function braveSpec(bridgeUrl: string): ChildSpec {
196
+ return {
186
197
  command: resolve(
187
198
  import.meta.dirname ?? ".",
188
199
  "../../../node_modules/.bin/brave-search-mcp-server",
189
200
  ),
190
201
  args: [],
191
202
  env: { BRAVE_API_KEY: hubConfig.braveApiKey ?? "" },
192
- });
203
+ bridgeUrl,
204
+ };
193
205
  }
194
206
 
195
207
  /**
@@ -202,11 +214,16 @@ function pluginSpec(
202
214
  chatId: string,
203
215
  bridgeUrl: string,
204
216
  ): ChildSpec {
205
- if (serverName === "brave-search") return braveSpec();
217
+ if (serverName === "brave-search") return braveSpec(bridgeUrl);
206
218
  const specs = getPluginMcpServers(bridgeUrl, chatId);
207
219
  const spec = specs[serverName];
208
220
  if (!spec) throw new Error(`Unknown hub plugin server: ${serverName}`);
209
- return { command: spec.command, args: [...spec.args], env: spec.env };
221
+ return {
222
+ command: spec.command,
223
+ args: [...spec.args],
224
+ env: spec.env,
225
+ bridgeUrl,
226
+ };
210
227
  }
211
228
 
212
229
  /** Test-only: expose the spec resolution for the pass-through contract. */
@@ -226,13 +243,15 @@ function buildServerFor(target: HubTarget, bridgeUrl: string) {
226
243
  }
227
244
  // Re-checked per request: a session opened during an operator turn must
228
245
  // not serve a later guest turn in the same chat.
229
- return buildProxyServer(target.serverName, () => {
230
- if (guestPluginDenied(target)) {
231
- return Promise.reject(new Error("Not available in this chat"));
232
- }
233
- return acquireChild(childKey(target.serverName, target.chatId), () =>
234
- pluginSpec(target.serverName, target.chatId, bridgeUrl),
235
- );
246
+ const key = childKey(target.serverName, target.chatId);
247
+ const spec = () => pluginSpec(target.serverName, target.chatId, bridgeUrl);
248
+ const denied = () =>
249
+ guestPluginDenied(target)
250
+ ? Promise.reject(new Error("Not available in this chat"))
251
+ : null;
252
+ return buildProxyServer(target.serverName, {
253
+ listTools: () => denied() ?? listChildTools(key, spec),
254
+ getChild: () => denied() ?? acquireChild(key, spec),
236
255
  });
237
256
  }
238
257
 
@@ -412,6 +431,7 @@ export async function shutdownHub(): Promise<void> {
412
431
  sessions.clear();
413
432
  await Promise.allSettled(entries.map((entry) => entry.transport.close()));
414
433
  await closeAllChildren();
434
+ stopChildGuard();
415
435
  }
416
436
 
417
437
  /** Diagnostic: live hub session count. */
@@ -1,11 +1,19 @@
1
1
  /**
2
2
  * MCP supervisor — one universal launch method for every install type.
3
3
  *
4
- * Every MCP stdio server Talon hands to a backend SDK is wrapped
5
- * through a supervisor process that proxies stdio, filters non-JSON
4
+ * The per-child supervisor process proxies stdio, filters non-JSON
6
5
  * stdout lines, and kills the child when Talon dies — no orphans, no
7
6
  * /proc scan, no per-plugin signature list.
8
7
  *
8
+ * The hub no longer runs one per MCP child by default: it spawns
9
+ * children itself, filters stdout in-process (child-transport.ts, same
10
+ * parseJsonRpcLine rule) and guards them with ONE reaper process per
11
+ * daemon (child-guard.ts / reaper.ts, same stdin-EOF + BridgeWatchdog +
12
+ * SIGTERM→SIGKILL semantics as below). Wrapping a child in this
13
+ * supervisor is the fallback when the reaper can't run, or when
14
+ * `TALON_MCP_SUPERVISOR=per-child` asks for it. `selfInvocation` below
15
+ * is how both helpers are launched.
16
+ *
9
17
  * The supervisor is Talon itself: `wrapMcpServer()` re-invokes the
10
18
  * current process (`process.execPath` + `process.execArgv` + entry
11
19
  * script) with the hidden `_mcp-launch` subcommand, the same
@@ -28,8 +36,8 @@
28
36
  * }
29
37
  *
30
38
  * src/index.ts and src/cli.ts are the two real entrypoints; both
31
- * dispatch. index.ts additionally defers the whole app graph behind a
32
- * dynamic import so supervisor processes stay light.
39
+ * dispatch (the reaper's `_mcp-reaper` too), each helper behind its own
40
+ * dynamic import so helper processes load only their own module.
33
41
  *
34
42
  * Child-shutdown signals (both terminate the child with SIGTERM, then
35
43
  * SIGKILL after a grace period):
@@ -107,7 +115,8 @@ function isEmbeddedEntry(entry: string): boolean {
107
115
  * The command prefix that re-invokes the current Talon process with a
108
116
  * hidden subcommand, in every install shape (tsx source run, bun source
109
117
  * run, bun-compiled binary, npm install). Used for MCP supervision
110
- * (`_mcp-launch`) and the WASM Lua trigger runner (`_lua-run`) — any
118
+ * (`_mcp-launch`, `_mcp-reaper`) and the WASM Lua trigger runner
119
+ * (`_lua-run`) — any
111
120
  * subcommand passed here must be dispatched by src/index.ts and
112
121
  * src/cli.ts before the app graph loads.
113
122
  */
@@ -275,6 +284,63 @@ export class BridgeWatchdog {
275
284
  }
276
285
  }
277
286
 
287
+ /** Tag prefixed to child stdout lines that were re-routed to stderr. */
288
+ export const STDOUT_REROUTE_TAG = "[mcp-launcher: stdout→stderr] ";
289
+
290
+ /**
291
+ * Parse one line of MCP child stdout under the stdio filter rule: a
292
+ * JSON-RPC line must be a JSON OBJECT (starts with `{`, parses). Returns
293
+ * the parsed value, or null for anything else — banners, log lines,
294
+ * `[timestamp] [INFO] …`, a stray `{ tip: "…" }` that isn't JSON. Both
295
+ * the per-child supervisor and the hub's in-process transport use this,
296
+ * so the two filtering paths cannot drift apart.
297
+ */
298
+ export function parseJsonRpcLine(line: string): object | null {
299
+ const s = line.trimStart();
300
+ if (s.length === 0 || s[0] !== "{") return null;
301
+ try {
302
+ const parsed: unknown = JSON.parse(s);
303
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Run the bridge-health watchdog: ping `${bridgeUrl}/health` every
311
+ * BRIDGE_PING_INTERVAL_MS (first tick staggered randomly), and call
312
+ * `onTrip` once BridgeWatchdog says the daemon is gone. `isStopping`
313
+ * short-circuits ticks after shutdown began. Shared by the per-child
314
+ * supervisor and the hub's reaper so both keep identical semantics.
315
+ */
316
+ export function startBridgeWatchdog(
317
+ bridgeUrl: string,
318
+ isStopping: () => boolean,
319
+ onTrip: (outcome: BridgePingOutcome, downForSec: number) => void,
320
+ ): void {
321
+ const watchdog = new BridgeWatchdog();
322
+ const tick = async (): Promise<void> => {
323
+ if (isStopping()) return;
324
+ const outcome = await pingBridge(bridgeUrl);
325
+ if (isStopping()) return;
326
+ if (watchdog.record(outcome)) {
327
+ onTrip(
328
+ outcome,
329
+ watchdog.consecutiveFailures * (BRIDGE_PING_INTERVAL_MS / 1000),
330
+ );
331
+ }
332
+ };
333
+ // Stagger first tick so a process-wide restart doesn't have every
334
+ // watchdog pinging the bridge in lockstep.
335
+ const initialDelay = Math.floor(Math.random() * BRIDGE_PING_INTERVAL_MS);
336
+ const startTimer = setTimeout(() => {
337
+ void tick();
338
+ const interval = setInterval(() => void tick(), BRIDGE_PING_INTERVAL_MS);
339
+ interval.unref?.();
340
+ }, initialDelay);
341
+ startTimer.unref?.();
342
+ }
343
+
278
344
  /**
279
345
  * Run the supervisor over `argvTail` = [cmd, ...args].
280
346
  *
@@ -331,16 +397,7 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
331
397
  // `[ISO-timestamp] [INFO] …`). Restrict to `{` and verify it's
332
398
  // parseable JSON before forwarding, so a stray `{ tip: "..." }`
333
399
  // shell-style line that isn't valid JSON still goes to stderr.
334
- const looksJson = (line: string): boolean => {
335
- const s = line.trimStart();
336
- if (s.length === 0 || s[0] !== "{") return false;
337
- try {
338
- JSON.parse(s);
339
- return true;
340
- } catch {
341
- return false;
342
- }
343
- };
400
+ const looksJson = (line: string): boolean => parseJsonRpcLine(line) !== null;
344
401
  let buf = "";
345
402
  child.stdout.setEncoding("utf-8");
346
403
  child.stdout.on("data", (chunk: string) => {
@@ -352,7 +409,7 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
352
409
  if (looksJson(line)) {
353
410
  process.stdout.write(line);
354
411
  } else if (line.trim().length > 0) {
355
- process.stderr.write(`[mcp-launcher: stdout→stderr] ${line}`);
412
+ process.stderr.write(`${STDOUT_REROUTE_TAG}${line}`);
356
413
  }
357
414
  }
358
415
  });
@@ -361,7 +418,7 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
361
418
  if (looksJson(buf)) {
362
419
  process.stdout.write(buf);
363
420
  } else {
364
- process.stderr.write(`[mcp-launcher: stdout→stderr] ${buf}`);
421
+ process.stderr.write(`${STDOUT_REROUTE_TAG}${buf}`);
365
422
  }
366
423
  buf = "";
367
424
  });
@@ -416,33 +473,20 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
416
473
  // (every Talon-spawned MCP server has it; ad-hoc supervisor uses
417
474
  // without the env var keep the stdin-EOF-only behavior).
418
475
  if (BRIDGE_URL) {
419
- const watchdog = new BridgeWatchdog();
420
- const tick = async (): Promise<void> => {
421
- if (terminating) return;
422
- const outcome = await pingBridge(BRIDGE_URL);
423
- if (terminating) return;
424
- if (watchdog.record(outcome)) {
476
+ startBridgeWatchdog(
477
+ BRIDGE_URL,
478
+ () => terminating,
479
+ (outcome, downForSec) => {
425
480
  // Talon's gateway is gone (or wedged for minutes). The MCP child
426
481
  // has nothing useful to serve — bridge calls would 404 against a
427
482
  // dead port — so shut down. Kilo/OpenCode notice the stdio close
428
483
  // on the next interaction and drop the registration on their side.
429
484
  process.stderr.write(
430
- `mcp-launcher: bridge ${BRIDGE_URL} ${outcome} for ${
431
- watchdog.consecutiveFailures * (BRIDGE_PING_INTERVAL_MS / 1000)
432
- }s; shutting down child\n`,
485
+ `mcp-launcher: bridge ${BRIDGE_URL} ${outcome} for ${downForSec}s; shutting down child\n`,
433
486
  );
434
487
  terminate(0);
435
- }
436
- };
437
- // Stagger first tick so a process-wide restart doesn't have every
438
- // supervisor pinging the bridge in lockstep.
439
- const initialDelay = Math.floor(Math.random() * BRIDGE_PING_INTERVAL_MS);
440
- const startTimer = setTimeout(() => {
441
- void tick();
442
- const interval = setInterval(() => void tick(), BRIDGE_PING_INTERVAL_MS);
443
- interval.unref?.();
444
- }, initialDelay);
445
- startTimer.unref?.();
488
+ },
489
+ );
446
490
  }
447
491
 
448
492
  // The child + stdin pipe keep the event loop alive; exit happens via
@@ -3,9 +3,11 @@
3
3
  * and tools/call to a hub-managed child (see children.ts).
4
4
  *
5
5
  * One proxy per hub session; many sessions share one child. The child
6
- * is re-acquired through `getChild` on every request, so a child that
6
+ * is re-acquired through `getChild` on every call, so a child that
7
7
  * was idle-reaped or crashed respawns transparently mid-session —
8
8
  * clients never see the lifecycle, only (at worst) a slow first call.
9
+ * tools/list goes through `listTools`, which the hub answers from its
10
+ * per-server cache when it can, so listing alone need not spawn.
9
11
  *
10
12
  * Tools-only by design: every MCP server Talon consumes (plugins,
11
13
  * brave) exposes tools, and the backends only wire tools. Resource /
@@ -18,26 +20,28 @@ import {
18
20
  ListToolsRequestSchema,
19
21
  CallToolRequestSchema,
20
22
  } from "@modelcontextprotocol/sdk/types.js";
23
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
21
24
  import type { ChildHandle } from "./children.js";
22
25
 
23
- export function buildProxyServer(
24
- name: string,
25
- getChild: () => Promise<ChildHandle>,
26
- ): Server {
26
+ export type ProxySource = {
27
+ listTools: () => Promise<Tool[]>;
28
+ getChild: () => Promise<ChildHandle>;
29
+ };
30
+
31
+ export function buildProxyServer(name: string, source: ProxySource): Server {
27
32
  const server = new Server(
28
33
  { name, version: "1.0.0" },
29
34
  { capabilities: { tools: {} } },
30
35
  );
31
36
 
32
37
  server.setRequestHandler(ListToolsRequestSchema, async () => {
33
- const child = await getChild();
34
- return { tools: await child.listTools() };
38
+ return { tools: await source.listTools() };
35
39
  });
36
40
 
37
41
  // `extra.signal` fires when the upstream client cancels or its session
38
42
  // closes; passing it on stops the child's work instead of orphaning it.
39
43
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
40
- const child = await getChild();
44
+ const child = await source.getChild();
41
45
  return child.callTool(
42
46
  request.params.name,
43
47
  request.params.arguments ?? {},
@@ -0,0 +1,120 @@
1
+ /**
2
+ * MCP reaper process — the `_mcp-reaper` side of the hub's child guard
3
+ * (see child-guard.ts for the design). Kept in its own module with no
4
+ * imports beyond launcher.ts so the reaper process stays small: the
5
+ * entry shims dispatch it before the app graph loads.
6
+ */
7
+
8
+ import { startBridgeWatchdog } from "./launcher.js";
9
+
10
+ /** Hidden CLI subcommand that turns a Talon process into the reaper. */
11
+ export const MCP_REAPER_SUBCOMMAND = "_mcp-reaper";
12
+
13
+ /** SIGTERM → SIGKILL grace, matching the per-child supervisor. */
14
+ const REAPER_KILL_GRACE_MS = 1_000;
15
+
16
+ /** One parsed control line: register (`+`) or release (`-`) a pid. */
17
+ export function parseGuardLine(
18
+ line: string,
19
+ ): { op: "+" | "-"; pid: number } | null {
20
+ const m = /^([+-])(\d+)$/.exec(line.trim());
21
+ if (!m) return null;
22
+ const pid = Number(m[2]);
23
+ return Number.isSafeInteger(pid) && pid > 0
24
+ ? { op: m[1] as "+" | "-", pid }
25
+ : null;
26
+ }
27
+
28
+ function pidAlive(pid: number): boolean {
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch (err) {
33
+ // EPERM: the pid exists but is not ours to signal — still alive.
34
+ return (err as NodeJS.ErrnoException).code === "EPERM";
35
+ }
36
+ }
37
+
38
+ function signalPid(pid: number, signal: NodeJS.Signals): void {
39
+ try {
40
+ process.kill(pid, signal);
41
+ } catch {
42
+ /* already gone */
43
+ }
44
+ }
45
+
46
+ /**
47
+ * SIGTERM every pid, wait up to `graceMs` for them to exit, then SIGKILL
48
+ * whatever is left. Resolves once the SIGKILLs are sent.
49
+ */
50
+ export async function killPids(
51
+ pids: readonly number[],
52
+ graceMs: number = REAPER_KILL_GRACE_MS,
53
+ ): Promise<void> {
54
+ for (const pid of pids) signalPid(pid, "SIGTERM");
55
+ const deadline = Date.now() + graceMs;
56
+ let alive = pids.filter(pidAlive);
57
+ while (alive.length > 0 && Date.now() < deadline) {
58
+ await new Promise((resolve) => setTimeout(resolve, 50));
59
+ alive = alive.filter(pidAlive);
60
+ }
61
+ for (const pid of alive) signalPid(pid, "SIGKILL");
62
+ }
63
+
64
+ /**
65
+ * Reaper main. Never resolves: the process exits once it has reaped, which
66
+ * happens on stdin EOF/close, a termination signal, or a bridge-watchdog
67
+ * trip (only when TALON_BRIDGE_URL is set).
68
+ */
69
+ export function runReaper(): Promise<never> {
70
+ const pids = new Set<number>();
71
+ let reaping = false;
72
+ let buf = "";
73
+
74
+ const reap = (): void => {
75
+ if (reaping) return;
76
+ reaping = true;
77
+ void killPids([...pids]).finally(() => process.exit(0));
78
+ };
79
+
80
+ // The daemon's end of stdin/stderr may vanish at any moment (that is
81
+ // the point); an EPIPE must not take the reaper down before it reaps.
82
+ process.stdin.on("error", () => {});
83
+ process.stderr.on("error", () => {});
84
+ process.stdin.setEncoding("utf-8");
85
+ process.stdin.on("data", (chunk: string) => {
86
+ buf += chunk;
87
+ let nl;
88
+ while ((nl = buf.indexOf("\n")) !== -1) {
89
+ const parsed = parseGuardLine(buf.slice(0, nl));
90
+ buf = buf.slice(nl + 1);
91
+ if (!parsed) continue;
92
+ if (parsed.op === "+") pids.add(parsed.pid);
93
+ else pids.delete(parsed.pid);
94
+ }
95
+ });
96
+ process.stdin.on("end", reap);
97
+ process.stdin.on("close", reap);
98
+
99
+ const signals: NodeJS.Signals[] =
100
+ process.platform === "win32"
101
+ ? ["SIGTERM", "SIGINT"]
102
+ : ["SIGTERM", "SIGINT", "SIGHUP"];
103
+ for (const sig of signals) process.on(sig, reap);
104
+
105
+ const bridgeUrl = process.env.TALON_BRIDGE_URL;
106
+ if (bridgeUrl) {
107
+ startBridgeWatchdog(
108
+ bridgeUrl,
109
+ () => reaping,
110
+ (outcome, downForSec) => {
111
+ process.stderr.write(
112
+ `mcp-reaper: bridge ${bridgeUrl} ${outcome} for ${downForSec}s; reaping ${pids.size} MCP child(ren)\n`,
113
+ );
114
+ reap();
115
+ },
116
+ );
117
+ }
118
+
119
+ return new Promise<never>(() => {});
120
+ }
@@ -3,7 +3,7 @@
3
3
  * the token format, the scope vocabulary, and the operator surface.
4
4
  */
5
5
 
6
- export { DeviceCredentialStore } from "./store.js";
6
+ export { DeviceCredentialStore, UNBOUND_TTL_MS } from "./store.js";
7
7
  export { credentialIdOf, isDeviceCredentialToken } from "./token.js";
8
8
  export {
9
9
  DEFAULT_COMPANION_SCOPES,
@@ -43,7 +43,7 @@ import {
43
43
  const DEFAULT_FILE = resolve(dirs.root, "mesh-credentials.json");
44
44
  const DAY_MS = 24 * 60 * 60 * 1000;
45
45
  /** A pairing link or installer that is never used stops being a credential. */
46
- const UNBOUND_TTL_MS = 7 * DAY_MS;
46
+ export const UNBOUND_TTL_MS = 7 * DAY_MS;
47
47
  /** How long a device has to pick up a requested rotation before it expires. */
48
48
  const ROTATION_GRACE_MS = 7 * DAY_MS;
49
49
  /** Revoked rows are kept this long for `talon mesh` / audit, then pruned. */
@@ -863,6 +863,14 @@ export class MeshService {
863
863
  return this.links.makeCompanionPairLink(label, bridgeUrl);
864
864
  }
865
865
 
866
+ /** `make_companion_pair_link`: the pairing link as a tool result. */
867
+ makeCompanionPairLinkText(
868
+ label?: unknown,
869
+ bridgeUrl?: unknown,
870
+ ): MeshToolResult {
871
+ return this.links.makeCompanionPairLinkText(label, bridgeUrl);
872
+ }
873
+
866
874
  /** GET /pair — serve a pairing grant (single-use). */
867
875
  openCompanionPair(
868
876
  token: string,
@@ -12,6 +12,7 @@
12
12
  import { networkInterfaces } from "node:os";
13
13
  import {
14
14
  CompanionPairStore,
15
+ PAIR_GRANT_TTL_MS,
15
16
  pairLink,
16
17
  pairPage,
17
18
  type CompanionPairPayload,
@@ -32,7 +33,9 @@ import {
32
33
  } from "./node-provision.js";
33
34
  import {
34
35
  DEFAULT_COMPANION_SCOPES,
36
+ isDeviceCredentialToken,
35
37
  NODE_SCOPES,
38
+ UNBOUND_TTL_MS,
36
39
  type CredentialOrigin,
37
40
  type MeshScope,
38
41
  } from "../credentials/index.js";
@@ -281,6 +284,40 @@ export class BridgeLinks {
281
284
  };
282
285
  }
283
286
 
287
+ /**
288
+ * `make_companion_pair_link`: {@link makeCompanionPairLink} as a tool
289
+ * result — the one-tap link plus the values for typing in by hand, the
290
+ * same things the Telegram `/mesh pair` reply shows.
291
+ */
292
+ makeCompanionPairLinkText(
293
+ label?: unknown,
294
+ bridgeUrl?: unknown,
295
+ ): MeshToolResult {
296
+ const minted = this.makeCompanionPairLink(label, bridgeUrl);
297
+ if (!minted.ok) return minted;
298
+ const minutes = Math.round(PAIR_GRANT_TTL_MS / 60_000);
299
+ const perDevice = isDeviceCredentialToken(minted.token);
300
+ return {
301
+ ok: true,
302
+ text: [
303
+ "Open this link on the phone or computer running the Talon companion:",
304
+ "",
305
+ ` ${minted.link}`,
306
+ "",
307
+ `Single-use, expires in ${minutes} minutes. If the link doesn't open the app, enter these by hand:`,
308
+ ` Bridge URL: ${minted.url}`,
309
+ ` Token: ${minted.token}`,
310
+ ...(minted.fingerprint
311
+ ? [` Certificate SHA-256: ${minted.fingerprint}`]
312
+ : []),
313
+ "",
314
+ perDevice
315
+ ? `The token is this device's own credential: it binds to the first device that registers with it and lapses after ${Math.round(UNBOUND_TTL_MS / 86_400_000)} days unused. It can't take over a device that already holds a live credential — revoke that one first (talon mesh revoke <device>).`
316
+ : "The token is the bridge's shared token (this mesh has no per-device credential store).",
317
+ ].join("\n"),
318
+ };
319
+ }
320
+
284
321
  /**
285
322
  * GET /pair — serve a pairing grant (single-use), as the landing page or
286
323
  * as the JSON the companion reads.
@@ -32,7 +32,7 @@
32
32
  import { randomBytes } from "node:crypto";
33
33
 
34
34
  /** Unclaimed grants die after this long. A pairing link is used at once. */
35
- const GRANT_TTL_MS = 10 * 60 * 1000;
35
+ export const PAIR_GRANT_TTL_MS = 10 * 60 * 1000;
36
36
 
37
37
  export type CompanionPairGrant = {
38
38
  token: string;
@@ -59,7 +59,7 @@ export type CompanionPairPayload = {
59
59
  export class CompanionPairStore {
60
60
  private readonly grants = new Map<string, CompanionPairGrant>();
61
61
 
62
- constructor(private readonly ttlMs = GRANT_TTL_MS) {}
62
+ constructor(private readonly ttlMs = PAIR_GRANT_TTL_MS) {}
63
63
 
64
64
  create(
65
65
  grant: Omit<CompanionPairGrant, "token" | "createdAt" | "used">,
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * MCP server specs — the stdio command for every plugin that exposes an MCP
3
3
  * server (via `mcpServer` command/args or `mcpServerPath`), plus the
4
- * standalone MCP entries from config. The hub spawns its children from these.
4
+ * standalone MCP entries from config. The hub spawns its children from these
5
+ * raw specs and applies orphan protection itself (mcp-hub/child-guard.ts).
5
6
  */
6
7
 
7
8
  import { resolve } from "node:path";
8
9
  import { logWarn } from "../../util/log.js";
9
- import { wrapMcpServer } from "../mcp-hub/launcher.js";
10
10
  import { isBunRuntime } from "../../util/runtime.js";
11
11
  import { registry, reloadState } from "./registry.js";
12
12
  import type { McpServerConfig } from "./types.js";
@@ -71,14 +71,14 @@ export function getPluginMcpServers(
71
71
 
72
72
  if (plugin.mcpServer) {
73
73
  // Custom command/args (Python, Go, etc.) — no tsx wrapper
74
- servers[`${plugin.name}-tools`] = wrapMcpServer({
74
+ servers[`${plugin.name}-tools`] = {
75
75
  command: plugin.mcpServer.command,
76
76
  args: [...plugin.mcpServer.args],
77
77
  env: baseEnv,
78
- });
78
+ };
79
79
  } else if (plugin.mcpServerPath) {
80
80
  // TS server entry: bun runs it directly; node needs the tsx loader.
81
- servers[`${plugin.name}-tools`] = wrapMcpServer({
81
+ servers[`${plugin.name}-tools`] = {
82
82
  command: isBunRuntime()
83
83
  ? process.execPath
84
84
  : process.platform === "win32"
@@ -90,17 +90,17 @@ export function getPluginMcpServers(
90
90
  ? ["tsx", plugin.mcpServerPath]
91
91
  : ["--import", tsxPath, plugin.mcpServerPath],
92
92
  env: baseEnv,
93
- });
93
+ };
94
94
  }
95
95
  }
96
96
 
97
97
  for (const entry of registry.mcpEntries) {
98
98
  if (only !== undefined && !only.includes(entry.name)) continue;
99
- servers[`${entry.name}-tools`] = wrapMcpServer({
99
+ servers[`${entry.name}-tools`] = {
100
100
  command: entry.command,
101
101
  args: [...(entry.args ?? [])],
102
102
  env: buildBridgeEnv(bridgeUrl, chatId, entry.env),
103
- });
103
+ };
104
104
  }
105
105
 
106
106
  return servers;
@@ -28,6 +28,8 @@ const LONG_ACTION_TIMEOUTS_MS: Record<string, number> = {
28
28
  // must sit above it so the layer that gives up is the tool (which reports
29
29
  // the agent's live state), never the transport.
30
30
  wait_for_agent: 180_000,
31
+ // The pre-flight lane caps itself at 600s; same margin rule as above.
32
+ run_preflight: 660_000,
31
33
  device_pull_file: 3_600_000,
32
34
  device_push_file: 3_600_000,
33
35
  device_read_file: 3_600_000,
@@ -36,6 +38,9 @@ const LONG_ACTION_TIMEOUTS_MS: Record<string, number> = {
36
38
  native_write: 3_600_000,
37
39
  native_edit: 3_600_000,
38
40
  device_exec: 330_000,
41
+ // The fetch ladder may climb several rungs (and install curl-impersonate
42
+ // on first use) inside its own 150s budget.
43
+ fetch_url: 180_000,
39
44
  native_bash: 330_000,
40
45
  native_glob: 330_000,
41
46
  native_search: 330_000,
@@ -14,7 +14,7 @@ export const webTools: ToolDefinition[] = [
14
14
  {
15
15
  name: "fetch_url",
16
16
  description:
17
- "Fetch a URL — web pages return text content, image URLs are downloaded to workspace. Use to read articles, download images, or fetch any URL.",
17
+ "Fetch a URL — web pages return text content, image URLs are downloaded to workspace. Use to read articles, download images, or fetch any URL. Bot walls are retried automatically (browser-TLS impersonation, then any configured exits/browser); the result footer names the route that worked. A 404 means the URL is wrong, not that you were blocked.",
18
18
  schema: {
19
19
  url: z.string().describe("The URL to fetch"),
20
20
  },