talon-agent 5.10.0 → 5.12.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 +27 -0
- package/package.json +1 -1
- package/src/app.ts +27 -9
- package/src/backend/claude-sdk/factory.ts +5 -0
- package/src/backend/claude-sdk/handler.ts +1 -1
- package/src/backend/claude-sdk/host/in-process.ts +1 -1
- package/src/backend/claude-sdk/usage/banked-reset.ts +304 -0
- package/src/backend/claude-sdk/{plan-usage.ts → usage/plan-usage.ts} +62 -5
- package/src/bootstrap.ts +4 -1
- package/src/cli/config.ts +112 -6
- package/src/cli/index.ts +81 -79
- package/src/core/agent-runtime/capabilities.ts +63 -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 +106 -7
- package/src/core/daemon/crash.ts +23 -0
- package/src/core/engine/gateway-actions/triggers.ts +6 -8
- package/src/core/mcp-hub/launcher.ts +82 -22
- package/src/core/mesh/devices/registry.ts +25 -3
- 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/frontend/telegram/callbacks/index.ts +8 -0
- package/src/frontend/telegram/callbacks/usage-reset.ts +333 -0
- package/src/frontend/telegram/commands/admin.ts +28 -2
- package/src/frontend/telegram/commands/state.ts +11 -0
- package/src/storage/triggers.ts +1 -1
package/src/cli/config.ts
CHANGED
|
@@ -87,18 +87,124 @@ export const DEFAULTS: Config = {
|
|
|
87
87
|
maxMessageLength: 4000,
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A config.json that exists but can't be used — unreadable, not valid
|
|
92
|
+
* JSON, or not a JSON object. Thrown instead of silently falling back to
|
|
93
|
+
* defaults: `loadConfig` backs both read-only commands (`status`,
|
|
94
|
+
* `config`, `doctor`, the main menu's "is this configured?" check) and
|
|
95
|
+
* load → edit → save commands (`setup`, `plugin`), so treating a broken
|
|
96
|
+
* file as `{}` used to let the latter write those empty defaults straight
|
|
97
|
+
* back over — destroying — the user's real config. Mirrors
|
|
98
|
+
* core/config/index.ts's `ConfigFileError` in shape and wording (#1031)
|
|
99
|
+
* without importing across the CLI/daemon boundary; unlike that one, the
|
|
100
|
+
* CLI's `Config` type has no runtime schema, so there's no per-key
|
|
101
|
+
* validation to report here, only "can this be read as a JSON object".
|
|
102
|
+
*/
|
|
103
|
+
export class ConfigFileError extends Error {
|
|
104
|
+
constructor(
|
|
105
|
+
message: string,
|
|
106
|
+
readonly path: string,
|
|
107
|
+
) {
|
|
108
|
+
super(message);
|
|
109
|
+
this.name = "ConfigFileError";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Append a line/column hint to a JSON.parse error message. Recent V8
|
|
115
|
+
* already includes "(line L column C)"; older runtimes only report
|
|
116
|
+
* "at position N", so derive it from the raw text in that case.
|
|
117
|
+
*/
|
|
118
|
+
function describeJsonError(err: unknown, raw: string): string {
|
|
119
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
120
|
+
if (/\(line \d+ column \d+\)/.test(message)) return message;
|
|
121
|
+
const match = /at position (\d+)/.exec(message);
|
|
122
|
+
if (!match) return message;
|
|
123
|
+
const before = raw.slice(0, Number(match[1]));
|
|
124
|
+
const line = before.split("\n").length;
|
|
125
|
+
const column = before.length - before.lastIndexOf("\n");
|
|
126
|
+
return `${message} (line ${line} column ${column})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Parse `raw` as a config object, or describe in one phrase why it isn't one. */
|
|
130
|
+
function parseConfigJson(
|
|
131
|
+
raw: string,
|
|
132
|
+
):
|
|
133
|
+
{ ok: true; data: Record<string, unknown> } | { ok: false; problem: string } {
|
|
134
|
+
let data: unknown;
|
|
135
|
+
try {
|
|
136
|
+
data = JSON.parse(raw);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
problem: `invalid JSON — ${describeJsonError(err, raw)}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
144
|
+
return { ok: false, problem: "the top level must be a JSON object" };
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, data: data as Record<string, unknown> };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Load ~/.talon/config.json. A missing file is first run: defaults apply.
|
|
151
|
+
* A present file that can't be read or isn't a valid JSON object throws
|
|
152
|
+
* `ConfigFileError` — see the class comment for why this must not fall
|
|
153
|
+
* back to defaults instead.
|
|
154
|
+
*/
|
|
90
155
|
export function loadConfig(): Config {
|
|
156
|
+
if (!existsSync(CONFIG_FILE)) return { ...DEFAULTS };
|
|
157
|
+
let raw: string;
|
|
91
158
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
159
|
+
raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new ConfigFileError(
|
|
162
|
+
`Cannot read ${CONFIG_FILE}: ${err instanceof Error ? err.message : err}`,
|
|
163
|
+
CONFIG_FILE,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const result = parseConfigJson(raw);
|
|
167
|
+
if (!result.ok) {
|
|
168
|
+
throw new ConfigFileError(
|
|
169
|
+
`Invalid config in ${CONFIG_FILE}: ${result.problem}. ` +
|
|
170
|
+
`The file was left untouched — fix it and try again.`,
|
|
171
|
+
CONFIG_FILE,
|
|
172
|
+
);
|
|
97
173
|
}
|
|
98
|
-
return { ...DEFAULTS };
|
|
174
|
+
return { ...DEFAULTS, ...result.data };
|
|
99
175
|
}
|
|
100
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Save ~/.talon/config.json. Refuses to overwrite an existing file that
|
|
179
|
+
* isn't valid JSON. The normal caller shape is load → edit → save, and
|
|
180
|
+
* `loadConfig` above already throws before such a caller ever reaches
|
|
181
|
+
* this point — this check is the backstop for any caller that saves
|
|
182
|
+
* without a fresh load, or for the file changing under us between the
|
|
183
|
+
* two calls: writing `config` in either case would silently replace
|
|
184
|
+
* whatever is actually on disk with the caller's best guess.
|
|
185
|
+
*/
|
|
101
186
|
export function saveConfig(config: Config): void {
|
|
187
|
+
if (existsSync(CONFIG_FILE)) {
|
|
188
|
+
let raw: string;
|
|
189
|
+
try {
|
|
190
|
+
raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
191
|
+
} catch (err) {
|
|
192
|
+
throw new ConfigFileError(
|
|
193
|
+
`Refusing to write ${CONFIG_FILE}: cannot read the existing file ` +
|
|
194
|
+
`(${err instanceof Error ? err.message : err}). It was left untouched.`,
|
|
195
|
+
CONFIG_FILE,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const result = parseConfigJson(raw);
|
|
199
|
+
if (!result.ok) {
|
|
200
|
+
throw new ConfigFileError(
|
|
201
|
+
`Refusing to write ${CONFIG_FILE}: the existing file is invalid ` +
|
|
202
|
+
`(${result.problem}). It was left untouched — fix it manually, ` +
|
|
203
|
+
`or delete it to start over.`,
|
|
204
|
+
CONFIG_FILE,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
102
208
|
if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
|
|
103
209
|
const clean = Object.fromEntries(
|
|
104
210
|
Object.entries(config).filter(([, v]) => v !== undefined),
|
package/src/cli/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import pc from "picocolors";
|
|
|
24
24
|
// inlines the JSON at compile time; tsx/node resolve it from the package.
|
|
25
25
|
import pkg from "../../package.json" with { type: "json" };
|
|
26
26
|
import { PKG_ROOT } from "./context.js";
|
|
27
|
-
import { printBanner } from "./config.js";
|
|
27
|
+
import { printBanner, ConfigFileError } from "./config.js";
|
|
28
28
|
import { runSetup } from "./setup.js";
|
|
29
29
|
import { showStatus } from "./status.js";
|
|
30
30
|
import { viewConfig } from "./config-view.js";
|
|
@@ -121,84 +121,86 @@ function printHelp(): void {
|
|
|
121
121
|
/** Route a `talon <command>` invocation. Called by the entry point. */
|
|
122
122
|
export async function runCli(): Promise<void> {
|
|
123
123
|
const command = process.argv[2];
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
printBanner();
|
|
139
|
-
await daemonStart();
|
|
140
|
-
break;
|
|
141
|
-
case "stop":
|
|
142
|
-
printBanner();
|
|
143
|
-
await daemonStop();
|
|
144
|
-
break;
|
|
145
|
-
case "restart":
|
|
146
|
-
printBanner();
|
|
147
|
-
await daemonRestart();
|
|
148
|
-
break;
|
|
149
|
-
case "run":
|
|
150
|
-
process.chdir(PKG_ROOT);
|
|
151
|
-
import("../index.js");
|
|
152
|
-
break;
|
|
153
|
-
case "chat":
|
|
154
|
-
process.chdir(PKG_ROOT);
|
|
155
|
-
startChat();
|
|
156
|
-
break;
|
|
157
|
-
case "doctor":
|
|
158
|
-
runDoctor();
|
|
159
|
-
break;
|
|
160
|
-
case "ps":
|
|
161
|
-
await showTasks(process.argv[3] === "--all" || process.argv[3] === "-a");
|
|
162
|
-
break;
|
|
163
|
-
case "kill":
|
|
164
|
-
await killTask(process.argv[3]);
|
|
165
|
-
break;
|
|
166
|
-
case "events":
|
|
167
|
-
await showEvents(eventsOptions(process.argv.slice(3)));
|
|
168
|
-
break;
|
|
169
|
-
case "backup":
|
|
170
|
-
await runBackupCommand(process.argv.slice(3));
|
|
171
|
-
break;
|
|
172
|
-
case "plugin":
|
|
173
|
-
await runPluginCommand(process.argv.slice(3));
|
|
174
|
-
break;
|
|
175
|
-
case "skill":
|
|
176
|
-
await runSkillCommand(process.argv.slice(3));
|
|
177
|
-
break;
|
|
178
|
-
case "memory":
|
|
179
|
-
runMemoryCommand(process.argv.slice(3));
|
|
180
|
-
break;
|
|
181
|
-
case "--version":
|
|
182
|
-
case "-v": {
|
|
183
|
-
console.log(pkg.version);
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
case "--help":
|
|
187
|
-
case "-h":
|
|
188
|
-
printHelp();
|
|
189
|
-
break;
|
|
190
|
-
case undefined:
|
|
191
|
-
mainMenu();
|
|
192
|
-
break;
|
|
193
|
-
default: {
|
|
194
|
-
// "did you mean ...?" via the native similarity core (native/strsim-wasm).
|
|
195
|
-
const { closestMatch } = await import("../native/strsim.js");
|
|
196
|
-
const suggestion = closestMatch(command, CLI_COMMANDS);
|
|
197
|
-
const hint = suggestion
|
|
198
|
-
? `Did you mean ${pc.cyan(`talon ${suggestion.value}`)}?`
|
|
199
|
-
: `Run ${pc.cyan("talon --help")} for usage.`;
|
|
200
|
-
console.error(` Unknown command: ${command}\n ${hint}\n`);
|
|
201
|
-
process.exit(1);
|
|
124
|
+
try {
|
|
125
|
+
await dispatch(command);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// A present-but-invalid config.json: every command below that reads
|
|
128
|
+
// config (directly, or via `mainMenu`'s "is this configured?" check)
|
|
129
|
+
// is async but was previously invoked without `await`, so this throw
|
|
130
|
+
// would otherwise surface as a bare unhandled-rejection stack trace —
|
|
131
|
+
// or, worse for the main menu, never happen at all, because the old
|
|
132
|
+
// loader swallowed the error and returned defaults, sending a broken
|
|
133
|
+
// install into the first-run wizard, which then saves over the file.
|
|
134
|
+
if (err instanceof ConfigFileError) {
|
|
135
|
+
console.error(`\n ${pc.red("✖")} ${err.message}\n`);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
202
138
|
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type CommandHandler = (args: string[]) => void | Promise<void>;
|
|
144
|
+
|
|
145
|
+
/** Every `talon <command>`, keyed by name. Handlers get the argv after the command. */
|
|
146
|
+
const COMMANDS: Record<string, CommandHandler> = {
|
|
147
|
+
setup: () => runSetup(),
|
|
148
|
+
status: () => showStatus(),
|
|
149
|
+
config: () => viewConfig(),
|
|
150
|
+
logs: () => tailLogs(),
|
|
151
|
+
start: async () => {
|
|
152
|
+
printBanner();
|
|
153
|
+
await daemonStart();
|
|
154
|
+
},
|
|
155
|
+
stop: async () => {
|
|
156
|
+
printBanner();
|
|
157
|
+
await daemonStop();
|
|
158
|
+
},
|
|
159
|
+
restart: async () => {
|
|
160
|
+
printBanner();
|
|
161
|
+
await daemonRestart();
|
|
162
|
+
},
|
|
163
|
+
run: () => {
|
|
164
|
+
process.chdir(PKG_ROOT);
|
|
165
|
+
void import("../index.js");
|
|
166
|
+
},
|
|
167
|
+
chat: () => {
|
|
168
|
+
process.chdir(PKG_ROOT);
|
|
169
|
+
startChat();
|
|
170
|
+
},
|
|
171
|
+
doctor: () => runDoctor(),
|
|
172
|
+
ps: (args) => showTasks(args[0] === "--all" || args[0] === "-a"),
|
|
173
|
+
kill: (args) => killTask(args[0]),
|
|
174
|
+
events: (args) => showEvents(eventsOptions(args)),
|
|
175
|
+
backup: (args) => runBackupCommand(args),
|
|
176
|
+
plugin: (args) => runPluginCommand(args),
|
|
177
|
+
skill: (args) => runSkillCommand(args),
|
|
178
|
+
memory: (args) => runMemoryCommand(args),
|
|
179
|
+
"--version": () => console.log(pkg.version),
|
|
180
|
+
"-v": () => console.log(pkg.version),
|
|
181
|
+
"--help": () => printHelp(),
|
|
182
|
+
"-h": () => printHelp(),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
async function unknownCommand(command: string): Promise<never> {
|
|
186
|
+
// "did you mean ...?" via the native similarity core (native/strsim-wasm).
|
|
187
|
+
const { closestMatch } = await import("../native/strsim.js");
|
|
188
|
+
const suggestion = closestMatch(command, CLI_COMMANDS);
|
|
189
|
+
const hint = suggestion
|
|
190
|
+
? `Did you mean ${pc.cyan(`talon ${suggestion.value}`)}?`
|
|
191
|
+
: `Run ${pc.cyan("talon --help")} for usage.`;
|
|
192
|
+
console.error(` Unknown command: ${command}\n ${hint}\n`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function dispatch(command: string | undefined): Promise<void> {
|
|
197
|
+
if (command === undefined) {
|
|
198
|
+
await mainMenu();
|
|
199
|
+
return;
|
|
203
200
|
}
|
|
201
|
+
const handler = Object.hasOwn(COMMANDS, command)
|
|
202
|
+
? COMMANDS[command]
|
|
203
|
+
: undefined;
|
|
204
|
+
if (!handler) return unknownCommand(command);
|
|
205
|
+
await handler(process.argv.slice(3));
|
|
204
206
|
}
|
|
@@ -243,6 +243,67 @@ 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
|
+
* Banked one-shot limit resets, where the plan has them. Spending one is
|
|
248
|
+
* irreversible, so this is reachable only from a human-pressed confirm
|
|
249
|
+
* button — never exposed as an agent tool.
|
|
250
|
+
*/
|
|
251
|
+
bankedResets?: BankedResetControl;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One banked reset grant, as the plan reports it. */
|
|
255
|
+
export interface BankedResetGrant {
|
|
256
|
+
id: string;
|
|
257
|
+
label: string;
|
|
258
|
+
resetsLeft: number;
|
|
259
|
+
/** ISO deadline, when the grant has one. */
|
|
260
|
+
endsAt?: string;
|
|
261
|
+
/** Plan windows the reset clears (`five_hour`, `seven_day`, …). */
|
|
262
|
+
clears: string[];
|
|
263
|
+
/** Current utilisation per cleared window, 0-100. */
|
|
264
|
+
percentUsed: Record<string, number>;
|
|
265
|
+
/** The reset can only be spent while the account is at a limit. */
|
|
266
|
+
useRequiresLimit: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The grant a claim would spend, plus the account state around it. */
|
|
270
|
+
export interface BankedResetOffer {
|
|
271
|
+
grant: BankedResetGrant;
|
|
272
|
+
atLimit: boolean;
|
|
273
|
+
/** ISO end of a post-claim cooldown, while one is running. */
|
|
274
|
+
cooldownUntil?: string;
|
|
275
|
+
/** Resets left across every usable grant. */
|
|
276
|
+
totalResetsLeft: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export type BankedResetResult =
|
|
280
|
+
| "reset"
|
|
281
|
+
| "already_used"
|
|
282
|
+
| "not_limited"
|
|
283
|
+
| "cooldown"
|
|
284
|
+
| "ineligible"
|
|
285
|
+
| "unavailable"
|
|
286
|
+
| "rate_limited"
|
|
287
|
+
| "auth_error"
|
|
288
|
+
| "error";
|
|
289
|
+
|
|
290
|
+
export interface BankedResetClaim {
|
|
291
|
+
result: BankedResetResult;
|
|
292
|
+
/** Server-side reason code, when it gave one. */
|
|
293
|
+
reason?: string;
|
|
294
|
+
resetsLeft?: number;
|
|
295
|
+
cleared: string[];
|
|
296
|
+
weeklyResetsAt?: string;
|
|
297
|
+
cooldownUntil?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface BankedResetControl {
|
|
301
|
+
getOffer(): Promise<BankedResetOffer | undefined>;
|
|
302
|
+
/**
|
|
303
|
+
* Spend one reset from `grantId`. `requestId` is the idempotency key:
|
|
304
|
+
* reuse it when retrying the same user action so a retry can't spend twice.
|
|
305
|
+
*/
|
|
306
|
+
claim(grantId: string, requestId: string): Promise<BankedResetClaim>;
|
|
246
307
|
}
|
|
247
308
|
|
|
248
309
|
/** One subscription rate-limit window, as `/status` renders it. */
|
|
@@ -261,6 +322,8 @@ export interface PlanUsage {
|
|
|
261
322
|
windows: PlanWindow[];
|
|
262
323
|
/** How many one-shot rate-limit resets are still banked, when the plan has them. */
|
|
263
324
|
resetsAvailable?: number;
|
|
325
|
+
/** ISO time the soonest-expiring banked reset must be used by, when the plan says. */
|
|
326
|
+
resetsExpireAt?: string;
|
|
264
327
|
/** Epoch ms of the read, so renderers can flag figures as aged. */
|
|
265
328
|
fetchedAt: number;
|
|
266
329
|
}
|
|
@@ -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
|
@@ -511,6 +511,25 @@ const configSchema = z.object({
|
|
|
511
511
|
.default(15 * 60 * 1000),
|
|
512
512
|
})
|
|
513
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(),
|
|
514
533
|
/**
|
|
515
534
|
* Backups & checkpoints (docs/backups.md). Talon's only safety net, so
|
|
516
535
|
* it is on by default: every `intervalHours` it writes a snapshot of
|
|
@@ -785,15 +804,84 @@ const DEFAULT_CONFIG = {
|
|
|
785
804
|
pulseIntervalMs: 300000,
|
|
786
805
|
};
|
|
787
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
|
+
*/
|
|
788
854
|
function loadConfigFile(): Record<string, unknown> {
|
|
855
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
856
|
+
let raw: string;
|
|
789
857
|
try {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
+
);
|
|
795
883
|
}
|
|
796
|
-
return
|
|
884
|
+
return data as Record<string, unknown>;
|
|
797
885
|
}
|
|
798
886
|
|
|
799
887
|
function normalizeDeprecatedFrontendConfig(
|
|
@@ -879,7 +967,18 @@ export function loadConfig(): TalonConfig {
|
|
|
879
967
|
}
|
|
880
968
|
}
|
|
881
969
|
|
|
882
|
-
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;
|
|
883
982
|
|
|
884
983
|
// The soul kernel is gone (#953). Its config block still parses so an
|
|
885
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
|
+
}
|