talon-agent 5.9.0 → 5.11.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.
- package/README.md +28 -1
- package/package.json +1 -1
- package/src/app.ts +27 -9
- package/src/backend/agy/factory.ts +7 -5
- package/src/backend/agy/plan-usage.ts +240 -0
- package/src/backend/claude-sdk/plan-usage.ts +58 -1
- package/src/bootstrap.ts +4 -1
- package/src/core/agent-runtime/capabilities.ts +2 -0
- package/src/core/agents/registry.ts +2 -1
- package/src/core/background/triggers/caps.ts +78 -0
- package/src/core/background/triggers/index.ts +6 -0
- package/src/core/background/triggers/state.ts +12 -1
- package/src/core/config/index.ts +114 -12
- package/src/core/daemon/crash.ts +23 -0
- package/src/core/engine/backend-router/ledger.ts +4 -4
- package/src/core/engine/gateway-actions/triggers.ts +6 -8
- package/src/core/mcp-hub/launcher.ts +82 -22
- package/src/core/tools/ops/triggers.ts +2 -1
- package/src/frontend/presentation/reports.ts +4 -1
- package/src/frontend/presentation/status-context.ts +5 -0
- package/src/storage/triggers.ts +1 -1
package/README.md
CHANGED
|
@@ -488,7 +488,7 @@ Config file: `~/.talon/config.json`
|
|
|
488
488
|
| `heartbeatModel` | --- | Model for the heartbeat agent (falls back to `model`) |
|
|
489
489
|
| `heartbeatEffort` | --- | Reasoning effort for the heartbeat agent: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Unset = the model's own default |
|
|
490
490
|
| `router` | --- | Plan-aware routing for background work: `{ "enabled": true, "ceilingPercent": 85 }`. Unpinned sub-agents, cron `query` jobs and heartbeats run on whichever backend has the most plan headroom, skipping any whose tightest window is at or above the ceiling. `enabled: false` restores inherit-the-caller's-backend ([Backends](docs/backends.md)) |
|
|
491
|
-
| `backendBudgets` | --- | Soft token budgets for backends with no usage API, e.g. `{ "
|
|
491
|
+
| `backendBudgets` | --- | Soft token budgets for backends with no usage API, e.g. `{ "openai-agents": { "tokensPer5h": 2000000, "tokensPerDay": 8000000 } }`. Talon's own rolling ledger is measured against these so such a backend still has a headroom signal — and it is what opts an idle backend into routing. `agy` reports its real quota windows (via `agy -p /usage`); a budget there is only a fallback for when that read fails |
|
|
492
492
|
| `dreamModel` | --- | Model for dream / memory consolidation (falls back to `model`) |
|
|
493
493
|
| `dreamEffort` | --- | Reasoning effort for the dream agent — same levels as `heartbeatEffort` |
|
|
494
494
|
| `braveApiKey` | --- | Brave Search API key |
|
|
@@ -508,6 +508,33 @@ Config file: `~/.talon/config.json`
|
|
|
508
508
|
| `memory` | --- | Long-term memory backend selection: `mempalace` or `mem0` (see above) |
|
|
509
509
|
| `mempalace` | --- | Legacy MemPalace plugin config (prefer `memory`) |
|
|
510
510
|
| `playwright` | --- | Playwright plugin config (see above) |
|
|
511
|
+
| `triggers` | --- | Per-chat trigger caps, e.g. `{ "maxActivePerChat": 5, "maxPersistentPerChat": 3 }` ([Scaling limits](#scaling-limits)) |
|
|
512
|
+
| `agents` | --- | Sub-agent caps: `{ "maxConcurrent": 6, "maxDepth": 2, "defaultTimeoutMs": 900000 }` ([docs/agents.md](docs/agents.md)) |
|
|
513
|
+
|
|
514
|
+
### Scaling limits
|
|
515
|
+
|
|
516
|
+
Two caps bound how much background work Talon keeps alive. Both keep their
|
|
517
|
+
historical defaults and are raised in `~/.talon/config.json`; the error a
|
|
518
|
+
capped tool returns names the key to raise.
|
|
519
|
+
|
|
520
|
+
| Key | Default | Bounds | What it caps |
|
|
521
|
+
| ------------------------------- | ------- | ------ | ------------------------------------------------------------ |
|
|
522
|
+
| `triggers.maxActivePerChat` | `5` | 1–50 | Active (running or pending) triggers per chat |
|
|
523
|
+
| `triggers.maxPersistentPerChat` | unset | 1–50 | Optional separate budget for persistent triggers (see below) |
|
|
524
|
+
| `agents.maxConcurrent` | `6` | 1–64 | Live sub-agents daemon-wide, children included |
|
|
525
|
+
|
|
526
|
+
With `maxPersistentPerChat` unset, persistent and ad-hoc triggers share
|
|
527
|
+
`maxActivePerChat`, exactly as before. Set it and the two draw from separate
|
|
528
|
+
budgets: persistent triggers count only against `maxPersistentPerChat`, and
|
|
529
|
+
`maxActivePerChat` then bounds ad-hoc (non-persistent) triggers only — so a
|
|
530
|
+
chat running long-lived watchers still has room for a short CI wait. Caps are
|
|
531
|
+
checked at `trigger_create`; lowering one never kills a trigger already
|
|
532
|
+
running, and persistent triggers resumed after a restart are not re-checked.
|
|
533
|
+
|
|
534
|
+
```json
|
|
535
|
+
"triggers": { "maxActivePerChat": 5, "maxPersistentPerChat": 6 },
|
|
536
|
+
"agents": { "maxConcurrent": 12 }
|
|
537
|
+
```
|
|
511
538
|
|
|
512
539
|
### Background reasoning effort
|
|
513
540
|
|
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* are loaded dynamically — only the selected platform's dependencies are required.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { getFrontends } from "./core/config/index.js";
|
|
9
|
+
import { ConfigFileError, getFrontends } from "./core/config/index.js";
|
|
10
10
|
import { startUploadCleanup, stopUploadCleanup } from "./core/vfs/workspace.js";
|
|
11
11
|
import { flushDatabase } from "./storage/db.js";
|
|
12
12
|
import { getActiveCount, stopAllTurns } from "./core/engine/dispatcher.js";
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
crashCleanup,
|
|
41
41
|
crashStep,
|
|
42
42
|
handleUncaughtException,
|
|
43
|
+
handleUnhandledRejection,
|
|
43
44
|
} from "./core/daemon/crash.js";
|
|
44
45
|
import { log, logError, logWarn } from "./util/log.js";
|
|
45
46
|
import { bootPhase, bootReport } from "./core/daemon/boot-timer.js";
|
|
@@ -119,9 +120,31 @@ async function applyStagedRestore(): Promise<string | null> {
|
|
|
119
120
|
);
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
|
|
123
|
+
/**
|
|
124
|
+
* A present-but-invalid config.json is fatal at startup: print the file
|
|
125
|
+
* path and every problem, then exit non-zero. Booting on defaults instead
|
|
126
|
+
* would put the daemon in a surprising state (wrong frontend, no plugins).
|
|
127
|
+
*/
|
|
128
|
+
async function withConfigGuard<T>(fn: () => Promise<T>): Promise<T> {
|
|
129
|
+
try {
|
|
130
|
+
return await fn();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err instanceof ConfigFileError) {
|
|
133
|
+
logError("config", err.message);
|
|
134
|
+
console.error(`talon: ${err.message}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const restoreReport = await withConfigGuard(() =>
|
|
142
|
+
bootPhase("staged restore", applyStagedRestore),
|
|
143
|
+
);
|
|
123
144
|
|
|
124
|
-
const { config } = await
|
|
145
|
+
const { config } = await withConfigGuard(() =>
|
|
146
|
+
bootPhase("bootstrap", () => bootstrap()),
|
|
147
|
+
);
|
|
125
148
|
|
|
126
149
|
// Record this process as the daemon. The gateway port is appended once
|
|
127
150
|
// the gateway binds (it may fall back from the default on EADDRINUSE).
|
|
@@ -361,12 +384,7 @@ process.on("uncaughtException", (err) =>
|
|
|
361
384
|
handleUncaughtException(err, crashHooks),
|
|
362
385
|
);
|
|
363
386
|
|
|
364
|
-
process.on("unhandledRejection",
|
|
365
|
-
logWarn(
|
|
366
|
-
"bot",
|
|
367
|
-
`Unhandled rejection: ${reason instanceof Error ? reason.message : reason}`,
|
|
368
|
-
);
|
|
369
|
-
});
|
|
387
|
+
process.on("unhandledRejection", handleUnhandledRejection);
|
|
370
388
|
|
|
371
389
|
// ── Start ────────────────────────────────────────────────────────────────────
|
|
372
390
|
|
|
@@ -35,6 +35,7 @@ import { evictOrphanSubprocesses } from "./process/orphans.js";
|
|
|
35
35
|
import { getState, resetState } from "./state.js";
|
|
36
36
|
import { resetChat, warmSession, refreshTools } from "./sessions.js";
|
|
37
37
|
import { killAllChildren } from "./process/child.js";
|
|
38
|
+
import { getAgyPlanUsage, resetAgyPlanUsage } from "./plan-usage.js";
|
|
38
39
|
import { unregisterAllMcp } from "./mcp/register.js";
|
|
39
40
|
import {
|
|
40
41
|
resolveModel,
|
|
@@ -92,11 +93,11 @@ const agyFactory: BackendFactory = {
|
|
|
92
93
|
// cache-write counter in its usage payload, hence `cacheMetrics: "read"`.
|
|
93
94
|
const usage: UsageTelemetry = {
|
|
94
95
|
getSessionSnapshot: async (chatId) => getState().lastUsage.get(chatId),
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
getPlanUsage:
|
|
96
|
+
// Quota windows come from `agy -p /usage --output-format text`
|
|
97
|
+
// (see plan-usage.ts): at most one spawn per cache window, and
|
|
98
|
+
// undefined on any failure so headroom falls back to the local
|
|
99
|
+
// `backendBudgets` ledger.
|
|
100
|
+
getPlanUsage: () => getAgyPlanUsage(),
|
|
100
101
|
};
|
|
101
102
|
|
|
102
103
|
const control: SystemControl = {
|
|
@@ -127,6 +128,7 @@ const agyFactory: BackendFactory = {
|
|
|
127
128
|
killAllChildren("shutdown");
|
|
128
129
|
unregisterAllMcp();
|
|
129
130
|
resetModelCache();
|
|
131
|
+
resetAgyPlanUsage();
|
|
130
132
|
resetState();
|
|
131
133
|
log("bot", "Antigravity backend cleaned up");
|
|
132
134
|
},
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Antigravity subscription quota windows for `/usage`, `/status` and the
|
|
3
|
+
* plan-aware router.
|
|
4
|
+
*
|
|
5
|
+
* `agy` has no account API, but its `/usage` slash command also runs
|
|
6
|
+
* headlessly: `agy -p /usage --output-format text` prints one
|
|
7
|
+
* tab-separated line per quota window and exits without a model call:
|
|
8
|
+
*
|
|
9
|
+
* Gemini Models Weekly Limit Remaining 60% 2026-09-26T17:40:26Z
|
|
10
|
+
* Gemini Models Five Hour Limit Remaining 100% 2026-09-23T21:23:05Z
|
|
11
|
+
*
|
|
12
|
+
* Columns are the model group, the window, the percent REMAINING, and the
|
|
13
|
+
* next reset (ISO-8601 UTC). Talon speaks percent used, so the figure is
|
|
14
|
+
* flipped on the way in.
|
|
15
|
+
*
|
|
16
|
+
* A read is a subprocess spawn (~a second of Go start-up plus an account
|
|
17
|
+
* round-trip), so it is cached for a minute, concurrent callers share one
|
|
18
|
+
* spawn, and a failed read backs off before trying again. Everything
|
|
19
|
+
* degrades to the last good value or `undefined` — never a throw, so
|
|
20
|
+
* `/status` and the router fall through to the local-budget ledger.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { logWarn } from "../../util/log.js";
|
|
25
|
+
import type {
|
|
26
|
+
PlanUsage,
|
|
27
|
+
PlanWindow,
|
|
28
|
+
} from "../../core/agent-runtime/capabilities.js";
|
|
29
|
+
import { AGY_KILL_GRACE_MS } from "./constants.js";
|
|
30
|
+
import { agyBinary } from "./state.js";
|
|
31
|
+
|
|
32
|
+
/** argv for the headless quota report. */
|
|
33
|
+
const AGY_USAGE_ARGS: readonly string[] = [
|
|
34
|
+
"-p",
|
|
35
|
+
"/usage",
|
|
36
|
+
"--output-format",
|
|
37
|
+
"text",
|
|
38
|
+
];
|
|
39
|
+
const RUN_TIMEOUT_MS = 20_000;
|
|
40
|
+
const CACHE_TTL_MS = 60_000;
|
|
41
|
+
/** After a failed read, how long to wait before spawning `agy` again. */
|
|
42
|
+
const FAILURE_BACKOFF_MS = 15_000;
|
|
43
|
+
/** The report is a handful of lines; anything past this is not it. */
|
|
44
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
45
|
+
|
|
46
|
+
// ── Parsing ─────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const HOUR_WORDS: Record<string, number> = {
|
|
49
|
+
one: 1,
|
|
50
|
+
two: 2,
|
|
51
|
+
three: 3,
|
|
52
|
+
four: 4,
|
|
53
|
+
five: 5,
|
|
54
|
+
six: 6,
|
|
55
|
+
eight: 8,
|
|
56
|
+
twelve: 12,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Short duration label for a window name, in the claude/codex vocabulary
|
|
61
|
+
* (`5h`, `7d`). Unknown window kinds return undefined and are skipped
|
|
62
|
+
* rather than rendered under a guessed name.
|
|
63
|
+
*/
|
|
64
|
+
function windowLabel(name: string): string | undefined {
|
|
65
|
+
const lower = name.toLowerCase();
|
|
66
|
+
if (/\bweekly\b/.test(lower)) return "7d";
|
|
67
|
+
if (/\bdaily\b/.test(lower)) return "1d";
|
|
68
|
+
const hours = /\b(\d+|[a-z]+)[\s-]+hours?\b/.exec(lower);
|
|
69
|
+
if (hours) {
|
|
70
|
+
const raw = hours[1] as string;
|
|
71
|
+
const n = /^\d+$/.test(raw) ? Number(raw) : HOUR_WORDS[raw];
|
|
72
|
+
if (n && n > 0) return n % 24 === 0 ? `${n / 24}d` : `${n}h`;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `Gemini Models` → `Gemini`, `Claude and GPT models` → `Claude/GPT`. */
|
|
78
|
+
function groupLabel(name: string): string {
|
|
79
|
+
const short = name
|
|
80
|
+
.replace(/\s+models?$/i, "")
|
|
81
|
+
.replace(/\s+and\s+/gi, "/")
|
|
82
|
+
.trim();
|
|
83
|
+
return short.length > 0 ? short : name.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Percent remaining (`60%`, `60`, `12.5 %`) → percent used, 0-100. */
|
|
87
|
+
function usedPercent(raw: string): number | undefined {
|
|
88
|
+
const match = /^(\d+(?:\.\d+)?)\s*%?$/.exec(raw.trim());
|
|
89
|
+
if (!match) return undefined;
|
|
90
|
+
const remaining = Number(match[1]);
|
|
91
|
+
if (!Number.isFinite(remaining)) return undefined;
|
|
92
|
+
return Math.max(0, Math.min(100, Math.round(100 - remaining)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// oxlint-disable-next-line no-control-regex -- stripping terminal escapes
|
|
96
|
+
const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
97
|
+
|
|
98
|
+
function columns(line: string): string[] {
|
|
99
|
+
const clean = line.replace(ANSI, "").trim();
|
|
100
|
+
// Tabs are the real separator; runs of 2+ spaces cover a report that
|
|
101
|
+
// was padded into aligned columns instead.
|
|
102
|
+
const parts = clean.includes("\t")
|
|
103
|
+
? clean.split(/\t+/)
|
|
104
|
+
: clean.split(/ {2,}/);
|
|
105
|
+
return parts.map((p) => p.trim()).filter((p) => p.length > 0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseLine(line: string): PlanWindow | undefined {
|
|
109
|
+
const [group, window, percent, reset] = columns(line);
|
|
110
|
+
if (!group || !window || !percent) return undefined;
|
|
111
|
+
const duration = windowLabel(window);
|
|
112
|
+
const used = usedPercent(percent);
|
|
113
|
+
if (!duration || used === undefined) return undefined;
|
|
114
|
+
return {
|
|
115
|
+
label: `${groupLabel(group)} · ${duration}`,
|
|
116
|
+
percent: used,
|
|
117
|
+
...(reset && Number.isFinite(Date.parse(reset)) ? { resetsAt: reset } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Parse `agy /usage` text output. Malformed or unknown lines are skipped;
|
|
123
|
+
* `undefined` when no line yields a window.
|
|
124
|
+
*/
|
|
125
|
+
export function parseAgyUsage(stdout: string): PlanUsage | undefined {
|
|
126
|
+
const windows: PlanWindow[] = [];
|
|
127
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
128
|
+
const window = parseLine(line);
|
|
129
|
+
if (window) windows.push(window);
|
|
130
|
+
}
|
|
131
|
+
if (windows.length === 0) return undefined;
|
|
132
|
+
return { windows, fetchedAt: Date.now() };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Running ─────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Spawn `agy -p /usage` once and parse what it prints. Non-zero exit,
|
|
139
|
+
* spawn error, timeout or unparseable output all resolve `undefined`.
|
|
140
|
+
*/
|
|
141
|
+
export function runAgyUsage(
|
|
142
|
+
binary: string = agyBinary(),
|
|
143
|
+
timeoutMs: number = RUN_TIMEOUT_MS,
|
|
144
|
+
): Promise<PlanUsage | undefined> {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
let settled = false;
|
|
147
|
+
let stdout = "";
|
|
148
|
+
let stderr = "";
|
|
149
|
+
const finish = (value: PlanUsage | undefined, why?: string) => {
|
|
150
|
+
if (settled) return;
|
|
151
|
+
settled = true;
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
if (why) logWarn("agent", `agy usage: ${why}`);
|
|
154
|
+
resolve(value);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
let proc: ReturnType<typeof spawn>;
|
|
158
|
+
try {
|
|
159
|
+
proc = spawn(binary, [...AGY_USAGE_ARGS], {
|
|
160
|
+
env: process.env,
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
});
|
|
164
|
+
} catch (err) {
|
|
165
|
+
resolve(undefined);
|
|
166
|
+
logWarn(
|
|
167
|
+
"agent",
|
|
168
|
+
`agy usage: ${err instanceof Error ? err.message : String(err)}`,
|
|
169
|
+
);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
finish(undefined, `timed out after ${timeoutMs}ms`);
|
|
175
|
+
proc.kill("SIGTERM");
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
if (proc.exitCode === null && proc.signalCode === null) {
|
|
178
|
+
proc.kill("SIGKILL");
|
|
179
|
+
}
|
|
180
|
+
}, AGY_KILL_GRACE_MS).unref?.();
|
|
181
|
+
}, timeoutMs);
|
|
182
|
+
|
|
183
|
+
proc.stdout?.setEncoding("utf-8");
|
|
184
|
+
proc.stdout?.on("data", (chunk: string) => {
|
|
185
|
+
if (stdout.length < MAX_OUTPUT_BYTES) stdout += chunk;
|
|
186
|
+
});
|
|
187
|
+
proc.stderr?.setEncoding("utf-8");
|
|
188
|
+
proc.stderr?.on("data", (chunk: string) => {
|
|
189
|
+
stderr = (stderr + chunk).slice(-500);
|
|
190
|
+
});
|
|
191
|
+
proc.on("error", (err) => finish(undefined, err.message));
|
|
192
|
+
proc.on("close", (code) => {
|
|
193
|
+
if (code !== 0) {
|
|
194
|
+
const tail = stderr.trim().split("\n").pop() ?? "";
|
|
195
|
+
finish(undefined, `exited ${code}${tail ? `: ${tail}` : ""}`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const usage = parseAgyUsage(stdout);
|
|
199
|
+
finish(usage, usage ? undefined : "no quota windows in output");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Cache ───────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
let cache: { value: PlanUsage; fetchedAt: number } | undefined;
|
|
207
|
+
let lastFailureAt: number | undefined;
|
|
208
|
+
let inFlight: Promise<PlanUsage | undefined> | undefined;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Plan windows, cached for a minute with concurrent callers sharing one
|
|
212
|
+
* spawn. A failed refresh serves the last good value (its `fetchedAt`
|
|
213
|
+
* lets renderers age it) and is not retried for {@link FAILURE_BACKOFF_MS},
|
|
214
|
+
* so a busy router cannot turn a broken `agy` into a spawn storm.
|
|
215
|
+
*/
|
|
216
|
+
export async function getAgyPlanUsage(): Promise<PlanUsage | undefined> {
|
|
217
|
+
const now = Date.now();
|
|
218
|
+
if (cache && now - cache.fetchedAt < CACHE_TTL_MS) return cache.value;
|
|
219
|
+
if (lastFailureAt !== undefined && now - lastFailureAt < FAILURE_BACKOFF_MS)
|
|
220
|
+
return cache?.value;
|
|
221
|
+
|
|
222
|
+
inFlight ??= runAgyUsage().finally(() => {
|
|
223
|
+
inFlight = undefined;
|
|
224
|
+
});
|
|
225
|
+
const loaded = await inFlight;
|
|
226
|
+
if (loaded) {
|
|
227
|
+
cache = { value: loaded, fetchedAt: loaded.fetchedAt };
|
|
228
|
+
lastFailureAt = undefined;
|
|
229
|
+
} else {
|
|
230
|
+
lastFailureAt = Date.now();
|
|
231
|
+
}
|
|
232
|
+
return loaded ?? cache?.value;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Drop the cached reading — backend cleanup and test isolation. */
|
|
236
|
+
export function resetAgyPlanUsage(): void {
|
|
237
|
+
cache = undefined;
|
|
238
|
+
lastFailureAt = undefined;
|
|
239
|
+
inFlight = undefined;
|
|
240
|
+
}
|
|
@@ -21,7 +21,14 @@ import type {
|
|
|
21
21
|
PlanWindow,
|
|
22
22
|
} from "../../core/agent-runtime/capabilities.js";
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// `cedar_ember=1` asks the endpoint to include banked limit resets (the
|
|
25
|
+
// claude.ai "Reset for free" grants); `skip_spend=1` drops the spend block we
|
|
26
|
+
// don't render. Resets are only reported to the CLI surface — any other
|
|
27
|
+
// user agent gets `ineligible_reason: "surface"` — so the request identifies
|
|
28
|
+
// as the CLI, which is what the Agent SDK runs anyway.
|
|
29
|
+
const USAGE_ENDPOINT =
|
|
30
|
+
"https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1";
|
|
31
|
+
const CLI_USER_AGENT = "claude-cli/2.1.280 (external, cli)";
|
|
25
32
|
const REQUEST_TIMEOUT_MS = 5_000;
|
|
26
33
|
const CACHE_TTL_MS = 60_000;
|
|
27
34
|
|
|
@@ -77,6 +84,48 @@ function windowLabel(limit: RawLimit): string | undefined {
|
|
|
77
84
|
return undefined;
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
interface RawResetGrant {
|
|
88
|
+
resets_left?: number;
|
|
89
|
+
ends_at?: string | null;
|
|
90
|
+
paused?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Banked limit resets still usable: unpaused grants whose window hasn't
|
|
95
|
+
* closed. Returns the count and the soonest deadline among grants that still
|
|
96
|
+
* hold a reset, or undefined when there's nothing to offer.
|
|
97
|
+
*/
|
|
98
|
+
export function parseBankedResets(
|
|
99
|
+
body: unknown,
|
|
100
|
+
now = Date.now(),
|
|
101
|
+
): { count: number; expiresAt?: string } | undefined {
|
|
102
|
+
const program = (body as { cedar_ember?: unknown } | null)?.cedar_ember as
|
|
103
|
+
{ eligible?: boolean; grants?: unknown } | null | undefined;
|
|
104
|
+
if (!program || program.eligible === false || !Array.isArray(program.grants))
|
|
105
|
+
return undefined;
|
|
106
|
+
|
|
107
|
+
let count = 0;
|
|
108
|
+
let expiresAt: string | undefined;
|
|
109
|
+
for (const grant of program.grants as RawResetGrant[]) {
|
|
110
|
+
const left = grant.resets_left;
|
|
111
|
+
if (typeof left !== "number" || !Number.isFinite(left) || left <= 0)
|
|
112
|
+
continue;
|
|
113
|
+
if (grant.paused === true) continue;
|
|
114
|
+
const ends =
|
|
115
|
+
typeof grant.ends_at === "string" ? Date.parse(grant.ends_at) : NaN;
|
|
116
|
+
if (Number.isFinite(ends) && ends <= now) continue;
|
|
117
|
+
count += Math.floor(left);
|
|
118
|
+
if (
|
|
119
|
+
typeof grant.ends_at === "string" &&
|
|
120
|
+
Number.isFinite(ends) &&
|
|
121
|
+
(!expiresAt || ends < Date.parse(expiresAt))
|
|
122
|
+
)
|
|
123
|
+
expiresAt = grant.ends_at;
|
|
124
|
+
}
|
|
125
|
+
if (count <= 0) return undefined;
|
|
126
|
+
return { count, ...(expiresAt ? { expiresAt } : {}) };
|
|
127
|
+
}
|
|
128
|
+
|
|
80
129
|
export function parsePlanUsage(
|
|
81
130
|
body: unknown,
|
|
82
131
|
subscriptionType?: string,
|
|
@@ -103,9 +152,16 @@ export function parsePlanUsage(
|
|
|
103
152
|
}
|
|
104
153
|
|
|
105
154
|
if (windows.length === 0) return undefined;
|
|
155
|
+
const banked = parseBankedResets(body);
|
|
106
156
|
return {
|
|
107
157
|
...(subscriptionType ? { plan: subscriptionType } : {}),
|
|
108
158
|
windows,
|
|
159
|
+
...(banked
|
|
160
|
+
? {
|
|
161
|
+
resetsAvailable: banked.count,
|
|
162
|
+
...(banked.expiresAt ? { resetsExpireAt: banked.expiresAt } : {}),
|
|
163
|
+
}
|
|
164
|
+
: {}),
|
|
109
165
|
fetchedAt: Date.now(),
|
|
110
166
|
};
|
|
111
167
|
}
|
|
@@ -119,6 +175,7 @@ async function load(): Promise<PlanUsage | undefined> {
|
|
|
119
175
|
headers: {
|
|
120
176
|
Authorization: `Bearer ${creds.accessToken}`,
|
|
121
177
|
"anthropic-beta": "oauth-2025-04-20",
|
|
178
|
+
"User-Agent": CLI_USER_AGENT,
|
|
122
179
|
},
|
|
123
180
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
124
181
|
});
|
package/src/bootstrap.ts
CHANGED
|
@@ -607,7 +607,10 @@ function initRecurringAgents(
|
|
|
607
607
|
* same delivery shape, so they are wired together.
|
|
608
608
|
*/
|
|
609
609
|
function initWakeSubsystems(config: TalonConfig): void {
|
|
610
|
-
initTriggers({
|
|
610
|
+
initTriggers({
|
|
611
|
+
execute: dispatcherExecute,
|
|
612
|
+
...(config.triggers ? { caps: config.triggers } : {}),
|
|
613
|
+
});
|
|
611
614
|
initAgents({
|
|
612
615
|
execute: dispatcherExecute,
|
|
613
616
|
...(config.agents ? { caps: config.agents } : {}),
|
|
@@ -261,6 +261,8 @@ export interface PlanUsage {
|
|
|
261
261
|
windows: PlanWindow[];
|
|
262
262
|
/** How many one-shot rate-limit resets are still banked, when the plan has them. */
|
|
263
263
|
resetsAvailable?: number;
|
|
264
|
+
/** ISO time the soonest-expiring banked reset must be used by, when the plan says. */
|
|
265
|
+
resetsExpireAt?: string;
|
|
264
266
|
/** Epoch ms of the read, so renderers can flag figures as aged. */
|
|
265
267
|
fetchedAt: number;
|
|
266
268
|
}
|
|
@@ -141,7 +141,8 @@ export class AgentRegistry {
|
|
|
141
141
|
ok: false,
|
|
142
142
|
error:
|
|
143
143
|
`Sub-agent concurrency cap reached (${caps.maxConcurrent} live). ` +
|
|
144
|
-
`Wait for one to finish (wait_for_agent) or kill one (kill_agent)
|
|
144
|
+
`Wait for one to finish (wait_for_agent) or kill one (kill_agent), ` +
|
|
145
|
+
`or raise agents.maxConcurrent in ~/.talon/config.json.`,
|
|
145
146
|
};
|
|
146
147
|
}
|
|
147
148
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-chat trigger caps — how many watcher scripts one chat may keep active.
|
|
3
|
+
*
|
|
4
|
+
* Configured by `config.triggers` (see core/config) and wired in once via
|
|
5
|
+
* initTriggers. The check is a pure function of the chat's active triggers so
|
|
6
|
+
* the gateway handler and the tests share one rule.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MAX_ACTIVE_PER_CHAT } from "../../../storage/triggers.js";
|
|
10
|
+
|
|
11
|
+
export type TriggerCaps = {
|
|
12
|
+
/** Active triggers per chat — all of them, or only ad-hoc ones when
|
|
13
|
+
* `maxPersistentPerChat` is set. */
|
|
14
|
+
readonly maxActivePerChat: number;
|
|
15
|
+
/** Optional separate budget for persistent triggers. */
|
|
16
|
+
readonly maxPersistentPerChat?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_TRIGGER_CAPS: TriggerCaps = {
|
|
20
|
+
maxActivePerChat: MAX_ACTIVE_PER_CHAT,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const capsHolder: { caps: TriggerCaps } = { caps: DEFAULT_TRIGGER_CAPS };
|
|
24
|
+
|
|
25
|
+
/** Replace the live caps. Missing fields fall back to the defaults. */
|
|
26
|
+
export function setTriggerCaps(caps?: Partial<TriggerCaps>): void {
|
|
27
|
+
const next: TriggerCaps = {
|
|
28
|
+
maxActivePerChat:
|
|
29
|
+
caps?.maxActivePerChat ?? DEFAULT_TRIGGER_CAPS.maxActivePerChat,
|
|
30
|
+
...(caps?.maxPersistentPerChat !== undefined
|
|
31
|
+
? { maxPersistentPerChat: caps.maxPersistentPerChat }
|
|
32
|
+
: {}),
|
|
33
|
+
};
|
|
34
|
+
capsHolder.caps = next;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getTriggerCaps(): TriggerCaps {
|
|
38
|
+
return capsHolder.caps;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Why a new trigger may not be created, or null when there is room.
|
|
43
|
+
*
|
|
44
|
+
* Without `maxPersistentPerChat`, every active trigger shares
|
|
45
|
+
* `maxActivePerChat` (the historical behaviour). With it, persistent and
|
|
46
|
+
* ad-hoc triggers draw from separate budgets.
|
|
47
|
+
*/
|
|
48
|
+
export function triggerCapError(
|
|
49
|
+
active: ReadonlyArray<{ persistent?: boolean }>,
|
|
50
|
+
persistent: boolean,
|
|
51
|
+
caps: TriggerCaps = capsHolder.caps,
|
|
52
|
+
): string | null {
|
|
53
|
+
const configPath = "~/.talon/config.json";
|
|
54
|
+
if (caps.maxPersistentPerChat === undefined) {
|
|
55
|
+
if (active.length < caps.maxActivePerChat) return null;
|
|
56
|
+
return (
|
|
57
|
+
`Per-chat trigger cap reached (${caps.maxActivePerChat} active). ` +
|
|
58
|
+
`Cancel one before creating another, or raise ` +
|
|
59
|
+
`triggers.maxActivePerChat in ${configPath}.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (persistent) {
|
|
63
|
+
const count = active.filter((t) => t.persistent === true).length;
|
|
64
|
+
if (count < caps.maxPersistentPerChat) return null;
|
|
65
|
+
return (
|
|
66
|
+
`Per-chat persistent trigger cap reached ` +
|
|
67
|
+
`(${caps.maxPersistentPerChat} persistent active). Cancel one before ` +
|
|
68
|
+
`creating another, or raise triggers.maxPersistentPerChat in ${configPath}.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const count = active.filter((t) => t.persistent !== true).length;
|
|
72
|
+
if (count < caps.maxActivePerChat) return null;
|
|
73
|
+
return (
|
|
74
|
+
`Per-chat ad-hoc trigger cap reached (${caps.maxActivePerChat} ` +
|
|
75
|
+
`non-persistent active). Cancel one before creating another, or raise ` +
|
|
76
|
+
`triggers.maxActivePerChat in ${configPath}.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Split by responsibility:
|
|
6
6
|
* - `state` — injected deps + the child/timeout/log/buffer registries,
|
|
7
7
|
* the warden set, timing constants, init + getRunningCount
|
|
8
|
+
* - `caps` — per-chat active-trigger caps (config.triggers)
|
|
8
9
|
* - `command` — interpreter resolution per script language
|
|
9
10
|
* - `output` — stdout/stderr capture, payload truncation, wake firing
|
|
10
11
|
* - `exit` — timeout/cancel/shutdown, child kill, finalizeExit, failTrigger
|
|
@@ -20,6 +21,11 @@ import { handleStdoutLine } from "./output.js";
|
|
|
20
21
|
import { handleTimeout, finalizeExit } from "./exit.js";
|
|
21
22
|
|
|
22
23
|
export { initTriggers, getRunningCount } from "./state.js";
|
|
24
|
+
export {
|
|
25
|
+
getTriggerCaps,
|
|
26
|
+
triggerCapError,
|
|
27
|
+
DEFAULT_TRIGGER_CAPS,
|
|
28
|
+
} from "./caps.js";
|
|
23
29
|
export { commandForLanguage } from "./command.js";
|
|
24
30
|
export { spawnTrigger } from "./spawn.js";
|
|
25
31
|
export { cancelTrigger, shutdownTriggers } from "./exit.js";
|
|
@@ -9,12 +9,15 @@ import type { ChildProcess } from "node:child_process";
|
|
|
9
9
|
import type { WriteStream } from "node:fs";
|
|
10
10
|
import { execute as dispatcherExecute } from "../../engine/dispatcher.js";
|
|
11
11
|
import { log } from "../../../util/log.js";
|
|
12
|
+
import { getTriggerCaps, setTriggerCaps, type TriggerCaps } from "./caps.js";
|
|
12
13
|
|
|
13
14
|
// ── Dependencies (injected at startup) ──────────────────────────────────────
|
|
14
15
|
|
|
15
16
|
export type TriggerDeps = {
|
|
16
17
|
/** Used for terminal "fired"/"errored" wake prompts that go through the model. */
|
|
17
18
|
execute: typeof dispatcherExecute;
|
|
19
|
+
/** Per-chat caps from `config.triggers`; defaults apply when absent. */
|
|
20
|
+
caps?: Partial<TriggerCaps>;
|
|
18
21
|
};
|
|
19
22
|
|
|
20
23
|
/** Reassignable on a holder object so submodules see the injected deps. */
|
|
@@ -52,7 +55,15 @@ export const WARDEN_GRACE_SLACK_MS = 2_000;
|
|
|
52
55
|
export function initTriggers(d: TriggerDeps): void {
|
|
53
56
|
depsHolder.deps = d;
|
|
54
57
|
lifecycle.shuttingDown = false;
|
|
55
|
-
|
|
58
|
+
setTriggerCaps(d.caps);
|
|
59
|
+
const caps = getTriggerCaps();
|
|
60
|
+
log(
|
|
61
|
+
"triggers",
|
|
62
|
+
`Initialized — maxActivePerChat=${caps.maxActivePerChat}` +
|
|
63
|
+
(caps.maxPersistentPerChat !== undefined
|
|
64
|
+
? ` maxPersistentPerChat=${caps.maxPersistentPerChat}`
|
|
65
|
+
: ""),
|
|
66
|
+
);
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/** Number of triggers currently running. */
|
package/src/core/config/index.ts
CHANGED
|
@@ -464,15 +464,18 @@ const configSchema = z.object({
|
|
|
464
464
|
})
|
|
465
465
|
.optional(),
|
|
466
466
|
/**
|
|
467
|
-
* Soft token budgets for backends with no account usage API
|
|
468
|
-
* openai-agents). Talon keeps a local rolling ledger of every turn and
|
|
467
|
+
* Soft token budgets for backends with no account usage API
|
|
468
|
+
* (openai-agents). Talon keeps a local rolling ledger of every turn and
|
|
469
469
|
* one-shot it runs on a backend and derives headroom from it, so a
|
|
470
470
|
* provider that cannot report a plan still has a load-balancing signal.
|
|
471
|
-
*
|
|
472
|
-
*
|
|
471
|
+
* A backend's own plan windows always win; for agy (which reads its
|
|
472
|
+
* quota from `agy -p /usage`) a budget is only the fallback for when
|
|
473
|
+
* that read fails. Keyed by backend id; a backend with no entry
|
|
474
|
+
* contributes no signal (headroom 1, ranked below any backend with real
|
|
475
|
+
* telemetry on a tie).
|
|
473
476
|
*
|
|
474
477
|
* Example:
|
|
475
|
-
* "backendBudgets": { "
|
|
478
|
+
* "backendBudgets": { "openai-agents": { "tokensPer5h": 2000000, "tokensPerDay": 8000000 } }
|
|
476
479
|
*/
|
|
477
480
|
backendBudgets: z
|
|
478
481
|
.record(
|
|
@@ -508,6 +511,25 @@ const configSchema = z.object({
|
|
|
508
511
|
.default(15 * 60 * 1000),
|
|
509
512
|
})
|
|
510
513
|
.optional(),
|
|
514
|
+
/**
|
|
515
|
+
* Triggers — per-chat caps on active watcher scripts (running or
|
|
516
|
+
* pending). Checked when `trigger_create` runs; triggers already running
|
|
517
|
+
* are never killed when a cap is lowered.
|
|
518
|
+
*
|
|
519
|
+
* - `maxActivePerChat` — active triggers per chat (default 5). When
|
|
520
|
+
* `maxPersistentPerChat` is unset this counts persistent and ad-hoc
|
|
521
|
+
* triggers together, exactly as before.
|
|
522
|
+
* - `maxPersistentPerChat` — optional separate budget for persistent
|
|
523
|
+
* triggers. When set, persistent triggers count only against it and
|
|
524
|
+
* `maxActivePerChat` bounds ad-hoc (non-persistent) triggers only, so
|
|
525
|
+
* long-lived watchers can't starve short ad-hoc ones.
|
|
526
|
+
*/
|
|
527
|
+
triggers: z
|
|
528
|
+
.object({
|
|
529
|
+
maxActivePerChat: z.number().int().min(1).max(50).default(5),
|
|
530
|
+
maxPersistentPerChat: z.number().int().min(1).max(50).optional(),
|
|
531
|
+
})
|
|
532
|
+
.optional(),
|
|
511
533
|
/**
|
|
512
534
|
* Backups & checkpoints (docs/backups.md). Talon's only safety net, so
|
|
513
535
|
* it is on by default: every `intervalHours` it writes a snapshot of
|
|
@@ -782,15 +804,84 @@ const DEFAULT_CONFIG = {
|
|
|
782
804
|
pulseIntervalMs: 300000,
|
|
783
805
|
};
|
|
784
806
|
|
|
807
|
+
/**
|
|
808
|
+
* A config.json that exists but cannot be used — unreadable, not JSON, or
|
|
809
|
+
* rejected by the schema. Thrown instead of falling back to defaults: a
|
|
810
|
+
* daemon that silently boots on defaults (e.g. the telegram frontend) is
|
|
811
|
+
* far more surprising than one that refuses to start. The file on disk is
|
|
812
|
+
* never touched. `issues` carries one line per problem for callers that
|
|
813
|
+
* want to render them individually.
|
|
814
|
+
*/
|
|
815
|
+
export class ConfigFileError extends Error {
|
|
816
|
+
constructor(
|
|
817
|
+
message: string,
|
|
818
|
+
readonly path: string,
|
|
819
|
+
readonly issues: readonly string[] = [],
|
|
820
|
+
) {
|
|
821
|
+
super(message);
|
|
822
|
+
this.name = "ConfigFileError";
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Append a line/column hint to a JSON.parse error message. Recent V8
|
|
828
|
+
* already includes "(line L column C)"; older runtimes only report
|
|
829
|
+
* "at position N", so derive it from the raw text in that case.
|
|
830
|
+
*/
|
|
831
|
+
function describeJsonError(err: unknown, raw: string): string {
|
|
832
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
833
|
+
if (/\(line \d+ column \d+\)/.test(message)) return message;
|
|
834
|
+
const match = /at position (\d+)/.exec(message);
|
|
835
|
+
if (!match) return message;
|
|
836
|
+
const before = raw.slice(0, Number(match[1]));
|
|
837
|
+
const line = before.split("\n").length;
|
|
838
|
+
const column = before.length - before.lastIndexOf("\n");
|
|
839
|
+
return `${message} (line ${line} column ${column})`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Render zod issues as `path: message` lines (`(root)` for top-level). */
|
|
843
|
+
function formatSchemaIssues(error: z.ZodError): string[] {
|
|
844
|
+
return error.issues.map((issue) => {
|
|
845
|
+
const path = issue.path.map(String).join(".") || "(root)";
|
|
846
|
+
return `${path}: ${issue.message}`;
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Read config.json. A missing file is `{}` (first run — defaults apply);
|
|
852
|
+
* a present file that cannot be read or parsed throws ConfigFileError.
|
|
853
|
+
*/
|
|
785
854
|
function loadConfigFile(): Record<string, unknown> {
|
|
855
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
856
|
+
let raw: string;
|
|
786
857
|
try {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
858
|
+
raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
859
|
+
} catch (err) {
|
|
860
|
+
throw new ConfigFileError(
|
|
861
|
+
`Cannot read ${CONFIG_FILE}: ${err instanceof Error ? err.message : err}`,
|
|
862
|
+
CONFIG_FILE,
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
let data: unknown;
|
|
866
|
+
try {
|
|
867
|
+
data = JSON.parse(raw);
|
|
868
|
+
} catch (err) {
|
|
869
|
+
const detail = describeJsonError(err, raw);
|
|
870
|
+
throw new ConfigFileError(
|
|
871
|
+
`Invalid JSON in ${CONFIG_FILE}: ${detail}. ` +
|
|
872
|
+
`The file was left untouched — fix it and start Talon again.`,
|
|
873
|
+
CONFIG_FILE,
|
|
874
|
+
[detail],
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
878
|
+
throw new ConfigFileError(
|
|
879
|
+
`Invalid config in ${CONFIG_FILE}: the top level must be a JSON object.`,
|
|
880
|
+
CONFIG_FILE,
|
|
881
|
+
["(root): expected a JSON object"],
|
|
882
|
+
);
|
|
792
883
|
}
|
|
793
|
-
return
|
|
884
|
+
return data as Record<string, unknown>;
|
|
794
885
|
}
|
|
795
886
|
|
|
796
887
|
function normalizeDeprecatedFrontendConfig(
|
|
@@ -876,7 +967,18 @@ export function loadConfig(): TalonConfig {
|
|
|
876
967
|
}
|
|
877
968
|
}
|
|
878
969
|
|
|
879
|
-
const
|
|
970
|
+
const result = configSchema.safeParse(fileConfig);
|
|
971
|
+
if (!result.success) {
|
|
972
|
+
const issues = formatSchemaIssues(result.error);
|
|
973
|
+
throw new ConfigFileError(
|
|
974
|
+
`Invalid config in ${CONFIG_FILE}:\n` +
|
|
975
|
+
issues.map((line) => ` - ${line}`).join("\n") +
|
|
976
|
+
`\nThe file was left untouched — fix it and start Talon again.`,
|
|
977
|
+
CONFIG_FILE,
|
|
978
|
+
issues,
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
const parsed = result.data;
|
|
880
982
|
|
|
881
983
|
// The soul kernel is gone (#953). Its config block still parses so an
|
|
882
984
|
// existing config.json keeps loading, but it no longer does anything —
|
package/src/core/daemon/crash.ts
CHANGED
|
@@ -80,3 +80,26 @@ export function handleUncaughtException(err: Error, hooks: CrashHooks): void {
|
|
|
80
80
|
crashStep("crash report", () => logError("bot", "Uncaught exception", err));
|
|
81
81
|
process.exit(1);
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `process.on("unhandledRejection")` body. Report — never crash — but keep
|
|
86
|
+
* the stack: a bare "Unhandled rejection: ENOSPC: no space left on device,
|
|
87
|
+
* write" says nothing about which code path forgot its `.catch()`. Async fs
|
|
88
|
+
* errors carry `path`/`syscall` rather than useful frames, so those ride
|
|
89
|
+
* along in the message too.
|
|
90
|
+
*/
|
|
91
|
+
export function handleUnhandledRejection(reason: unknown): void {
|
|
92
|
+
crashStep("rejection report", () => {
|
|
93
|
+
if (!(reason instanceof Error)) {
|
|
94
|
+
logError("bot", `Unhandled rejection: ${String(reason)}`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const { syscall, path } = reason as NodeJS.ErrnoException;
|
|
98
|
+
const where = [syscall, path].filter(Boolean).join(" ");
|
|
99
|
+
logError(
|
|
100
|
+
"bot",
|
|
101
|
+
`Unhandled rejection: ${reason.message}${where ? ` (${where})` : ""}`,
|
|
102
|
+
reason,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
* Local rolling token ledger — the headroom signal for backends with no
|
|
3
3
|
* account usage API.
|
|
4
4
|
*
|
|
5
|
-
* Claude and
|
|
6
|
-
*
|
|
7
|
-
* the router would treat those as infinitely fresh and pile every
|
|
8
|
-
* run onto them. So Talon counts what it spends itself: every chat turn,
|
|
5
|
+
* Claude, Codex and `agy` report subscription windows; `openai-agents` has
|
|
6
|
+
* no plan at all, and `agy`'s read (a CLI spawn) can fail. Without a second
|
|
7
|
+
* signal the router would treat those as infinitely fresh and pile every
|
|
8
|
+
* background run onto them. So Talon counts what it spends itself: every chat turn,
|
|
9
9
|
* one-shot and sub-agent folds its token total into a per-backend ledger,
|
|
10
10
|
* and `headroom.ts` reads that against the operator's soft budget
|
|
11
11
|
* (`config.backendBudgets`).
|
|
@@ -19,12 +19,12 @@ import {
|
|
|
19
19
|
validateTimeout,
|
|
20
20
|
writeScriptFile,
|
|
21
21
|
DEFAULT_TIMEOUT_SECONDS,
|
|
22
|
-
MAX_ACTIVE_PER_CHAT,
|
|
23
22
|
type TriggerLanguage,
|
|
24
23
|
} from "../../../storage/triggers.js";
|
|
25
24
|
import {
|
|
26
25
|
cancelTrigger,
|
|
27
26
|
spawnTrigger,
|
|
27
|
+
triggerCapError,
|
|
28
28
|
} from "../../background/triggers/index.js";
|
|
29
29
|
import { log } from "../../../util/log.js";
|
|
30
30
|
import { validateJobModelOverride } from "./validation.js";
|
|
@@ -61,13 +61,11 @@ export const triggerHandlers: SharedActionHandlers = {
|
|
|
61
61
|
error: `A trigger named "${name}" already exists in this chat. Cancel it first or pick a different name.`,
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
}
|
|
64
|
+
const capErr = triggerCapError(
|
|
65
|
+
getActiveTriggersForChat(chatKey),
|
|
66
|
+
persistent,
|
|
67
|
+
);
|
|
68
|
+
if (capErr) return { ok: false, error: capErr };
|
|
71
69
|
|
|
72
70
|
// Validate the model up front so a bad id is rejected here instead of
|
|
73
71
|
// silently failing at fire time.
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
* 2. `TALON_BRIDGE_URL/health` stops responding for several
|
|
41
41
|
* consecutive pings — Talon's gateway is gone. Catches the
|
|
42
42
|
* "kilo serve / opencode serve outlives Talon" case where those
|
|
43
|
-
* daemons keep our stdin open across Talon restarts.
|
|
43
|
+
* daemons keep our stdin open across Talon restarts. A ping that only
|
|
44
|
+
* times out (port still bound, gateway busy) is tolerated for minutes,
|
|
45
|
+
* not seconds — see BridgeWatchdog.
|
|
44
46
|
*/
|
|
45
47
|
|
|
46
48
|
import crossSpawn from "cross-spawn";
|
|
@@ -203,7 +205,75 @@ export function wrapMcpCommand(command: readonly string[]): string[] {
|
|
|
203
205
|
// within ~1 minute.
|
|
204
206
|
const BRIDGE_PING_INTERVAL_MS = 15_000;
|
|
205
207
|
const BRIDGE_PING_TIMEOUT_MS = 2_000;
|
|
206
|
-
const BRIDGE_FAILURES_BEFORE_EXIT = 4;
|
|
208
|
+
export const BRIDGE_FAILURES_BEFORE_EXIT = 4;
|
|
209
|
+
// A ping that TIMES OUT means the port is still bound — the kernel accepted
|
|
210
|
+
// the connection — but the gateway is too busy to answer within 2s (event
|
|
211
|
+
// loop saturated by a burst of agents, a big synchronous write, …). That is
|
|
212
|
+
// a live Talon, not a dead one, so it gets a much longer budget (~5 min)
|
|
213
|
+
// before the child is evicted. Only a truly wedged daemon reaches it.
|
|
214
|
+
export const BRIDGE_UNRESPONSIVE_BEFORE_EXIT = 20;
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Outcome of one bridge health ping:
|
|
218
|
+
* - "ok": /health answered 2xx.
|
|
219
|
+
* - "unreachable": nothing healthy behind the port (connection refused or
|
|
220
|
+
* reset, non-2xx reply) — Talon is gone or restarting.
|
|
221
|
+
* - "unresponsive": the request timed out — something holds the port but
|
|
222
|
+
* is slow to answer; Talon is alive but busy.
|
|
223
|
+
*/
|
|
224
|
+
export type BridgePingOutcome = "ok" | "unreachable" | "unresponsive";
|
|
225
|
+
|
|
226
|
+
/** Classify a rejected health fetch. Timeouts/aborts mean "busy", not "gone". */
|
|
227
|
+
export function classifyBridgePingError(
|
|
228
|
+
err: unknown,
|
|
229
|
+
): Exclude<BridgePingOutcome, "ok"> {
|
|
230
|
+
const name = (err as { name?: unknown } | null)?.name;
|
|
231
|
+
return name === "TimeoutError" || name === "AbortError"
|
|
232
|
+
? "unresponsive"
|
|
233
|
+
: "unreachable";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Ping `${bridgeUrl}/health` once. Never throws. */
|
|
237
|
+
export async function pingBridge(
|
|
238
|
+
bridgeUrl: string,
|
|
239
|
+
timeoutMs: number = BRIDGE_PING_TIMEOUT_MS,
|
|
240
|
+
): Promise<BridgePingOutcome> {
|
|
241
|
+
try {
|
|
242
|
+
const resp = await fetch(`${bridgeUrl}/health`, {
|
|
243
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
244
|
+
});
|
|
245
|
+
// Drain the body so the socket is released promptly.
|
|
246
|
+
await resp.arrayBuffer().catch(() => undefined);
|
|
247
|
+
return resp.ok ? "ok" : "unreachable";
|
|
248
|
+
} catch (err) {
|
|
249
|
+
return classifyBridgePingError(err);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Consecutive-failure bookkeeping for the bridge watchdog. `record()`
|
|
255
|
+
* returns true once the child should be shut down: after
|
|
256
|
+
* BRIDGE_FAILURES_BEFORE_EXIT failures ending in an "unreachable" ping (the
|
|
257
|
+
* port is really closed), or after BRIDGE_UNRESPONSIVE_BEFORE_EXIT failures
|
|
258
|
+
* of any kind (the daemon is wedged, not just busy).
|
|
259
|
+
*/
|
|
260
|
+
export class BridgeWatchdog {
|
|
261
|
+
consecutiveFailures = 0;
|
|
262
|
+
|
|
263
|
+
record(outcome: BridgePingOutcome): boolean {
|
|
264
|
+
if (outcome === "ok") {
|
|
265
|
+
this.consecutiveFailures = 0;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
this.consecutiveFailures += 1;
|
|
269
|
+
if (this.consecutiveFailures >= BRIDGE_UNRESPONSIVE_BEFORE_EXIT)
|
|
270
|
+
return true;
|
|
271
|
+
return (
|
|
272
|
+
outcome === "unreachable" &&
|
|
273
|
+
this.consecutiveFailures >= BRIDGE_FAILURES_BEFORE_EXIT
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
207
277
|
|
|
208
278
|
/**
|
|
209
279
|
* Run the supervisor over `argvTail` = [cmd, ...args].
|
|
@@ -346,29 +416,19 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
|
|
|
346
416
|
// (every Talon-spawned MCP server has it; ad-hoc supervisor uses
|
|
347
417
|
// without the env var keep the stdin-EOF-only behavior).
|
|
348
418
|
if (BRIDGE_URL) {
|
|
349
|
-
|
|
419
|
+
const watchdog = new BridgeWatchdog();
|
|
350
420
|
const tick = async (): Promise<void> => {
|
|
351
421
|
if (terminating) return;
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
consecutiveFailures += 1;
|
|
361
|
-
} catch {
|
|
362
|
-
consecutiveFailures += 1;
|
|
363
|
-
}
|
|
364
|
-
if (consecutiveFailures >= BRIDGE_FAILURES_BEFORE_EXIT) {
|
|
365
|
-
// Talon's gateway is gone. The MCP child has nothing useful to
|
|
366
|
-
// serve — bridge calls would 404 against a dead port — so shut
|
|
367
|
-
// down. Kilo/OpenCode notice the stdio close on the next
|
|
368
|
-
// interaction and drop the registration on their side.
|
|
422
|
+
const outcome = await pingBridge(BRIDGE_URL);
|
|
423
|
+
if (terminating) return;
|
|
424
|
+
if (watchdog.record(outcome)) {
|
|
425
|
+
// Talon's gateway is gone (or wedged for minutes). The MCP child
|
|
426
|
+
// has nothing useful to serve — bridge calls would 404 against a
|
|
427
|
+
// dead port — so shut down. Kilo/OpenCode notice the stdio close
|
|
428
|
+
// on the next interaction and drop the registration on their side.
|
|
369
429
|
process.stderr.write(
|
|
370
|
-
`mcp-launcher: bridge ${BRIDGE_URL}
|
|
371
|
-
consecutiveFailures * (BRIDGE_PING_INTERVAL_MS / 1000)
|
|
430
|
+
`mcp-launcher: bridge ${BRIDGE_URL} ${outcome} for ${
|
|
431
|
+
watchdog.consecutiveFailures * (BRIDGE_PING_INTERVAL_MS / 1000)
|
|
372
432
|
}s; shutting down child\n`,
|
|
373
433
|
);
|
|
374
434
|
terminate(0);
|
|
@@ -57,7 +57,8 @@ shutdown/crash are respawned (not ones that exited on their own), the
|
|
|
57
57
|
script must be safe to re-run from scratch, and timeout_seconds is ignored
|
|
58
58
|
(persistent triggers run until cancelled or until Talon shuts down).
|
|
59
59
|
|
|
60
|
-
Per-chat cap
|
|
60
|
+
Per-chat cap on active triggers (default 5; config triggers.maxActivePerChat,
|
|
61
|
+
with an optional separate triggers.maxPersistentPerChat budget).`;
|
|
61
62
|
|
|
62
63
|
export const triggerTools: ToolDefinition[] = [
|
|
63
64
|
{
|
|
@@ -462,7 +462,10 @@ export function renderUsageMessage(
|
|
|
462
462
|
if (entry.plan.resetsAvailable) {
|
|
463
463
|
const n = entry.plan.resetsAvailable;
|
|
464
464
|
const resets = `usage limit reset${n === 1 ? "" : "s"} available`;
|
|
465
|
-
|
|
465
|
+
const by = entry.plan.resetsExpireLabel
|
|
466
|
+
? ` ${fmt.escape(`(use by ${entry.plan.resetsExpireLabel})`)}`
|
|
467
|
+
: "";
|
|
468
|
+
lines.push(` • You have ${fmt.bold(String(n))} ${resets}${by}`);
|
|
466
469
|
}
|
|
467
470
|
for (const w of entry.plan.windows) {
|
|
468
471
|
const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
|
|
@@ -404,6 +404,8 @@ export interface PlanDisplay {
|
|
|
404
404
|
windows: PlanWindowDisplay[];
|
|
405
405
|
/** Set only when the account still has one-shot rate-limit resets left. */
|
|
406
406
|
resetsAvailable: number | undefined;
|
|
407
|
+
/** Deadline for the soonest-expiring banked reset, when the plan reports one. */
|
|
408
|
+
resetsExpireLabel?: string;
|
|
407
409
|
/** Set only when the figures have aged, e.g. "12m ago". */
|
|
408
410
|
ageLabel: string | undefined;
|
|
409
411
|
}
|
|
@@ -432,6 +434,9 @@ export function buildPlanDisplay(
|
|
|
432
434
|
return {
|
|
433
435
|
plan: usage.plan,
|
|
434
436
|
resetsAvailable: usage.resetsAvailable,
|
|
437
|
+
resetsExpireLabel: usage.resetsAvailable
|
|
438
|
+
? planResetLabel(usage.resetsExpireAt)
|
|
439
|
+
: undefined,
|
|
435
440
|
ageLabel:
|
|
436
441
|
age > PLAN_STALE_AFTER_MS
|
|
437
442
|
? formatRelativeAge(usage.fetchedAt)
|
package/src/storage/triggers.ts
CHANGED
|
@@ -38,7 +38,7 @@ import type { Trigger, TriggerLanguage } from "./repositories/triggers-repo.js";
|
|
|
38
38
|
|
|
39
39
|
export const DEFAULT_TIMEOUT_SECONDS = 24 * 60 * 60; // 24h
|
|
40
40
|
export const MAX_TIMEOUT_SECONDS = 7 * 24 * 60 * 60; // 7d
|
|
41
|
-
/**
|
|
41
|
+
/** Default per-chat cap on simultaneously active triggers (`config.triggers.maxActivePerChat`). */
|
|
42
42
|
export const MAX_ACTIVE_PER_CHAT = 5;
|
|
43
43
|
/** Truncate fire payloads at this many bytes to keep wake prompts sane. */
|
|
44
44
|
export const FIRE_PAYLOAD_MAX_BYTES = 4_096;
|