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/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
- if (existsSync(CONFIG_FILE)) {
93
- return { ...DEFAULTS, ...JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) };
94
- }
95
- } catch {
96
- /* corrupt */
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
- switch (command) {
125
- case "setup":
126
- runSetup();
127
- break;
128
- case "status":
129
- showStatus();
130
- break;
131
- case "config":
132
- viewConfig();
133
- break;
134
- case "logs":
135
- tailLogs();
136
- break;
137
- case "start":
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
- log("triggers", "Initialized");
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. */
@@ -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
- if (existsSync(CONFIG_FILE)) {
791
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
792
- }
793
- } catch {
794
- /* corrupt — will be recreated */
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 parsed = configSchema.parse(fileConfig);
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 —
@@ -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
+ }