talon-agent 5.23.1 → 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 (80) hide show
  1. package/README.md +8 -2
  2. package/package.json +1 -1
  3. package/src/app.ts +30 -0
  4. package/src/backend/agy/process/orphans.ts +5 -1
  5. package/src/backend/claude-sdk/one-shot.ts +8 -1
  6. package/src/backend/codex/factory.ts +5 -1
  7. package/src/backend/codex/plan-usage.ts +12 -0
  8. package/src/backend/runtime/turn/turn-phases.ts +7 -1
  9. package/src/bootstrap.ts +1 -0
  10. package/src/cli/install-sources.ts +187 -35
  11. package/src/cli/plugin.ts +27 -11
  12. package/src/cli/skill.ts +60 -7
  13. package/src/cli.ts +18 -12
  14. package/src/core/agent-runtime/capabilities.ts +7 -0
  15. package/src/core/agents/runner.ts +4 -0
  16. package/src/core/background/cron/job-oneshot.ts +7 -1
  17. package/src/core/background/cron/scheduler.ts +28 -14
  18. package/src/core/background/cron/spec.ts +23 -3
  19. package/src/core/background/heartbeat/agent.ts +6 -0
  20. package/src/core/background/triggers/resume.ts +19 -4
  21. package/src/core/background/triggers/spawn.ts +1 -1
  22. package/src/core/config/index.ts +88 -4
  23. package/src/core/daemon/discovery.ts +160 -0
  24. package/src/core/daemon/pidfile.ts +107 -0
  25. package/src/core/daemon/respawn.ts +4 -1
  26. package/src/core/engine/backend-router/breaker.ts +158 -0
  27. package/src/core/engine/backend-router/headroom.ts +90 -22
  28. package/src/core/engine/backend-router/index.ts +11 -0
  29. package/src/core/engine/gateway-actions/cron.ts +5 -0
  30. package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
  31. package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
  32. package/src/core/engine/gateway-actions/mesh.ts +4 -0
  33. package/src/core/engine/gateway-actions/models.ts +19 -4
  34. package/src/core/engine/gateway-routes.ts +31 -2
  35. package/src/core/engine/gateway.ts +11 -1
  36. package/src/core/fetch/classify.ts +73 -0
  37. package/src/core/fetch/curl-impersonate.ts +447 -0
  38. package/src/core/fetch/errors.ts +14 -0
  39. package/src/core/fetch/index.ts +133 -0
  40. package/src/core/fetch/ladder.ts +401 -0
  41. package/src/core/fetch/rungs.ts +319 -0
  42. package/src/core/fetch/types.ts +105 -0
  43. package/src/core/frontend-runtime/admin-notify.ts +100 -12
  44. package/src/core/mcp-hub/child-guard.ts +215 -0
  45. package/src/core/mcp-hub/child-transport.ts +89 -39
  46. package/src/core/mcp-hub/children.ts +65 -9
  47. package/src/core/mcp-hub/guest-scope.ts +3 -2
  48. package/src/core/mcp-hub/index.ts +36 -16
  49. package/src/core/mcp-hub/launcher.ts +81 -37
  50. package/src/core/mcp-hub/proxy-server.ts +12 -8
  51. package/src/core/mcp-hub/reaper.ts +120 -0
  52. package/src/core/mesh/credentials/index.ts +2 -2
  53. package/src/core/mesh/credentials/store.ts +1 -1
  54. package/src/core/mesh/devices/service.ts +8 -0
  55. package/src/core/mesh/links/bridge-links.ts +37 -0
  56. package/src/core/mesh/links/companion-pairing.ts +2 -2
  57. package/src/core/plugin/mcp.ts +8 -8
  58. package/src/core/tools/bridge.ts +3 -0
  59. package/src/core/tools/content/web.ts +1 -1
  60. package/src/core/tools/ops/mesh.ts +21 -0
  61. package/src/core/tools/ops/scheduling.ts +15 -0
  62. package/src/frontend/native/bridge/auth-guard.ts +117 -53
  63. package/src/frontend/native/bridge/credentials/claims.ts +11 -1
  64. package/src/frontend/native/bridge/credentials/principal.ts +13 -1
  65. package/src/frontend/native/bridge/routes/table.ts +13 -0
  66. package/src/frontend/native/bridge/server.ts +37 -11
  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/skills.ts +7 -1
  74. package/src/storage/sql/cron.sql +5 -5
  75. package/src/storage/sql/db.sql +5 -0
  76. package/src/storage/sql/schema.sql +2 -1
  77. package/src/storage/sql/statements.generated.ts +10 -6
  78. package/src/util/log.ts +2 -1
  79. package/src/util/paths.ts +2 -0
  80. package/src/core/background/triggers/pid.ts +0 -28
