talon-agent 5.24.0 → 5.24.1

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 (71) hide show
  1. package/package.json +1 -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/runner.ts +4 -0
  12. package/src/core/background/cron/job-oneshot.ts +7 -1
  13. package/src/core/background/cron/scheduler.ts +28 -14
  14. package/src/core/background/cron/spec.ts +23 -3
  15. package/src/core/background/heartbeat/agent.ts +6 -0
  16. package/src/core/background/triggers/resume.ts +19 -4
  17. package/src/core/background/triggers/spawn.ts +1 -1
  18. package/src/core/config/index.ts +82 -0
  19. package/src/core/daemon/discovery.ts +160 -0
  20. package/src/core/daemon/pidfile.ts +107 -0
  21. package/src/core/daemon/respawn.ts +4 -1
  22. package/src/core/engine/backend-router/breaker.ts +158 -0
  23. package/src/core/engine/backend-router/headroom.ts +90 -22
  24. package/src/core/engine/backend-router/index.ts +11 -0
  25. package/src/core/engine/gateway-actions/cron.ts +5 -0
  26. package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
  27. package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
  28. package/src/core/engine/gateway-actions/mesh.ts +4 -0
  29. package/src/core/engine/gateway-actions/models.ts +19 -4
  30. package/src/core/engine/gateway-routes.ts +31 -2
  31. package/src/core/engine/gateway.ts +11 -1
  32. package/src/core/fetch/classify.ts +73 -0
  33. package/src/core/fetch/curl-impersonate.ts +447 -0
  34. package/src/core/fetch/errors.ts +14 -0
  35. package/src/core/fetch/index.ts +133 -0
  36. package/src/core/fetch/ladder.ts +401 -0
  37. package/src/core/fetch/rungs.ts +319 -0
  38. package/src/core/fetch/types.ts +105 -0
  39. package/src/core/frontend-runtime/admin-notify.ts +100 -12
  40. package/src/core/mcp-hub/child-guard.ts +215 -0
  41. package/src/core/mcp-hub/child-transport.ts +89 -39
  42. package/src/core/mcp-hub/children.ts +65 -9
  43. package/src/core/mcp-hub/guest-scope.ts +3 -2
  44. package/src/core/mcp-hub/index.ts +36 -16
  45. package/src/core/mcp-hub/launcher.ts +81 -37
  46. package/src/core/mcp-hub/proxy-server.ts +12 -8
  47. package/src/core/mcp-hub/reaper.ts +120 -0
  48. package/src/core/mesh/credentials/index.ts +1 -1
  49. package/src/core/mesh/credentials/store.ts +1 -1
  50. package/src/core/mesh/devices/service.ts +8 -0
  51. package/src/core/mesh/links/bridge-links.ts +37 -0
  52. package/src/core/mesh/links/companion-pairing.ts +2 -2
  53. package/src/core/plugin/mcp.ts +8 -8
  54. package/src/core/tools/bridge.ts +3 -0
  55. package/src/core/tools/content/web.ts +1 -1
  56. package/src/core/tools/ops/mesh.ts +21 -0
  57. package/src/core/tools/ops/scheduling.ts +15 -0
  58. package/src/frontend/native/bridge/credentials/claims.ts +11 -1
  59. package/src/frontend/telegram/actions/media.ts +164 -12
  60. package/src/index.ts +22 -19
  61. package/src/plugins/playwright/version-coupling.ts +172 -62
  62. package/src/storage/cron.ts +34 -2
  63. package/src/storage/db.ts +1 -0
  64. package/src/storage/repositories/cron-repo.ts +9 -0
  65. package/src/storage/sql/cron.sql +5 -5
  66. package/src/storage/sql/db.sql +5 -0
  67. package/src/storage/sql/schema.sql +2 -1
  68. package/src/storage/sql/statements.generated.ts +10 -6
  69. package/src/util/log.ts +2 -1
  70. package/src/util/paths.ts +2 -0
  71. package/src/core/background/triggers/pid.ts +0 -28
@@ -7,15 +7,16 @@
7
7
  * credentials), the loopback gateway, a router admin page on the LAN.
8
8
  * So before every request — the first one and each redirect hop — the
