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
@@ -6,10 +6,6 @@
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
8
  import { resolve } from "node:path";
9
- import {
10
- readBodyLimited,
11
- ResponseTooLargeError,
12
- } from "../../../../util/http-body.js";
13
9
  import {
14
10
  advertisedBinaryKind,
15
11
  decodeText,
@@ -22,7 +18,9 @@ import {
22
18
  import { dirs } from "../../../../util/paths.js";
23
19
  import { getPoolConfig } from "../../backend-controller/index.js";
24
20
  import type { SharedActionHandlers } from "../types.js";
25
- import { BlockedUrlError, guardedFetch } from "./guard.js";
21
+ import { logDebug } from "../../../../util/log.js";
22
+ import { buildFetchLadder } from "../../../fetch/index.js";
23
+ import { BlockedUrlError } from "./guard.js";
26
24
 
27
25
  const MAX_RESPONSE_MB = 50;
28
26
  const MAX_RESPONSE_BYTES = MAX_RESPONSE_MB * 1024 * 1024;
@@ -69,55 +67,37 @@ function textResult(
69
67
  return { ok: true, text: capText(text) };
70
68
  }
71
69
 
70
+ /** Footer naming the rung that got the page, plus any caveat. */
71
+ function footer(via: string, note?: string): string {
72
+ return `\n\n[fetched via ${via}${note ? ` — ${note}` : ""}]`;
73
+ }
74
+
72
75
  export const fetchUrlHandlers: SharedActionHandlers = {
73
76
  fetch_url: async (body) => {
74
77
  const url = String(body.url ?? "");
75
78
  const invalid = urlError(url);
76
79
  if (invalid) return { ok: false, error: invalid };
77
80
  try {
81
+ // The ladder (core/fetch) climbs browser-TLS impersonation → SOCKS
82
+ // exits → plain fetch → browser → egress device until one answers
83
+ // with content, and stops early on a definitive answer (a 404).
78
84
  // Local addresses are reachable by default; with
79
85
  // `fetchUrl.allowPrivateNetworks: false` every hop is checked against
80
86
  // private/loopback/link-local ranges (see guard.ts).
81
- const resp = await guardedFetch(
82
- url,
83
- {
84
- signal: AbortSignal.timeout(15_000),
85
- headers: { "User-Agent": "Talon/1.0" },
86
- },
87
- {
88
- allowPrivateNetworks:
89
- getPoolConfig()?.fetchUrl?.allowPrivateNetworks !== false,
90
- },
91
- );
92
- if (!resp.ok) return { ok: false, error: `HTTP ${resp.status}` };
93
- const ct = resp.headers.get("content-type") ?? "";
94
-
95
- // Reject oversized responses before downloading the body.
96
- // The Content-Length header is advisory but saves bandwidth when present.
97
- const contentLength = resp.headers.get("content-length");
98
- if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES) {
99
- return {
100
- ok: false,
101
- error: `File too large (${(Number(contentLength) / 1024 / 1024).toFixed(0)}MB, max ${MAX_RESPONSE_MB}MB)`,
102
- };
103
- }
87
+ const ladder = buildFetchLadder(getPoolConfig(), {
88
+ maxBytes: MAX_RESPONSE_BYTES,
89
+ });
90
+ const result = await ladder.fetch(url);
91
+ if (!result.ok) return { ok: false, error: result.error };
92
+ logDebug("fetch", `fetch via ${result.via} (${new URL(url).host})`);
93
+ const via = footer(result.via, result.note);
94
+ const ct = result.headers.get("content-type") ?? "";
95
+ const buffer = result.body;
104
96
 
105
97
  const mimeType = ct.split(";")[0].trim().toLowerCase();
106
- let buffer: Buffer;
107
- try {
108
- buffer = await readBodyLimited(resp, MAX_RESPONSE_BYTES);
109
- } catch (err) {
110
- if (err instanceof ResponseTooLargeError) {
111
- return {
112
- ok: false,
113
- error: `Response too large (max ${MAX_RESPONSE_MB}MB)`,
114
- };
115
- }
116
- throw err;
117
- }
118
-
119
98
  if (isTextContent(mimeType, buffer)) {
120
- return textResult(mimeType, buffer, ct);
99
+ const text = textResult(mimeType, buffer, ct);
100
+ return { ...text, text: text.text + via };
121
101
  }
122
102
 
123
103
  if (buffer.length === 0)
@@ -148,7 +128,7 @@ export const fetchUrlHandlers: SharedActionHandlers = {
148
128
  : (detected?.ext ?? ct.split("/")[1]?.split(";")[0] ?? "file");
149
129
  return {
150
130
  ok: true,
151
- text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").`,
131
+ text: `Downloaded ${typeLabel} (${(buffer.length / 1024).toFixed(0)}KB) to: ${filePath}\nRead it with the Read tool or send it with send(type="file", file_path="${filePath}").${via}`,
152
132
  };
153
133
  } catch (err) {
154
134
  if (err instanceof BlockedUrlError) {
@@ -91,6 +91,9 @@ export const meshHandlers: SharedActionHandlers = {
91
91
  body.name,
92
92
  body.bridge_url,
93
93
  ),
94
+ // Companion pairing: the agent-tool twin of Telegram's `/mesh pair`.
95
+ make_companion_pair_link: async (body) =>
96
+ getMeshService().makeCompanionPairLinkText(body.label, body.bridge_url),
94
97
  };
95
98
 
96
99
  /**
@@ -124,4 +127,5 @@ export const chatFreeActions: ReadonlySet<string> = new Set([
124
127
  "update_node",
125
128
  "get_node_binary",
126
129
  "make_node_install_link",
130
+ "make_companion_pair_link",
127
131
  ]);
@@ -18,10 +18,21 @@ import {
18
18
  collectBackendUsage,
19
19
  formatHeadroom,
20
20
  leadWith,
21
+ type BackendHeadroom,
21
22
  type BackendUsageSnapshot,
22
23
  } from "../backend-router/index.js";
23
24
  import type { SharedActionHandlers } from "./types.js";
24
25
 
26
+ /**
27
+ * Headroom as agents see it, to 2dp. `null` for an unmeasured backend: the
28
+ * router scores those 0 so they are never preferred, but telling an agent
29
+ * "0" would read as "spent", which is not known either.
30
+ */
31
+ function headroomFigure(entry: BackendHeadroom): number | null {
32
+ if (entry.source === "none" && !entry.unavailable) return null;
33
+ return Math.round(entry.headroom * 100) / 100;
34
+ }
35
+
25
36
  /** One `plan_usage` block: the headroom line, then any plan windows. */
26
37
  function usageLines(entry: BackendUsageSnapshot): string[] {
27
38
  const head = `- ${entry.label || entry.id}: ${formatHeadroom(entry.headroom)}`;
@@ -143,10 +154,13 @@ export const modelHandlers: SharedActionHandlers = {
143
154
  id: entry.id,
144
155
  label: entry.label,
145
156
  current: entry.id === currentId,
146
- headroom: Math.round(entry.headroom.headroom * 100) / 100,
157
+ headroom: headroomFigure(entry.headroom),
147
158
  source: entry.headroom.source,
148
159
  limiting: entry.headroom.limiting ?? null,
149
160
  stale: entry.headroom.stale ?? false,
161
+ ...(entry.headroom.unavailable
162
+ ? { unavailable: entry.headroom.unavailable }
163
+ : {}),
150
164
  plan: entry.plan?.plan ?? null,
151
165
  windows: entry.plan?.windows ?? [],
152
166
  ...(entry.note ? { note: entry.note } : {}),
@@ -170,10 +184,11 @@ export const modelHandlers: SharedActionHandlers = {
170
184
  id: b.id,
171
185
  label: b.label,
172
186
  current: b.id === currentId,
173
- headroom: entry
174
- ? Math.round(entry.headroom.headroom * 100) / 100
175
- : null,
187
+ headroom: entry ? headroomFigure(entry.headroom) : null,
176
188
  headroomSource: entry?.headroom.source ?? null,
189
+ ...(entry?.headroom.unavailable
190
+ ? { unavailable: entry.headroom.unavailable }
191
+ : {}),
177
192
  };
178
193
  });
179
194
  const lines = backends.map((b) => {
@@ -320,16 +320,45 @@ export async function dispatchGatewayRoute(
320
320
 
321
321
  const PORT_RETRIES = 5;
322
322
 
323
+ /**
324
+ * Asked before walking past a busy port. Resolves a reason to refuse the
325
+ * fallback (the bind then fails with it), or `undefined` to keep walking.
326
+ */
327
+ export type PortInUseCheck = (busyPort: number) => Promise<string | undefined>;
328
+
323
329
  /**
324
330
  * Bind to 127.0.0.1, walking up from `port` on EADDRINUSE (at most
325
331
  * `PORT_RETRIES` times). Resolves with the port actually bound — `port`
326
332
  * 0 asks the OS for a free one — and leaves a persistent error handler
327
333
  * on the server, so a later server-level error is logged instead of
328
334
  * crashing the process via an unhandled 'error' event.
335
+ *
336
+ * `onPortInUse` can veto the walk: the daemon uses it to fail outright when
337
+ * the busy port belongs to another Talon daemon, rather than settling on
338
+ * the next port and running as a second instance.
329
339
  */
330
- export function listenWithRetry(server: Server, port: number): Promise<number> {
340
+ export function listenWithRetry(
341
+ server: Server,
342
+ port: number,
343
+ onPortInUse?: PortInUseCheck,
344
+ ): Promise<number> {
331
345
  return new Promise<number>((resolve, reject) => {
332
346
  let attempt = 0;
347
+ const walkOn = async (
348
+ candidate: number,
349
+ err: NodeJS.ErrnoException,
350
+ ): Promise<void> => {
351
+ const refusal = onPortInUse
352
+ ? await onPortInUse(candidate).catch(() => undefined)
353
+ : undefined;
354
+ if (refusal) {
355
+ reject(
356
+ Object.assign(new Error(refusal), { code: err.code, cause: err }),
357
+ );
358
+ return;
359
+ }
360
+ tryPort(candidate + 1);
361
+ };
333
362
  const tryPort = (candidate: number): void => {
334
363
  server.once("error", (err: NodeJS.ErrnoException) => {
335
364
  if (err.code === "EADDRINUSE" && attempt < PORT_RETRIES) {
@@ -339,7 +368,7 @@ export function listenWithRetry(server: Server, port: number): Promise<number> {
339
368
  // registered; drop it or every stale callback fires when a later
340
369
  // port finally binds.
341
370
  server.removeAllListeners("listening");
342
- tryPort(candidate + 1);
371
+ void walkOn(candidate, err);
343
372
  } else {
344
373
  reject(err);
345
374
  }
@@ -30,6 +30,7 @@ import { getHubSessionCount } from "../mcp-hub/index.js";
30
30
  import {
31
31
  dispatchGatewayRoute,
32
32
  listenWithRetry,
33
+ type PortInUseCheck,
33
34
  type GatewayRouteHost,
34
35
  } from "./gateway-routes.js";
35
36
  import { gatewayToken } from "./gateway-auth.js";
@@ -471,6 +472,15 @@ export class Gateway {
471
472
  * A failed bind clears the in-flight state, so a later `start()` retries;
472
473
  * so does `stop()`, so start-after-stop binds afresh.
473
474
  */
475
+ /**
476
+ * Veto for the EADDRINUSE port walk (see `listenWithRetry`). The daemon
477
+ * installs one that refuses when the busy port is another daemon.
478
+ */
479
+ setPortInUseCheck(check: PortInUseCheck | undefined): void {
480
+ this.portInUseCheck = check;
481
+ }
482
+ private portInUseCheck: PortInUseCheck | undefined;
483
+
474
484
  async start(port = 19876): Promise<number> {
475
485
  if (this.server) return this.port;
476
486
  if (this.starting) return this.starting;
@@ -509,7 +519,7 @@ export class Gateway {
509
519
  const httpServer = createServer(
510
520
  (req, res) => void dispatchGatewayRoute(req, res, host),
511
521
  );
512
- const bound = await listenWithRetry(httpServer, port);
522
+ const bound = await listenWithRetry(httpServer, port, this.portInUseCheck);
513
523
  this.server = httpServer;
514
524
  this.port = bound;
515
525
  log("gateway", `Action gateway on :${this.port}`);
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Response classification for the fetch ladder: is this content, a bot
3
+ * wall worth climbing past, or a definitive answer (a wrong URL) that no
4
+ * amount of climbing will change?
5
+ *
6
+ * Getting "blocked" vs "not found" right is the whole point: climbing on a
7
+ * 404 hammers a site through every exit for nothing and then reports a
8
+ * block that never happened; stopping on a 403 challenge gives up on a
9
+ * page that the next rung would have fetched.
10
+ */
11
+
12
+ import type { Verdict } from "./types.js";
13
+
14
+ /** Statuses bot walls answer with. 999 is LinkedIn's. */
15
+ const BLOCK_STATUSES = new Set([403, 429, 503, 999]);
16
+ const NOT_FOUND_STATUSES = new Set([404, 410]);
17
+
18
+ /**
19
+ * Challenge pages are small; a real article that merely mentions
20
+ * "captcha" is not. Only bodies under this size are scanned.
21
+ */
22
+ const CHALLENGE_SCAN_BYTES = 32 * 1024;
23
+
24
+ /**
25
+ * Markers of interstitial challenge pages (Cloudflare, DataDome, Akamai,
26
+ * Imperva/Incapsula, PerimeterX). Deliberately no bare "captcha" or
27
+ * reCAPTCHA marker: plenty of ordinary pages embed a form captcha. A 2xx
28
+ * page flagged here is kept as a fallback by the ladder, so a false
29
+ * positive costs extra rungs, never the content.
30
+ */
31
+ const CHALLENGE_MARKERS: readonly RegExp[] = [
32
+ // Not "challenge-platform": Cloudflare injects that script path into
33
+ // ordinary pages too. Same for the DataDome/PerimeterX tag scripts —
34
+ // only their challenge iframes/widgets count.
35
+ /cf-chl-|cf_chl_opt|<title>Just a moment\.\.\.<\/title>/i,
36
+ /Attention Required! \| Cloudflare/i,
37
+ /captcha-delivery\.com/i,
38
+ /_Incapsula_Resource|Incapsula incident/i,
39
+ /px-captcha/i,
40
+ /Pardon Our Interruption/i,
41
+ /<title>Access Denied<\/title>[\s\S]*Reference #/i,
42
+ /verify (that )?you are (a )?human|are you a robot/i,
43
+ /challenges\.cloudflare\.com\/turnstile/i,
44
+ ];
45
+
46
+ /** All markers as one regex source (for the browser driver subprocess). */
47
+ export const CHALLENGE_PATTERN = CHALLENGE_MARKERS.map(
48
+ (re) => `(?:${re.source})`,
49
+ ).join("|");
50
+
51
+ /** True when a small body looks like a bot-check interstitial. */
52
+ export function looksLikeChallenge(body: Buffer | string): boolean {
53
+ const size = typeof body === "string" ? body.length : body.byteLength;
54
+ if (size === 0 || size > CHALLENGE_SCAN_BYTES) return false;
55
+ const text = typeof body === "string" ? body : body.toString("latin1");
56
+ return CHALLENGE_MARKERS.some((re) => re.test(text));
57
+ }
58
+
59
+ /** Judge one HTTP answer. Network failures never reach here. */
60
+ export function classifyResponse(status: number, body: Buffer): Verdict {
61
+ if (BLOCK_STATUSES.has(status)) return "blocked";
62
+ if (looksLikeChallenge(body)) return "blocked";
63
+ if (status >= 200 && status < 300) return "ok";
64
+ if (NOT_FOUND_STATUSES.has(status)) return "not-found";
65
+ return "http-error";
66
+ }
67
+
68
+ /** Verdicts after which trying another rung is pointless. */
69
+ export function isDefinitive(verdict: Verdict): boolean {
70
+ return (
71
+ verdict === "ok" || verdict === "not-found" || verdict === "http-error"
72
+ );
73
+ }