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.
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/app.ts +30 -0
- package/src/backend/agy/process/orphans.ts +5 -1
- package/src/backend/claude-sdk/one-shot.ts +8 -1
- package/src/backend/codex/factory.ts +5 -1
- package/src/backend/codex/plan-usage.ts +12 -0
- package/src/backend/runtime/turn/turn-phases.ts +7 -1
- package/src/bootstrap.ts +1 -0
- package/src/cli/install-sources.ts +187 -35
- package/src/cli/plugin.ts +27 -11
- package/src/cli/skill.ts +60 -7
- package/src/cli.ts +18 -12
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/runner.ts +4 -0
- package/src/core/background/cron/job-oneshot.ts +7 -1
- package/src/core/background/cron/scheduler.ts +28 -14
- package/src/core/background/cron/spec.ts +23 -3
- package/src/core/background/heartbeat/agent.ts +6 -0
- package/src/core/background/triggers/resume.ts +19 -4
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/config/index.ts +88 -4
- package/src/core/daemon/discovery.ts +160 -0
- package/src/core/daemon/pidfile.ts +107 -0
- package/src/core/daemon/respawn.ts +4 -1
- package/src/core/engine/backend-router/breaker.ts +158 -0
- package/src/core/engine/backend-router/headroom.ts +90 -22
- package/src/core/engine/backend-router/index.ts +11 -0
- package/src/core/engine/gateway-actions/cron.ts +5 -0
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
- package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
- package/src/core/engine/gateway-actions/mesh.ts +4 -0
- package/src/core/engine/gateway-actions/models.ts +19 -4
- package/src/core/engine/gateway-routes.ts +31 -2
- package/src/core/engine/gateway.ts +11 -1
- package/src/core/fetch/classify.ts +73 -0
- package/src/core/fetch/curl-impersonate.ts +447 -0
- package/src/core/fetch/errors.ts +14 -0
- package/src/core/fetch/index.ts +133 -0
- package/src/core/fetch/ladder.ts +401 -0
- package/src/core/fetch/rungs.ts +319 -0
- package/src/core/fetch/types.ts +105 -0
- package/src/core/frontend-runtime/admin-notify.ts +100 -12
- package/src/core/mcp-hub/child-guard.ts +215 -0
- package/src/core/mcp-hub/child-transport.ts +89 -39
- package/src/core/mcp-hub/children.ts +65 -9
- package/src/core/mcp-hub/guest-scope.ts +3 -2
- package/src/core/mcp-hub/index.ts +36 -16
- package/src/core/mcp-hub/launcher.ts +81 -37
- package/src/core/mcp-hub/proxy-server.ts +12 -8
- package/src/core/mcp-hub/reaper.ts +120 -0
- package/src/core/mesh/credentials/index.ts +2 -2
- package/src/core/mesh/credentials/store.ts +1 -1
- package/src/core/mesh/devices/service.ts +8 -0
- package/src/core/mesh/links/bridge-links.ts +37 -0
- package/src/core/mesh/links/companion-pairing.ts +2 -2
- package/src/core/plugin/mcp.ts +8 -8
- package/src/core/tools/bridge.ts +3 -0
- package/src/core/tools/content/web.ts +1 -1
- package/src/core/tools/ops/mesh.ts +21 -0
- package/src/core/tools/ops/scheduling.ts +15 -0
- package/src/frontend/native/bridge/auth-guard.ts +117 -53
- package/src/frontend/native/bridge/credentials/claims.ts +11 -1
- package/src/frontend/native/bridge/credentials/principal.ts +13 -1
- package/src/frontend/native/bridge/routes/table.ts +13 -0
- package/src/frontend/native/bridge/server.ts +37 -11
- package/src/frontend/telegram/actions/media.ts +164 -12
- package/src/index.ts +22 -19
- package/src/plugins/playwright/version-coupling.ts +172 -62
- package/src/storage/cron.ts +34 -2
- package/src/storage/db.ts +1 -0
- package/src/storage/repositories/cron-repo.ts +9 -0
- package/src/storage/skills.ts +7 -1
- package/src/storage/sql/cron.sql +5 -5
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/statements.generated.ts +10 -6
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +2 -0
- package/src/core/background/triggers/pid.ts +0 -28
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 {
|
|
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
|
|
124
|
-
|
|
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
|
|
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 =
|
|
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
|
|
27
|
-
// Lua trigger scripts (`_lua-run`) by re-invoking its own
|
|
28
|
-
// (see core/mcp-hub/launcher.ts).
|
|
29
|
-
// exits from its own handlers.
|
|
30
|
-
|
|
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 (
|
|
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 {
|
|
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
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// breaker opens
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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):
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
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 {
|
|
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 "
|
|
28
|
+
import { readPidStarttimeSync } from "../../../core/daemon/pidfile.js";
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Spawn a trigger's script as a supervised child process.
|
package/src/core/config/index.ts
CHANGED
|
@@ -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=`
|
|
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
|
|
174
|
-
* non-loopback `host`, the bridge
|
|
175
|
-
* (~/.talon/keys/bridge-token) —
|
|
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
|