9
9
  * host is resolved and EVERY address it resolves to must be public.
10
- * Redirects are followed by hand (never by fetch) so a public page
11
- * cannot bounce the request into a private one.
10
+ * Redirects are followed by hand (never by the transport) in the fetch
11
+ * ladder (core/fetch/ladder.ts), which calls `assertPublicUrl` on every
12
+ * hop, so a public page cannot bounce the request into a private one.
12
13
  *
13
- * Residual risk, stated plainly: fetch resolves the name again after we
14
- * checked it, so a DNS-rebinding server with a zero TTL can still race
15
- * us. Pinning the checked address would need a custom connector, which
16
- * Bun (one of the two runtimes) does not honour. The guard closes the
17
- * direct, redirect and static-DNS paths; rebinding needs a hostile DNS
18
- * server and a lucky race.
14
+ * Residual risk, stated plainly: the runtime's fetch (the "plain" rung)
15
+ * resolves the name again after we checked it, so a DNS-rebinding server
16
+ * with a zero TTL can still race it. Pinning the checked address would
17
+ * need a custom connector, which Bun does not honour. The curl rungs do
18
+ * pin (`--resolve` to the checked addresses); requests through a SOCKS
19
+ * exit are resolved by the exit, outside this host's network.
19
20
  *
20
21
  * The guard is opt-in: `fetch_url` applies it only when the operator sets
21
22
  * `fetchUrl.allowPrivateNetworks: false`. By default the agent can read
@@ -33,9 +34,6 @@ const defaultResolver: Resolver = async (host) =>
33
34
 
34
35
  export class BlockedUrlError extends Error {}
35
36
 
36
- const MAX_REDIRECTS = 5;
37
- const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
38
-
39
37
  // ── Address classification ──────────────────────────────────────────────────
40
38
 
41
39
  /** Non-public IPv4 ranges: [network, prefix length]. */
@@ -135,11 +133,13 @@ export function isBlockedAddress(ip: string): boolean {
135
133
  /**
136
134
  * Throw unless `url` is http(s) and its host resolves only to public
137
135
  * addresses. A literal IP is judged directly; a name is resolved.
136
+ * Returns the checked addresses so a caller that can pin the connection
137
+ * to them (curl `--resolve`) closes the rebinding race.
138
138
  */
139
139
  export async function assertPublicUrl(
140
140
  url: URL,
141
141
  resolve: Resolver = defaultResolver,
142
- ): Promise<void> {
142
+ ): Promise<string[]> {
143
143
  if (url.protocol !== "http:" && url.protocol !== "https:") {
144
144
  throw new BlockedUrlError("URL must use http or https protocol");
145
145
  }
@@ -166,36 +166,5 @@ export async function assertPublicUrl(
166
166
  `Remove fetchUrl.allowPrivateNetworks: false from config.json (or set it to true) to allow local addresses.`,
167
167
  );
168
168
  }
169
- }
170
-
171
- export type GuardedFetchOptions = {
172
- allowPrivateNetworks?: boolean;
173
- resolve?: Resolver;
174
- maxRedirects?: number;
175
- };
176
-
177
- /**
178
- * `fetch` with the guard applied to the first request and to every
179
- * redirect hop. Returns the final (non-redirect) response.
180
- */
181
- export async function guardedFetch(
182
- input: string,
183
- init: RequestInit,
184
- options: GuardedFetchOptions = {},
185
- ): Promise<Response> {
186
- const maxRedirects = options.maxRedirects ?? MAX_REDIRECTS;
187
- let url = new URL(input);
188
- for (let hop = 0; ; hop++) {
189
- if (!options.allowPrivateNetworks) {
190
- await assertPublicUrl(url, options.resolve);
191
- }
192
- const resp = await fetch(url, { ...init, redirect: "manual" });
193
- const location = resp.headers.get("location");
194
- if (!REDIRECT_STATUSES.has(resp.status) || !location) return resp;
195
- if (hop >= maxRedirects) {
196
- throw new BlockedUrlError(`Too many redirects (max ${maxRedirects})`);
197
- }
198
- await resp.body?.cancel().catch(() => {});
199
- url = new URL(location, url);
200
- }
169
+ return addresses;
201
170
  }
@@ -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
+ }