package/src/cli/skill.ts CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  import pc from "picocolors";
17
17
  import { existsSync, readdirSync } from "node:fs";
18
- import { resolve } from "node:path";
18
+ import { relative, resolve, sep } from "node:path";
19
19
  import {
20
20
  deleteSkill,
21
21
  installSkillFromDir,
@@ -23,7 +23,13 @@ import {
23
23
  setSkillEnabled,
24
24
  type Skill,
25
25
  } from "../storage/skills.js";
26
- import { cloneShallow, resolveSource } from "./install-sources.js";
26
+ import {
27
+ cloneSource,
28
+ resolveSource,
29
+ withCommit,
30
+ writeInstallRecord,
31
+ type GitSource,
32
+ } from "./install-sources.js";
27
33
 
28
34
  const USAGE = [
29
35
  ` Usage: ${pc.cyan("talon skill <command>")}`,
@@ -32,6 +38,7 @@ const USAGE = [
32
38
  ` ${pc.cyan("list")} Show installed skills`,
33
39
  ` ${pc.cyan("install <source> [--force]")} Add skills from a local folder,`,
34
40
  " git URL, or owner/repo[/subpath]",
41
+ ` ${pc.cyan(" [--commit <sha>]")} …at this commit (or <source>#<sha>)`,
35
42
  ` ${pc.cyan("enable <name>")} Restore a skill to the prompt index`,
36
43
  ` ${pc.cyan("disable <name>")} Hide a skill from the prompt index`,
37
44
  ` ${pc.cyan("remove <name>")} Delete a skill folder`,
@@ -119,16 +126,61 @@ function installAll(dirs: string[], force: boolean): Skill[] {
119
126
  return installed;
120
127
  }
121
128
 
129
+ type SkillInstallArgs = { source: string; force: boolean; commit?: string };
130
+
131
+ function parseInstallArgs(args: string[]): SkillInstallArgs | null {
132
+ let source: string | undefined;
133
+ let force = false;
134
+ let commit: string | undefined;
135
+ for (let i = 0; i < args.length; i++) {
136
+ const arg = args[i]!;
137
+ if (arg === "--force") force = true;
138
+ else if (arg === "--commit") commit = args[++i];
139
+ else if (!arg.startsWith("-") && source === undefined) source = arg;
140
+ else return null;
141
+ }
142
+ if (!source || (commit !== undefined && !commit)) return null;
143
+ return { source, force, ...(commit !== undefined ? { commit } : {}) };
144
+ }
145
+
146
+ /**
147
+ * Record, in each skill folder of the clone, which repo and commit it came
148
+ * from — before it is copied into the store.
149
+ */
150
+ function recordProvenance(
151
+ source: GitSource,
152
+ root: string,
153
+ dirs: string[],
154
+ commit: string | undefined,
155
+ ): void {
156
+ for (const dir of dirs) {
157
+ const subpath = relative(root, dir).split(sep).join("/");
158
+ const inRepo = [source.subpath, subpath].filter(Boolean).join("/");
159
+ writeInstallRecord(dir, {
160
+ source: source.url,
161
+ ...(inRepo ? { subpath: inRepo } : {}),
162
+ ...(commit ? { commit } : {}),
163
+ ...(source.commit ? { pinned: true } : {}),
164
+ });
165
+ }
166
+ }
167
+
122
168
  async function cmdInstall(args: string[]): Promise<void> {
123
- const force = args.includes("--force");
124
- const source = args.find((arg) => !arg.startsWith("-"));
125
- if (!source) {
169
+ const parsed = parseInstallArgs(args);
170
+ if (!parsed) {
126
171
  console.log(USAGE);
127
172
  process.exitCode = 1;
128
173
  return;
129
174
  }
175
+ const { source, force } = parsed;
130
176
 
131
- const resolved = resolveSource(source);
177
+ const pinned = withCommit(resolveSource(source), parsed.commit);
178
+ if (!pinned.ok) {
179
+ fail(pinned.error);
180
+ process.exitCode = 1;
181
+ return;
182
+ }
183
+ const resolved = pinned.source;
132
184
  if (resolved.kind === "other") {
133
185
  fail(
134
186
  `"${source}" is not a folder, git URL, or owner/repo — skills install from SKILL.md folders.`,
@@ -147,7 +199,7 @@ async function cmdInstall(args: string[]): Promise<void> {
147
199
  }
148
200
  installed = installAll(dirs, force);
149
201
  } else {
150
- const clone = cloneShallow(resolved.url);
202
+ const clone = cloneSource(resolved);
151
203
  if (!clone.ok) {
152
204
  fail(clone.error);
153
205
  process.exitCode = 1;
@@ -170,6 +222,7 @@ async function cmdInstall(args: string[]): Promise<void> {
170
222
  process.exitCode = 1;
171
223
  return;
172
224
  }
225
+ recordProvenance(resolved, root, dirs, clone.commit);
173
226
  installed = installAll(dirs, force);
174
227
  if (clone.commit) {
175
228
  console.log(` ${pc.dim(`From ${resolved.url} @ ${clone.commit}`)}`);
package/src/cli.ts CHANGED
@@ -16,22 +16,28 @@
16
16
  * `cli/index.ts`. Everything else lives under `cli/`.
17
17
  */
18
18
 
19
- import {
20
- MCP_LAUNCH_SUBCOMMAND,
21
- runSupervisor,
22
- } from "./core/mcp-hub/launcher.js";
23
- import { LUA_RUN_SUBCOMMAND, runLuaMain } from "./core/scripts/lua.js";
24
-
25
19
  // Hidden subcommand dispatch — must run before anything else. Talon
26
- // supervises MCP stdio children (`_mcp-launch`) and runs WASM-sandboxed
27
- // Lua trigger scripts (`_lua-run`) by re-invoking its own entrypoint
28
- // (see core/mcp-hub/launcher.ts). Neither call resolves; the helper process
29
- // exits from its own handlers.
30
- if (process.argv[2] === MCP_LAUNCH_SUBCOMMAND) {
20
+ // supervises MCP stdio children (`_mcp-launch`, `_mcp-reaper`) and runs
21
+ // WASM-sandboxed Lua trigger scripts (`_lua-run`) by re-invoking its own
22
+ // entrypoint (see core/mcp-hub/launcher.ts). None of these calls resolves;
23
+ // the helper process exits from its own handlers. Names are literals and
24
+ // modules load dynamically so a helper process only evaluates its own
25
+ // module (pinned to the exported constants by entry-dispatch.test.ts).
26
+ const subcommand = process.argv[2];
27
+ if (subcommand === "_mcp-launch") {
28
+ const { runSupervisor } = await import("./core/mcp-hub/launcher.js");
31
29
  await runSupervisor(process.argv.slice(3));
32
- } else if (process.argv[2] === LUA_RUN_SUBCOMMAND) {
30
+ } else if (subcommand === "_mcp-reaper") {
31
+ const { runReaper } = await import("./core/mcp-hub/reaper.js");
32
+ await runReaper();
33
+ } else if (subcommand === "_lua-run") {
34
+ const { runLuaMain } = await import("./core/scripts/lua.js");
33
35
  await runLuaMain(process.argv.slice(3));
34
36
  } else {
35
37
  const { runCli } = await import("./cli/index.js");
36
38
  await runCli();
37
39
  }
40
+
41
+ // No static imports (see above) — mark the file as an ES module so
42
+ // top-level await type-checks.
43
+ export {};
@@ -243,6 +243,13 @@ export interface UsageTelemetry {
243
243
  * plan concept; resolves `undefined` when the data can't be read.
244
244
  */
245
245
  getPlanUsage?(): Promise<PlanUsage | undefined>;
246
+ /**
247
+ * Why the backend's credential is known to be rejected (e.g. the usage
248
+ * endpoint returned 401), or `undefined` when nothing says it is.
249
+ * Synchronous and cheap: it reports what the last `getPlanUsage` saw.
250
+ * The router treats a backend that reports one as having no headroom.
251
+ */
252
+ getAuthFailure?(): string | undefined;
246
253
  /**
247
254
  * Banked one-shot limit resets, where the plan has them. Spending one is
248
255
  * irreversible, so this is reachable only from a human-pressed confirm
@@ -35,6 +35,8 @@ import {
35
35
  } from "../engine/backend-controller/index.js";
36
36
  import {
37
37
  chooseBackend,
38
+ recordBackendRunFailure,
39
+ recordBackendRunSuccess,
38
40
  recordBackendRunUsage,
39
41
  taskClassForEffort,
40
42
  } from "../engine/backend-router/index.js";
@@ -437,9 +439,11 @@ async function runAgent(
437
439
  evictLabel: agentContextLabel(id),
438
440
  });
439
441
  recordBackendRunUsage(record.backendId, usage ?? undefined);
442
+ recordBackendRunSuccess(record.backendId);
440
443
  settled = settleSuccess(id, task, capture.last, usage ?? undefined);
441
444
  }
442
445
  } catch (err) {
446
+ recordBackendRunFailure(record.backendId, err);
443
447
  settled = settleFailure(id, task, err);
444
448
  } finally {
445
449
  await release().catch((err: unknown) =>
@@ -19,7 +19,11 @@ import {
19
19
  acquireBackendInstance,
20
20
  isModelValidForBackend,
21
21
  } from "../../engine/backend-controller/index.js";
22
- import { recordBackendRunUsage } from "../../engine/backend-router/index.js";
22
+ import {
23
+ recordBackendRunFailure,
24
+ recordBackendRunSuccess,
25
+ recordBackendRunUsage,
26
+ } from "../../engine/backend-router/index.js";
23
27
  import { taskTable } from "../../tasks/index.js";
24
28
  import type { OneShotAgentParams } from "../../types.js";
25
29
  import { runIsolatedAgent } from "../isolated-agent.js";
@@ -184,8 +188,10 @@ async function attemptJobOneShot(
184
188
  });
185
189
  task.succeed(usage ?? undefined);
186
190
  recordBackendRunUsage(backendId, usage ?? undefined);
191
+ recordBackendRunSuccess(backendId);
187
192
  } catch (err) {
188
193
  task.fail(err);
194
+ recordBackendRunFailure(backendId, err);
189
195
  throw err;
190
196
  }
191
197
  log(
@@ -223,15 +223,17 @@ async function runScheduled(job: CronJob): Promise<void> {
223
223
  }
224
224
  enforceRunCap(job.id);
225
225
  } catch (err) {
226
- // Advance lastRunAt on failure too (without bumping runCount — failed runs
227
- // don't count toward maxRuns). For interval jobs the anchor IS lastRunAt, so
228
- // skipping this would make a flaky job re-fire every 60s tick until the
229
- // breaker opens, instead of honoring its everyMs cadence between retries.
230
- updateCronJob(job.id, {
231
- lastRunAt: Date.now(),
232
- lastStatus: "error",
233
- lastError: err instanceof Error ? err.message : String(err),
234
- lastDurationMs: Date.now() - startedAt,
226
+ // A failed run is still a run: it advances lastRunAt and bumps runCount,
227
+ // so it counts toward maxRuns. For interval jobs the anchor IS lastRunAt,
228
+ // so not advancing it would make a flaky job re-fire every 60s tick until
229
+ // the breaker opens. And a capped job — a one-shot above all — whose
230
+ // failure didn't count would stay enabled and fire again at the next
231
+ // matching time: for a date-pinned cron expression that is a year later,
232
+ // long after anyone wanted it.
233
+ recordCronRun(job.id, {
234
+ status: "error",
235
+ error: err instanceof Error ? err.message : String(err),
236
+ durationMs: Date.now() - startedAt,
235
237
  });
236
238
  logError(
237
239
  "cron",
@@ -252,6 +254,14 @@ async function runScheduled(job: CronJob): Promise<void> {
252
254
  `Cron job "${job.name}" failed ${JOB_HEALTH.threshold} runs in a row: ${faultText(err)}. Paused for ~${mins} min.`,
253
255
  );
254
256
  }
257
+ if (enforceRunCap(job.id)) {
258
+ // The job's last permitted run failed and it is now retired — nothing
259
+ // will retry it, so the operator has to hear about it.
260
+ raiseAlert(
261
+ `cron.job.${job.id}`,
262
+ `Cron job "${job.name}" failed on its final run and was disabled: ${faultText(err)}. Re-enable or recreate it to try again.`,
263
+ );
264
+ }
255
265
  } finally {
256
266
  runningJobs.delete(job.id);
257
267
  }
@@ -261,11 +271,12 @@ async function runScheduled(job: CronJob): Promise<void> {
261
271
 
262
272
  /**
263
273
  * Disable a job that has reached its run cap (`maxRuns`; =1 means one-shot).
264
- * Call after a successful run, once runCount has been bumped.
274
+ * Call after every run — successful or failed — once runCount has been
275
+ * bumped. Returns true when this call retired the job.
265
276
  */
266
- function enforceRunCap(id: string): void {
277
+ function enforceRunCap(id: string): boolean {
267
278
  const job = getCronJob(id);
268
- if (!job || !job.enabled) return;
279
+ if (!job || !job.enabled) return false;
269
280
  if (job.maxRuns !== undefined && job.runCount >= job.maxRuns) {
270
281
  updateCronJob(id, { enabled: false });
271
282
  log(
@@ -276,7 +287,9 @@ function enforceRunCap(id: string): void {
276
287
  "Cron",
277
288
  `Job "${job.name}" finished after ${job.runCount} run(s)`,
278
289
  );
290
+ return true;
279
291
  }
292
+ return false;
280
293
  }
281
294
 
282
295
  /**
@@ -522,7 +535,8 @@ async function routeQueryJob(
522
535
  return { backendId: decision.backendId, model };
523
536
  }
524
537
 
525
- const CRON_JOB_TIMEOUT_MS = 10 * 60_000; // 10-minute max per job
538
+ /** Default hard limit per query run; a job's own `timeoutMs` overrides it. */
539
+ const CRON_JOB_TIMEOUT_MS = 10 * 60_000;
526
540
 
527
541
  export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
528
542
  if (!deps) return { status: "skipped", reason: "cron is not initialised" };
@@ -577,7 +591,7 @@ export async function executeJob(job: CronJob): Promise<ExecuteJobResult> {
577
591
  payload,
578
592
  label: job.name,
579
593
  kind: "cron",
580
- timeoutMs: CRON_JOB_TIMEOUT_MS,
594
+ timeoutMs: job.timeoutMs ?? CRON_JOB_TIMEOUT_MS,
581
595
  ...(fallback ? { fallback } : {}),
582
596
  });
583
597
  if (result.status === "skipped") {
@@ -24,6 +24,9 @@ import {
24
24
  const MIN_INTERVAL_SECONDS = 60;
25
25
  const MAX_CONTENT_LENGTH = 10_000;
26
26
  const CATCHUP_POLICIES = new Set<CatchupPolicy>(["skip", "once", "all"]);
27
+ /** Per-job run timeout bounds (seconds). The scheduler default is 10 minutes. */
28
+ const MIN_TIMEOUT_SECONDS = 60;
29
+ const MAX_TIMEOUT_SECONDS = 4 * 60 * 60;
27
30
 
28
31
  /** The job fields the spec covers; everything else is identity or telemetry. */
29
32
  type CronSpec = Pick<CronJob, "name" | "type" | "content" | "catchup"> &
@@ -40,6 +43,7 @@ type CronSpec = Pick<CronJob, "name" | "type" | "content" | "catchup"> &
40
43
  | "model"
41
44
  | "provider"
42
45
  | "instructions"
46
+ | "timeoutMs"
43
47
  >
44
48
  >;
45
49
 
@@ -220,8 +224,8 @@ const parseCatchup: Section = ({ body, editing, touched, updates }) => {
220
224
  };
221
225
 
222
226
  /**
223
- * Model / provider / instructions only make sense for "query" jobs (a
224
- * "message" job just sends text — no model runs), and a provider override
227
+ * Model / provider / instructions / timeout only make sense for "query" jobs
228
+ * (a "message" job just sends text — no model runs), and a provider override
225
229
  * needs a model to pick on it.
226
230
  */
227
231
  const parseOverrides: Section = (p) => {
@@ -230,9 +234,25 @@ const parseOverrides: Section = (p) => {
230
234
  if (touched(key))
231
235
  updates[key] = provided(body[key]) ? String(body[key]) : undefined;
232
236
  }
233
- const { type, model, provider, instructions } = merge(p);
237
+ if (touched("timeout_seconds")) {
238
+ if (!provided(body.timeout_seconds)) {
239
+ updates.timeoutMs = undefined;
240
+ } else {
241
+ const secs = Number(body.timeout_seconds);
242
+ if (
243
+ !Number.isFinite(secs) ||
244
+ secs < MIN_TIMEOUT_SECONDS ||
245
+ secs > MAX_TIMEOUT_SECONDS
246
+ )
247
+ return `'timeout_seconds' must be a number between ${MIN_TIMEOUT_SECONDS} and ${MAX_TIMEOUT_SECONDS}.`;
248
+ updates.timeoutMs = Math.round(secs * 1000);
249
+ }
250
+ }
251
+ const { type, model, provider, instructions, timeoutMs } = merge(p);
234
252
  if (type !== "query" && (model || provider || instructions))
235
253
  return "Model/provider/instructions only apply to 'query' jobs.";
254
+ if (type !== "query" && timeoutMs !== undefined)
255
+ return "'timeout_seconds' only applies to 'query' jobs.";
236
256
  if (provider && !model)
237
257
  return "A 'provider' override also requires a 'model'.";
238
258
  return null;
@@ -18,6 +18,8 @@ import type { OneShotAgentParams } from "../../types.js";
18
18
  import { acquireBackendInstance } from "../../engine/backend-controller/index.js";
19
19
  import {
20
20
  chooseBackend,
21
+ recordBackendRunFailure,
22
+ recordBackendRunSuccess,
21
23
  recordBackendRunUsage,
22
24
  resolveRoutedModel,
23
25
  } from "../../engine/backend-router/index.js";
@@ -471,6 +473,9 @@ export async function runHeartbeatAgent(
471
473
  runCount,
472
474
  heartbeatLogFile,
473
475
  );
476
+ } catch (err) {
477
+ recordBackendRunFailure(target.backendId, err);
478
+ throw err;
474
479
  } finally {
475
480
  // A routed run borrowed the instance from the pool; hand it back on
476
481
  // every path or the provider stays warm until the daemon restarts.
@@ -483,6 +488,7 @@ export async function runHeartbeatAgent(
483
488
  }
484
489
  }
485
490
  recordBackendRunUsage(target.backendId, usage ?? undefined);
491
+ recordBackendRunSuccess(target.backendId);
486
492
  task.succeed(usage ?? undefined);
487
493
  return heartbeatLogFile;
488
494
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Post-restart resume — respawn persistent triggers, fire late wakes for
3
3
  * recently-terminated ones, plus the orphan-kill used to avoid duplicate
4
- * spawns after an unclean crash. The /proc PID-starttime probe both this
5
- * and spawn use lives in ./pid.ts.
4
+ * spawns after an unclean crash. The /proc probes it relies on (pid start
5
+ * time, owning daemon) live in core/daemon/pidfile.ts.
6
6
  */
7
7
 
8
8
  import {
@@ -12,11 +12,14 @@ import {
12
12
  SHUTDOWN_KILL_ERROR,
13
13
  type Trigger,
14
14
  } from "../../../storage/triggers.js";
15
- import { log, logError } from "../../../util/log.js";
15
+ import { log, logError, logWarn } from "../../../util/log.js";
16
16
  import { depsHolder } from "./state.js";
17
17
  import { fireWake } from "./output.js";
18
18
  import { spawnTrigger } from "./spawn.js";
19
- import { readPidStarttimeSync } from "./pid.js";
19
+ import {
20
+ childBelongsToLiveDaemon,
21
+ readPidStarttimeSync,
22
+ } from "../../../core/daemon/pidfile.js";
20
23
 
21
24
  /**
22
25
  * After the dispatcher is wired, walk the store and clean up leftover state
@@ -85,6 +88,11 @@ export async function resumeAfterRestart(): Promise<void> {
85
88
  * boot and unchanged by exec(), so a match means the PID still belongs to our
86
89
  * process. On non-Linux (no /proc), pidStarttime is undefined and we fall
87
90
  * through to SIGKILL.
91
+ *
92
+ * Owner check: a process whose spawning daemon is still alive is not an
93
+ * orphan. It is another daemon's live trigger. That is what a second,
94
+ * concurrently started daemon found on 2026-09-27, and it SIGKILLed the
95
+ * running daemon's watchers.
88
96
  */
89
97
  function killOrphan(t: Trigger): void {
90
98
  if (t.pid === undefined) return;
@@ -93,6 +101,13 @@ function killOrphan(t: Trigger): void {
93
101
  } catch {
94
102
  return; // dead — nothing to do
95
103
  }
104
+ if (childBelongsToLiveDaemon(t.pid)) {
105
+ logWarn(
106
+ "triggers",
107
+ `Orphan probe: pid=${t.pid} ("${t.name}") belongs to another running daemon — leaving alone`,
108
+ );
109
+ return;
110
+ }
96
111
  if (t.pidStarttime !== undefined) {
97
112
  const current = readPidStarttimeSync(t.pid);
98
113
  if (current !== undefined && current !== t.pidStarttime) {
@@ -25,7 +25,7 @@ import {
25
25
  import { commandForLanguage } from "./command.js";
26
26
  import { handleStdoutLine, handleStderrLine } from "./output.js";
27
27
  import { handleTimeout, finalizeExit, failTrigger } from "./exit.js";
28
- import { readPidStarttimeSync } from "./pid.js";
28
+ import { readPidStarttimeSync } from "../../../core/daemon/pidfile.js";
29
29
 
30
30
  /**
31
31
  * Spawn a trigger's script as a supervised child process.
@@ -154,7 +154,8 @@ const frontendEnum = z.enum([
154
154
  *
155
155
  * Defaults are loopback-only and unauthenticated (single-machine use). To
156
156
  * reach Talon remotely, set `host: "0.0.0.0"` and a `token` — the bridge
157
- * then requires `Authorization: Bearer <token>` (or `?token=` for SSE).
157
+ * then requires `Authorization: Bearer <token>` (or `?token=` on `GET /events`
158
+ * and `GET /media` only, where clients can't set a header).
158
159
  * A non-loopback bind with no token auto-mints a persistent one
159
160
  * (~/.talon/keys/bridge-token) rather than serving the LAN open.
160
161
  */
@@ -170,9 +171,10 @@ const nativeConfigSchema = z
170
171
  host: z.string().default("127.0.0.1"),
171
172
  /**
172
173
  * Optional shared secret. When set, every request must present it as a
173
- * bearer token (header) or `?token=` query param (SSE). When unset on a
174
- * non-loopback `host`, the bridge mints and persists one automatically
175
- * (~/.talon/keys/bridge-token) — the network never gets an open bridge.
174
+ * bearer token (header), or a `?token=` query param on `GET /events` and
175
+ * `GET /media` only. When unset on a non-loopback `host`, the bridge
176
+ * mints and persists one automatically (~/.talon/keys/bridge-token) —
177
+ * the network never gets an open bridge.
176
178
  */
177
179
  token: z.string().optional(),
178
180
  /**
@@ -316,6 +318,80 @@ const whatsappConfigSchema = z
316
318
  })
317
319
  .strict();
318
320
 
321
+ /** `fetch` — the fetch ladder behind `fetch_url` (docs/fetch-ladder.md). */
322
+ export const fetchConfigSchema = z
323
+ .object({
324
+ /** Browser-TLS impersonation via curl-impersonate (default true). */
325
+ impersonate: z.boolean().default(true),
326
+ /** curl-impersonate profiles for the direct rung, tried in order. */
327
+ impersonateTargets: z
328
+ .array(
329
+ z
330
+ .string()
331
+ .regex(
332
+ /^[a-z0-9_]+$/,
333
+ "a curl-impersonate target name, e.g. safari184",
334
+ ),
335
+ )
336
+ .min(1)
337
+ .optional(),
338
+ /** Use this curl-impersonate binary instead of the first-use download. */
339
+ curlImpersonatePath: z.string().trim().min(1).optional(),
340
+ /**
341
+ * SOCKS exits for the impersonation rung, e.g.
342
+ * "socks5h://socks-nl1.nordvpn.com:1080". Credentials are never
343
+ * accepted here: they come from TALON_FETCH_SOCKS_USER /
344
+ * TALON_FETCH_SOCKS_PASS or `socksCredentialsFile` (one line,
345
+ * "user:pass").
346
+ */
347
+ socksExits: z
348
+ .array(
349
+ z
350
+ .string()
351
+ .trim()
352
+ .refine((value) => {
353
+ try {
354
+ return /^socks(4a?|5h?):$/.test(new URL(value).protocol);
355
+ } catch {
356
+ return false;
357
+ }
358
+ }, "must be a socks5h://, socks5://, socks4a:// or socks4:// URL")
359
+ .refine((value) => {
360
+ try {
361
+ const u = new URL(value);
362
+ return !u.username && !u.password;
363
+ } catch {
364
+ return true;
365
+ }
366
+ }, "must not contain credentials — set TALON_FETCH_SOCKS_USER/TALON_FETCH_SOCKS_PASS or fetch.socksCredentialsFile"),
367
+ )
368
+ .default([]),
369
+ socksCredentialsFile: z.string().trim().min(1).optional(),
370
+ /**
371
+ * Anti-detect browser rung: connect to the Camoufox/Playwright server
372
+ * at `browserEndpoint` (default: the playwright plugin's endpoint).
373
+ */
374
+ camoufox: z.boolean().default(false),
375
+ browserEndpoint: z.string().trim().min(1).optional(),
376
+ /**
377
+ * Last resort: run curl on this mesh device (id or name). Off unless
378
+ * set — the device's owner pays for the traffic and the IP.
379
+ */
380
+ egressDevice: z.string().trim().min(1).optional(),
381
+ /**
382
+ * Daily cap (bytes) on traffic through relayed rungs (SOCKS exits and
383
+ * the egress device). 0 disables those rungs. Direct rungs are
384
+ * uncapped. The counter is in-memory and resets at UTC midnight and
385
+ * on restart.
386
+ */
387
+ dailyByteCap: z
388
+ .number()
389
+ .int()
390
+ .min(0)
391
+ .default(100 * 1024 * 1024),
392
+ })
393
+ .strict();
394
+
319
395
  const playwrightConfigSchema = z.object({
320
396
  enabled: z.boolean().default(false),
321
397
  /** Browser engine: chromium (default), chrome, firefox, webkit, msedge */
@@ -688,6 +764,14 @@ const configSchema = z.object({
688
764
  .object({ allowPrivateNetworks: z.boolean().default(true) })
689
765
  .strict()
690
766
  .optional(),
767
+ /**
768
+ * The fetch ladder behind `fetch_url` (docs/fetch-ladder.md): when a page
769
+ * answers with a bot wall it retries through browser-TLS impersonation,
770
+ * SOCKS exits, a plain fetch, an anti-detect browser and — only if set —
771
+ * a mesh device, in that order. Everything but impersonation is off
772
+ * until configured.
773
+ */
774
+ fetch: fetchConfigSchema.optional(),
691
775
  /**
692
776
  * Codex-specific OpenAI API key. Prefer this, CODEX_API_KEY, or
693
777
  * TALON_CODEX_KEY when the Codex backend should use API-key billing