talon-agent 5.11.0 → 5.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.11.0",
3
+ "version": "5.13.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@ import {
30
30
  evictOrphanSubprocesses as claudeEvictOrphanSubprocesses,
31
31
  } from "./index.js";
32
32
  import { createInProcessAgentHost } from "./host/in-process.js";
33
+ import { claimBankedReset, getBankedResetOffer } from "./usage/banked-reset.js";
33
34
 
34
35
  import * as modelProvider from "./model-provider.js";
35
36
  import { claudeDoctorChecks } from "./doctor.js";
@@ -105,6 +106,10 @@ const claudeSdkFactory: BackendFactory = {
105
106
  // but the subscription's rate-limit windows are readable.
106
107
  const usage: UsageTelemetry = {
107
108
  getPlanUsage: () => host.planUsage(),
109
+ bankedResets: {
110
+ getOffer: () => getBankedResetOffer(),
111
+ claim: (grantId, requestId) => claimBankedReset(grantId, requestId),
112
+ },
108
113
  };
109
114
 
110
115
  const backend = composeBackend({
@@ -38,7 +38,7 @@ import { applyRetryDecisionStream } from "../runtime/turn/handle-retry.js";
38
38
  import { getConfig } from "./state.js";
39
39
  import { buildSdkOptions, getActiveFrontends } from "./options.js";
40
40
  import { waitForMcpServersReady } from "./mcp-ready.js";
41
- import { invalidatePlanUsage } from "./plan-usage.js";
41
+ import { invalidatePlanUsage } from "./usage/plan-usage.js";
42
42
  import { frontendsForChat } from "../runtime/frontends.js";
43
43
  import { rollUpTurnCache } from "../runtime/cache/cache-metrics.js";
44
44
  import {
@@ -39,7 +39,7 @@ import {
39
39
  } from "../handler.js";
40
40
  import { waitForMcpServersReady } from "../mcp-ready.js";
41
41
  import { listModels as claudeListModels } from "../model-provider.js";
42
- import { getPlanUsage } from "../plan-usage.js";
42
+ import { getPlanUsage } from "../usage/plan-usage.js";
43
43
 
44
44
  /**
45
45
  * Install an MCP server set on the chat's live query. `null` when the
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Spending a banked Claude usage-limit reset (the claude.ai "Reset for free"
3
+ * grants, program `cedar_ember`).
4
+ *
5
+ * The same OAuth usage endpoint `/usage` reads lists the grants; a claim is a
6
+ * POST to the organization's `reset_rate_limits` route, the one Claude Code's
7
+ * own `/limit-reset` uses. The request and response shapes mirror the CLI:
8
+ *
9
+ * POST /api/organizations/<org>/reset_rate_limits
10
+ * { program: "cedar_ember", grant_id, request_id }
11
+ * → { result, reason?, resets_left?, cleared?, weekly_resets_at?, cooldown_until? }
12
+ *
13
+ * `request_id` is an idempotency key. A retry of the same user action must
14
+ * reuse it, so a claim whose answer was lost can't spend a second reset.
15
+ *
16
+ * A reset is one-off and irreversible. Nothing here is reachable from an
17
+ * agent tool; the only caller is a confirm button a human presses.
18
+ */
19
+
20
+ import { randomUUID } from "node:crypto";
21
+ import { logWarn } from "../../../util/log.js";
22
+ import type {
23
+ BankedResetClaim,
24
+ BankedResetGrant,
25
+ BankedResetOffer,
26
+ BankedResetResult,
27
+ } from "../../../core/agent-runtime/capabilities.js";
28
+ import {
29
+ CLI_USER_AGENT,
30
+ USAGE_ENDPOINT,
31
+ invalidatePlanUsage,
32
+ readCredentials,
33
+ } from "./plan-usage.js";
34
+
35
+ const API_BASE = "https://api.anthropic.com";
36
+ const PROFILE_ENDPOINT = `${API_BASE}/api/oauth/profile`;
37
+ const PROGRAM = "cedar_ember";
38
+ const READ_TIMEOUT_MS = 5_000;
39
+ // The CLI allows a claim 25s; the server may do real work before answering.
40
+ const CLAIM_TIMEOUT_MS = 25_000;
41
+
42
+ // Same patterns the CLI enforces before it will send a claim.
43
+ const GRANT_ID = /^[a-z0-9_-]{1,40}$/;
44
+ const REQUEST_ID = /^[A-Za-z0-9_-]{1,64}$/;
45
+ const ORG_ID = /^[A-Za-z0-9-]{1,64}$/;
46
+
47
+ const SERVER_RESULTS: ReadonlySet<string> = new Set([
48
+ "reset",
49
+ "already_used",
50
+ "not_limited",
51
+ "cooldown",
52
+ "ineligible",
53
+ "unavailable",
54
+ ]);
55
+
56
+ export function isValidGrantId(id: string): boolean {
57
+ return GRANT_ID.test(id);
58
+ }
59
+
60
+ export function isValidRequestId(id: string): boolean {
61
+ return REQUEST_ID.test(id);
62
+ }
63
+
64
+ /** A fresh idempotency key for one user action. */
65
+ export function newResetRequestId(): string {
66
+ return randomUUID().replace(/-/g, "");
67
+ }
68
+
69
+ export interface ResetStatus {
70
+ atLimit: boolean;
71
+ cooldownUntil?: string;
72
+ nextGrantId?: string;
73
+ grants: Array<BankedResetGrant & { paused: boolean; usableNow: boolean }>;
74
+ }
75
+
76
+ function str(v: unknown): string | undefined {
77
+ return typeof v === "string" && v.length > 0 ? v : undefined;
78
+ }
79
+
80
+ function strings(v: unknown): string[] {
81
+ return Array.isArray(v)
82
+ ? v.filter((x): x is string => typeof x === "string")
83
+ : [];
84
+ }
85
+
86
+ function percents(v: unknown): Record<string, number> {
87
+ const out: Record<string, number> = {};
88
+ if (!v || typeof v !== "object") return out;
89
+ for (const [k, p] of Object.entries(v as Record<string, unknown>)) {
90
+ if (typeof p === "number" && Number.isFinite(p))
91
+ out[k] = Math.max(0, Math.min(100, Math.round(p)));
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** The `cedar_ember` block of a usage response, or undefined without one. */
97
+ export function parseResetStatus(body: unknown): ResetStatus | undefined {
98
+ const raw = (body as { cedar_ember?: unknown } | null)?.cedar_ember as
99
+ Record<string, unknown> | null | undefined;
100
+ if (!raw || typeof raw !== "object" || raw.eligible === false)
101
+ return undefined;
102
+ const grants: ResetStatus["grants"] = [];
103
+ for (const g of Array.isArray(raw.grants) ? raw.grants : []) {
104
+ if (!g || typeof g !== "object") continue;
105
+ const r = g as Record<string, unknown>;
106
+ const id = str(r.id);
107
+ // A malformed id is one the CLI would refuse to claim; skip it here too.
108
+ if (!id || !isValidGrantId(id)) continue;
109
+ const left = r.resets_left;
110
+ const endsAt = str(r.ends_at);
111
+ grants.push({
112
+ id,
113
+ label: str(r.label) ?? id,
114
+ resetsLeft:
115
+ typeof left === "number" && Number.isFinite(left)
116
+ ? Math.max(0, Math.floor(left))
117
+ : 0,
118
+ ...(endsAt ? { endsAt } : {}),
119
+ clears: strings(r.clears),
120
+ percentUsed: percents(r.percent_used),
121
+ // The CLI defaults an absent flag to the cautious reading.
122
+ useRequiresLimit: r.use_requires_limit !== false,
123
+ paused: r.paused === true,
124
+ usableNow: r.usable_now === true,
125
+ });
126
+ }
127
+ const cooldownUntil = str(raw.cooldown_until);
128
+ const nextGrantId = str(raw.next_grant_id);
129
+ return {
130
+ atLimit: raw.at_limit === true,
131
+ ...(cooldownUntil ? { cooldownUntil } : {}),
132
+ ...(nextGrantId ? { nextGrantId } : {}),
133
+ grants,
134
+ };
135
+ }
136
+
137
+ function usable(grant: ResetStatus["grants"][number], now: number): boolean {
138
+ if (!grant.usableNow || grant.paused || grant.resetsLeft <= 0) return false;
139
+ const ends = grant.endsAt ? Date.parse(grant.endsAt) : NaN;
140
+ return !(Number.isFinite(ends) && ends <= now);
141
+ }
142
+
143
+ /**
144
+ * The grant a claim should spend: the server's `next_grant_id` when that
145
+ * grant is usable, else the usable grant that expires soonest (a grant with
146
+ * no deadline goes last).
147
+ */
148
+ export function pickGrant(
149
+ status: ResetStatus,
150
+ now = Date.now(),
151
+ ): ResetStatus["grants"][number] | undefined {
152
+ const candidates = status.grants.filter((g) => usable(g, now));
153
+ const next = candidates.find((g) => g.id === status.nextGrantId);
154
+ if (next) return next;
155
+ const deadline = (g: { endsAt?: string }) => {
156
+ const t = g.endsAt ? Date.parse(g.endsAt) : NaN;
157
+ return Number.isFinite(t) ? t : Infinity;
158
+ };
159
+ return candidates.sort((a, b) => deadline(a) - deadline(b))[0];
160
+ }
161
+
162
+ function authHeaders(token: string): Record<string, string> {
163
+ return {
164
+ Authorization: `Bearer ${token}`,
165
+ "anthropic-beta": "oauth-2025-04-20",
166
+ "User-Agent": CLI_USER_AGENT,
167
+ };
168
+ }
169
+
170
+ async function oauthToken(): Promise<string | undefined> {
171
+ // An API-key session isn't on the subscription whose resets these are.
172
+ if (process.env.ANTHROPIC_API_KEY) return undefined;
173
+ return (await readCredentials())?.accessToken;
174
+ }
175
+
176
+ /** What a claim would spend right now, or undefined when there's nothing. */
177
+ export async function getBankedResetOffer(
178
+ now = Date.now(),
179
+ ): Promise<BankedResetOffer | undefined> {
180
+ const token = await oauthToken();
181
+ if (!token) return undefined;
182
+ try {
183
+ const res = await fetch(USAGE_ENDPOINT, {
184
+ headers: authHeaders(token),
185
+ signal: AbortSignal.timeout(READ_TIMEOUT_MS),
186
+ });
187
+ if (!res.ok) {
188
+ logWarn("agent", `banked resets: usage returned ${res.status}`);
189
+ return undefined;
190
+ }
191
+ const status = parseResetStatus(await res.json());
192
+ if (!status) return undefined;
193
+ const grant = pickGrant(status, now);
194
+ if (!grant) return undefined;
195
+ const { paused: _p, usableNow: _u, ...clean } = grant;
196
+ const totalResetsLeft = status.grants
197
+ .filter((g) => usable(g, now))
198
+ .reduce((n, g) => n + g.resetsLeft, 0);
199
+ return {
200
+ grant: clean,
201
+ atLimit: status.atLimit,
202
+ ...(status.cooldownUntil ? { cooldownUntil: status.cooldownUntil } : {}),
203
+ totalResetsLeft,
204
+ };
205
+ } catch (err) {
206
+ logWarn(
207
+ "agent",
208
+ `banked resets: ${err instanceof Error ? err.message : String(err)}`,
209
+ );
210
+ return undefined;
211
+ }
212
+ }
213
+
214
+ async function organizationId(token: string): Promise<string | undefined> {
215
+ const res = await fetch(PROFILE_ENDPOINT, {
216
+ headers: authHeaders(token),
217
+ signal: AbortSignal.timeout(READ_TIMEOUT_MS),
218
+ });
219
+ if (!res.ok) return undefined;
220
+ const body = (await res.json()) as { organization?: { uuid?: unknown } };
221
+ const id = body?.organization?.uuid;
222
+ return typeof id === "string" && ORG_ID.test(id) ? id : undefined;
223
+ }
224
+
225
+ const failed = (result: BankedResetResult): BankedResetClaim => ({
226
+ result,
227
+ cleared: [],
228
+ });
229
+
230
+ /** Map a claim response body onto a result; unknown results read as unavailable. */
231
+ export function parseClaimResponse(body: unknown): BankedResetClaim {
232
+ const r = (body && typeof body === "object" ? body : {}) as Record<
233
+ string,
234
+ unknown
235
+ >;
236
+ const result =
237
+ typeof r.result === "string" && SERVER_RESULTS.has(r.result)
238
+ ? (r.result as BankedResetResult)
239
+ : "unavailable";
240
+ const reason = str(r.reason);
241
+ const left = r.resets_left;
242
+ const weeklyResetsAt = str(r.weekly_resets_at);
243
+ const cooldownUntil = str(r.cooldown_until);
244
+ return {
245
+ result,
246
+ ...(reason ? { reason } : {}),
247
+ ...(typeof left === "number" && Number.isInteger(left) && left >= 0
248
+ ? { resetsLeft: left }
249
+ : {}),
250
+ cleared: strings(r.cleared),
251
+ ...(weeklyResetsAt ? { weeklyResetsAt } : {}),
252
+ ...(cooldownUntil ? { cooldownUntil } : {}),
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Spend one reset from `grantId`. Never throws: every failure comes back as
258
+ * a result the caller can put into words.
259
+ */
260
+ export async function claimBankedReset(
261
+ grantId: string,
262
+ requestId: string = newResetRequestId(),
263
+ ): Promise<BankedResetClaim> {
264
+ if (!isValidGrantId(grantId) || !isValidRequestId(requestId)) {
265
+ logWarn("agent", "banked resets: refusing a malformed grant or request id");
266
+ return failed("error");
267
+ }
268
+ const token = await oauthToken();
269
+ if (!token) return failed("auth_error");
270
+
271
+ try {
272
+ const org = await organizationId(token);
273
+ if (!org) return failed("auth_error");
274
+ const res = await fetch(
275
+ `${API_BASE}/api/organizations/${org}/reset_rate_limits`,
276
+ {
277
+ method: "POST",
278
+ headers: { ...authHeaders(token), "Content-Type": "application/json" },
279
+ body: JSON.stringify({
280
+ program: PROGRAM,
281
+ grant_id: grantId,
282
+ request_id: requestId,
283
+ }),
284
+ signal: AbortSignal.timeout(CLAIM_TIMEOUT_MS),
285
+ },
286
+ );
287
+ if (res.status === 429) return failed("rate_limited");
288
+ if (res.status === 401 || res.status === 403) return failed("auth_error");
289
+ if (!res.ok) {
290
+ logWarn("agent", `banked resets: claim returned ${res.status}`);
291
+ return failed("error");
292
+ }
293
+ const claim = parseClaimResponse(await res.json().catch(() => null));
294
+ // Any settled answer changes what /usage should say; re-read next time.
295
+ if (claim.result !== "unavailable") invalidatePlanUsage();
296
+ return claim;
297
+ } catch (err) {
298
+ logWarn(
299
+ "agent",
300
+ `banked resets: claim failed: ${err instanceof Error ? err.message : String(err)}`,
301
+ );
302
+ return failed("error");
303
+ }
304
+ }
@@ -15,20 +15,20 @@
15
15
  import { readFile } from "node:fs/promises";
16
16
  import { homedir } from "node:os";
17
17
  import { join } from "node:path";
18
- import { logWarn } from "../../util/log.js";
18
+ import { logWarn } from "../../../util/log.js";
19
19
  import type {
20
20
  PlanUsage,
21
21
  PlanWindow,
22
- } from "../../core/agent-runtime/capabilities.js";
22
+ } from "../../../core/agent-runtime/capabilities.js";
23
23
 
24
24
  // `cedar_ember=1` asks the endpoint to include banked limit resets (the
25
25
  // claude.ai "Reset for free" grants); `skip_spend=1` drops the spend block we
26
26
  // don't render. Resets are only reported to the CLI surface — any other
27
27
  // user agent gets `ineligible_reason: "surface"` — so the request identifies
28
28
  // as the CLI, which is what the Agent SDK runs anyway.
29
- const USAGE_ENDPOINT =
29
+ export const USAGE_ENDPOINT =
30
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)";
31
+ export const CLI_USER_AGENT = "claude-cli/2.1.280 (external, cli)";
32
32
  const REQUEST_TIMEOUT_MS = 5_000;
33
33
  const CACHE_TTL_MS = 60_000;
34
34
 
@@ -48,7 +48,7 @@ interface OAuthCredentials {
48
48
  subscriptionType?: string;
49
49
  }
50
50
 
51
- async function readCredentials(): Promise<OAuthCredentials | undefined> {
51
+ export async function readCredentials(): Promise<OAuthCredentials | undefined> {
52
52
  try {
53
53
  const parsed = JSON.parse(await readFile(credentialsPath(), "utf8")) as {
54
54
  claudeAiOauth?: OAuthCredentials;
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. */
@@ -205,12 +205,25 @@ export class MeshRegistry {
205
205
  * taking their location + history with them (an evicted device leaves no
206
206
  * residue). Returns whether anything went, so callers know the location and
207
207
  * history sidecars need rewriting too.
208
+ *
209
+ * Two devices can legitimately share a `lastSeen` millisecond (a bulk
210
+ * backfill, or two heartbeats landing in the same event-loop tick) —
211
+ * ties are broken by registration order (the earlier registration is
212
+ * treated as staler) so eviction is deterministic rather than depending on
213
+ * `Array.prototype.sort`'s stability guarantee to preserve the `Map`'s
214
+ * insertion order.
208
215
  */
209
216
  private enforceDeviceCap(): boolean {
210
217
  if (this.devices.size <= MAX_DEVICES) return false;
211
218
  const stalest = [...this.devices.values()]
212
- .sort((a, b) => a.lastSeen - b.lastSeen)
213
- .slice(0, this.devices.size - MAX_DEVICES);
219
+ .map((device, insertionIndex) => ({ device, insertionIndex }))
220
+ .sort(
221
+ (a, b) =>
222
+ a.device.lastSeen - b.device.lastSeen ||
223
+ a.insertionIndex - b.insertionIndex,
224
+ )
225
+ .slice(0, this.devices.size - MAX_DEVICES)
226
+ .map(({ device }) => device);
214
227
  for (const device of stalest) {
215
228
  this.devices.delete(device.id);
216
229
  this.locations.delete(device.id);
@@ -230,12 +243,21 @@ export class MeshRegistry {
230
243
  * unknown device ids past MAX_UNREGISTERED_LOCATIONS are dropped
231
244
  * stalest-first, with their history. Registered devices are untouched —
232
245
  * their entries are already bounded by the device cap.
246
+ *
247
+ * Same explicit tie-break as {@link enforceDeviceCap}: fixes with an
248
+ * identical `ts` fall back to insertion order instead of leaning on sort
249
+ * stability.
233
250
  */
234
251
  private enforceOrphanLocationCap(): boolean {
235
252
  let dropped = false;
236
253
  const orphans = [...this.locations.values()]
237
254
  .filter((l) => !this.devices.has(l.deviceId))
238
- .sort((a, b) => a.ts - b.ts);
255
+ .map((location, insertionIndex) => ({ location, insertionIndex }))
256
+ .sort(
257
+ (a, b) =>
258
+ a.location.ts - b.location.ts || a.insertionIndex - b.insertionIndex,
259
+ )
260
+ .map(({ location }) => location);
239
261
  if (orphans.length > MAX_UNREGISTERED_LOCATIONS) {
240
262
  for (const loc of orphans.slice(
241
263
  0,
@@ -12,6 +12,7 @@
12
12
  * - `auth` — `auth:*` (backend sign-in panel, admin only)
13
13
  * - `whatsapp` — `whatsapp:*` (WhatsApp link panel, admin only)
14
14
  * - `backup` — `backup:*` (restore confirmation, admin only)
15
+ * - `usage-reset` — `ureset:*` (spend a banked limit reset, admin DM only)
15
16
  *
16
17
  * `registerCallbacks` installs one `callback_query:data` listener that
17
18
  * dispatches on the data prefix, preserving the original order and the
@@ -31,6 +32,7 @@ import { handleModelCallback } from "./model.js";
31
32
  import { handleAuthCallback } from "./auth.js";
32
33
  import { handleWhatsAppCallback } from "./whatsapp.js";
33
34
  import { handleBackupCallback } from "./backup.js";
35
+ import { handleUsageResetCallback } from "./usage-reset.js";
34
36
 
35
37
  export { answerCallbackQuerySafe } from "./query.js";
36
38
 
@@ -58,6 +60,12 @@ export function registerCallbacks(
58
60
  return;
59
61
  }
60
62
 
63
+ // Spending a banked limit reset — irreversible, so confirm-gated.
64
+ if (data.startsWith("ureset:")) {
65
+ await handleUsageResetCallback(ctx, data);
66
+ return;
67
+ }
68
+
61
69
  // Handle pulse callbacks
62
70
  if (data.startsWith("pulse:")) {
63
71
  await handlePulseCallback(ctx, data, cid);
@@ -0,0 +1,333 @@
1
+ /**
2
+ * `ureset:*` callbacks — spending a banked usage-limit reset from /usage.
3
+ *
4
+ * ureset:ask:<backend> show the confirmation for that backend's next grant
5
+ * ureset:ok:<token> spend it (or retry an unconfirmed claim)
6
+ * ureset:no:<token> drop the confirmation
7
+ *
8
+ * A reset is one-off and belongs to the operator, so every step is gated on
9
+ * the configured admin in a private chat, and nothing is spent without the
10
+ * explicit Confirm press. The confirmation's idempotency key is minted once
11
+ * and reused by every retry of it, and a press that lands while a claim is
12
+ * in flight is ignored — a double tap can never spend two resets.
13
+ */
14
+
15
+ import { randomBytes } from "node:crypto";
16
+ import type { Context } from "grammy";
17
+ import type {
18
+ BankedResetClaim,
19
+ BankedResetControl,
20
+ BankedResetOffer,
21
+ } from "../../../core/agent-runtime/capabilities.js";
22
+ import { getPooledBackend } from "../../../core/engine/backend-controller/index.js";
23
+ import type { BackendUsageEntry } from "../../presentation/plan-usage-report.js";
24
+ import { formatSmartTimestamp } from "../../../util/time.js";
25
+ import { escapeHtml } from "../formatting.js";
26
+ import { isConfiguredAdmin } from "../commands/state.js";
27
+ import { answerCallbackQuerySafe } from "./query.js";
28
+
29
+ type Button = { text: string; callback_data: string };
30
+
31
+ const PENDING_TTL_MS = 10 * 60_000;
32
+ const RETRYABLE: ReadonlySet<BankedResetClaim["result"]> = new Set([
33
+ "unavailable",
34
+ "rate_limited",
35
+ "error",
36
+ ]);
37
+
38
+ interface Pending {
39
+ chatId: number;
40
+ userId: number;
41
+ backendId: string;
42
+ grantId: string;
43
+ /** Idempotency key: one per confirmation, shared by all its retries. */
44
+ requestId: string;
45
+ clears: string[];
46
+ claiming: boolean;
47
+ /** A claim was sent and its outcome is unconfirmed. */
48
+ attempted: boolean;
49
+ createdAt: number;
50
+ }
51
+
52
+ const pending = new Map<string, Pending>();
53
+
54
+ function newToken(): string {
55
+ return randomBytes(6).toString("hex");
56
+ }
57
+
58
+ function newRequestId(): string {
59
+ return randomBytes(16).toString("hex");
60
+ }
61
+
62
+ /** Test hook: forget every open confirmation. */
63
+ export function resetUsageResetState(): void {
64
+ pending.clear();
65
+ }
66
+
67
+ /** Who may spend a reset: the configured admin, in a DM. */
68
+ export function canUseReset(ctx: Context): boolean {
69
+ return isConfiguredAdmin(ctx) && ctx.chat?.type === "private";
70
+ }
71
+
72
+ function controlFor(backendId: string): BankedResetControl | undefined {
73
+ return getPooledBackend(backendId)?.usage?.bankedResets;
74
+ }
75
+
76
+ /**
77
+ * The "Use a reset" row for a /usage reply, or undefined when this viewer
78
+ * mustn't see it or no backend has a reset to spend.
79
+ */
80
+ export function usageResetKeyboard(
81
+ ctx: Context,
82
+ entries: BackendUsageEntry[],
83
+ ): Button[][] | undefined {
84
+ if (!canUseReset(ctx)) return undefined;
85
+ const offers = entries.filter(
86
+ (e) => (e.plan?.resetsAvailable ?? 0) > 0 && controlFor(e.id),
87
+ );
88
+ if (offers.length === 0) return undefined;
89
+ return offers.map((e) => [
90
+ {
91
+ text: offers.length === 1 ? "Use a reset" : `Use a ${e.label} reset`,
92
+ callback_data: `ureset:ask:${e.id}`,
93
+ },
94
+ ]);
95
+ }
96
+
97
+ const WINDOW_NAMES: Record<string, string> = {
98
+ five_hour: "5-hour",
99
+ seven_day: "weekly",
100
+ seven_day_overage_included: "weekly (incl. overage)",
101
+ seven_day_opus: "weekly Opus",
102
+ seven_day_sonnet: "weekly Sonnet",
103
+ };
104
+
105
+ function windowName(key: string): string {
106
+ return WINDOW_NAMES[key] ?? key.replace(/_/g, " ");
107
+ }
108
+
109
+ function when(iso: string | undefined): string | undefined {
110
+ const t = iso ? Date.parse(iso) : NaN;
111
+ return Number.isFinite(t) ? formatSmartTimestamp(t) : undefined;
112
+ }
113
+
114
+ function plural(n: number): string {
115
+ return `${n} reset${n === 1 ? "" : "s"}`;
116
+ }
117
+
118
+ /** The confirmation text for spending `offer`. */
119
+ export function renderResetConfirmation(
120
+ offer: BankedResetOffer,
121
+ now = Date.now(),
122
+ ): string {
123
+ const { grant } = offer;
124
+ const lines = [
125
+ "<b>Use a usage-limit reset?</b>",
126
+ `<i>${escapeHtml(grant.label)}</i>`,
127
+ "",
128
+ ];
129
+ if (grant.clears.length > 0) {
130
+ lines.push(`Clears: ${grant.clears.map(windowName).join(", ")}`);
131
+ const current = grant.clears
132
+ .filter((k) => grant.percentUsed[k] !== undefined)
133
+ .map((k) => `${windowName(k)} ${grant.percentUsed[k]}%`);
134
+ if (current.length > 0) lines.push(`Right now: ${current.join(" · ")}`);
135
+ }
136
+ const by = when(grant.endsAt);
137
+ if (by) lines.push(`Use by: ${escapeHtml(by)}`);
138
+ lines.push(`Resets left: ${offer.totalResetsLeft}`);
139
+
140
+ const warnings: string[] = [];
141
+ if (grant.useRequiresLimit && !offer.atLimit)
142
+ warnings.push(
143
+ "⚠️ This reset only works while you're at a limit, and you aren't — " +
144
+ "the claim will be refused and the reset kept.",
145
+ );
146
+ const cooldown = offer.cooldownUntil ? Date.parse(offer.cooldownUntil) : NaN;
147
+ if (Number.isFinite(cooldown) && cooldown > now)
148
+ warnings.push(
149
+ `⚠️ A cooldown is running until ${escapeHtml(when(offer.cooldownUntil) ?? "")} — ` +
150
+ "the claim will likely be refused.",
151
+ );
152
+ if (warnings.length > 0) lines.push("", ...warnings);
153
+ lines.push("", "This spends a one-off reset and can't be undone.");
154
+ return lines.join("\n");
155
+ }
156
+
157
+ /** A claim's outcome in plain words. */
158
+ export function describeClaim(
159
+ claim: BankedResetClaim,
160
+ clears: string[] = [],
161
+ ): string {
162
+ const left =
163
+ claim.resetsLeft !== undefined ? ` ${plural(claim.resetsLeft)} left.` : "";
164
+ const cleared = claim.cleared.length > 0 ? claim.cleared : clears;
165
+ switch (claim.result) {
166
+ case "reset":
167
+ return (
168
+ "✅ Reset used — " +
169
+ (cleared.length > 0
170
+ ? `your ${cleared.map(windowName).join(", ")} limits are clear again.`
171
+ : "your limits are clear again.") +
172
+ left
173
+ );
174
+ case "already_used":
175
+ return `That reset was already used, so nothing more was spent.${left}`;
176
+ case "not_limited":
177
+ return "Nothing to reset — you aren't at a limit, and this reset only works when you are. It's still banked.";
178
+ case "cooldown": {
179
+ const until = when(claim.cooldownUntil);
180
+ return `A reset was used recently, so this one is on cooldown${until ? ` until ${escapeHtml(until)}` : ""}. Nothing was spent.`;
181
+ }
182
+ case "ineligible":
183
+ return `This account can't use that reset right now${claim.reason ? ` (${escapeHtml(claim.reason)})` : ""}. Nothing was spent.`;
184
+ case "rate_limited":
185
+ return "Too many attempts — wait a minute, then tap Retry. The retry reuses this request, so it can't spend twice.";
186
+ case "auth_error":
187
+ return "Couldn't sign in to Claude with the stored credentials, so nothing was sent.";
188
+ default:
189
+ return "Couldn't confirm whether the reset went through. Tap Retry — it reuses the same request, so it can't spend twice.";
190
+ }
191
+ }
192
+
193
+ function confirmKeyboard(token: string, retry = false): Button[][] {
194
+ return [
195
+ [
196
+ {
197
+ text: retry ? "Retry" : "Confirm",
198
+ callback_data: `ureset:ok:${token}`,
199
+ },
200
+ { text: "Cancel", callback_data: `ureset:no:${token}` },
201
+ ],
202
+ ];
203
+ }
204
+
205
+ /**
206
+ * Look up what a claim would spend and reply with the confirmation. Shared
207
+ * by the /usage button and `/usage reset`.
208
+ */
209
+ export async function sendResetConfirmation(
210
+ ctx: Context,
211
+ backendId: string,
212
+ ): Promise<void> {
213
+ const control = controlFor(backendId);
214
+ const offer = await control?.getOffer().catch(() => undefined);
215
+ if (!offer) {
216
+ await ctx.reply("No usage-limit reset is available to use right now.");
217
+ return;
218
+ }
219
+ const chatId = ctx.chat?.id ?? 0;
220
+ // One open confirmation per chat: a newer one retires the older buttons.
221
+ for (const [token, p] of pending)
222
+ if (p.chatId === chatId && !p.claiming) pending.delete(token);
223
+ const token = newToken();
224
+ pending.set(token, {
225
+ chatId,
226
+ userId: ctx.from?.id ?? 0,
227
+ backendId,
228
+ grantId: offer.grant.id,
229
+ requestId: newRequestId(),
230
+ clears: offer.grant.clears,
231
+ claiming: false,
232
+ attempted: false,
233
+ createdAt: Date.now(),
234
+ });
235
+ await ctx.reply(renderResetConfirmation(offer), {
236
+ parse_mode: "HTML",
237
+ reply_markup: { inline_keyboard: confirmKeyboard(token) },
238
+ });
239
+ }
240
+
241
+ function live(token: string, ctx: Context): Pending | undefined {
242
+ const p = pending.get(token);
243
+ if (!p) return undefined;
244
+ if (Date.now() - p.createdAt > PENDING_TTL_MS && !p.claiming) {
245
+ pending.delete(token);
246
+ return undefined;
247
+ }
248
+ if (p.chatId !== ctx.chat?.id || p.userId !== ctx.from?.id) return undefined;
249
+ return p;
250
+ }
251
+
252
+ async function confirm(ctx: Context, token: string): Promise<void> {
253
+ const p = live(token, ctx);
254
+ if (!p) {
255
+ await answerCallbackQuerySafe(ctx, {
256
+ text: "This confirmation has expired — run /usage again.",
257
+ });
258
+ return;
259
+ }
260
+ if (p.claiming) {
261
+ await answerCallbackQuerySafe(ctx, { text: "Already on it…" });
262
+ return;
263
+ }
264
+ p.claiming = true;
265
+ await answerCallbackQuerySafe(ctx, { text: "Using the reset…" });
266
+ await ctx.editMessageText("⏳ Using the reset…").catch(() => {});
267
+
268
+ let claim: BankedResetClaim;
269
+ try {
270
+ const control = controlFor(p.backendId);
271
+ claim = control
272
+ ? await control.claim(p.grantId, p.requestId)
273
+ : { result: "error", cleared: [] };
274
+ } catch {
275
+ claim = { result: "error", cleared: [] };
276
+ }
277
+
278
+ const text = describeClaim(claim, p.clears);
279
+ if (RETRYABLE.has(claim.result)) {
280
+ // Keep the confirmation (and its request id) so Retry is idempotent.
281
+ p.claiming = false;
282
+ p.attempted = true;
283
+ await ctx
284
+ .editMessageText(text, {
285
+ parse_mode: "HTML",
286
+ reply_markup: { inline_keyboard: confirmKeyboard(token, true) },
287
+ })
288
+ .catch(() => {});
289
+ return;
290
+ }
291
+ pending.delete(token);
292
+ await ctx.editMessageText(text, { parse_mode: "HTML" }).catch(() => {});
293
+ }
294
+
295
+ async function cancel(ctx: Context, token: string): Promise<void> {
296
+ const p = live(token, ctx);
297
+ if (p?.claiming) {
298
+ await answerCallbackQuerySafe(ctx, { text: "Already being claimed." });
299
+ return;
300
+ }
301
+ pending.delete(token);
302
+ await answerCallbackQuerySafe(ctx, { text: "Cancelled." });
303
+ await ctx
304
+ .editMessageText(
305
+ p?.attempted
306
+ ? "Closed. If the last attempt went through, /usage will show one reset fewer."
307
+ : "No reset used.",
308
+ )
309
+ .catch(() => {});
310
+ }
311
+
312
+ export async function handleUsageResetCallback(
313
+ ctx: Context,
314
+ data: string,
315
+ ): Promise<void> {
316
+ if (!canUseReset(ctx)) {
317
+ await answerCallbackQuerySafe(ctx, { text: "Not authorized." });
318
+ return;
319
+ }
320
+ const [, action, arg] = data.split(":");
321
+ if (!arg || !/^[A-Za-z0-9_-]{1,40}$/.test(arg)) {
322
+ await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
323
+ return;
324
+ }
325
+ if (action === "ask") {
326
+ await answerCallbackQuerySafe(ctx);
327
+ await sendResetConfirmation(ctx, arg);
328
+ return;
329
+ }
330
+ if (action === "ok") return confirm(ctx, arg);
331
+ if (action === "no") return cancel(ctx, arg);
332
+ await answerCallbackQuerySafe(ctx, { text: "Invalid callback data" });
333
+ }
@@ -25,6 +25,11 @@ import {
25
25
  renderUsageMessage,
26
26
  } from "../render/reports.js";
27
27
  import { collectPlanUsage } from "../../presentation/plan-usage-report.js";
28
+ import {
29
+ canUseReset,
30
+ sendResetConfirmation,
31
+ usageResetKeyboard,
32
+ } from "../callbacks/usage-reset.js";
28
33
  import { collectDoctorReport } from "../../../core/doctor/index.js";
29
34
  import { handleAdminCommand } from "../admin.js";
30
35
  import { getTodayMetrics } from "../../../storage/metrics.js";
@@ -58,11 +63,32 @@ function registerMetricsCommand(bot: Bot): void {
58
63
 
59
64
  // /usage — plan limits across every exposed backend, not just this
60
65
  // chat's. Not admin-gated: it says how close the shared account is to a
61
- // wall, which is exactly what a user hitting one needs to know.
66
+ // wall, which is exactly what a user hitting one needs to know. Spending a
67
+ // banked reset is: the "Use a reset" button (and `/usage reset`) only ever
68
+ // reach the configured admin in a DM, and both end at a Confirm press.
62
69
  function registerUsageCommand(bot: Bot, config: TalonConfig): void {
63
70
  bot.command("usage", async (ctx) => {
64
71
  const entries = await collectPlanUsage(config);
65
- await ctx.reply(renderUsageMessage(entries), { parse_mode: "HTML" });
72
+ if (ctx.match.trim().toLowerCase() === "reset") {
73
+ if (!canUseReset(ctx)) {
74
+ await ctx.reply(
75
+ "Only the admin can use a usage-limit reset, in a private chat.",
76
+ );
77
+ return;
78
+ }
79
+ const target = entries.find((e) => (e.plan?.resetsAvailable ?? 0) > 0);
80
+ if (!target) {
81
+ await ctx.reply("No usage-limit reset is available to use right now.");
82
+ return;
83
+ }
84
+ await sendResetConfirmation(ctx, target.id);
85
+ return;
86
+ }
87
+ const keyboard = usageResetKeyboard(ctx, entries);
88
+ await ctx.reply(renderUsageMessage(entries), {
89
+ parse_mode: "HTML",
90
+ ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
91
+ });
66
92
  });
67
93
  }
68
94
 
@@ -28,6 +28,17 @@ export function isAuthorizedAdmin(ctx: Context): boolean {
28
28
  );
29
29
  }
30
30
 
31
+ /**
32
+ * Stricter than `isAuthorizedAdmin`: a configured admin id is required and
33
+ * must match. For irreversible actions on the operator's own account, where
34
+ * "no admin configured" must not mean "anyone may".
35
+ */
36
+ export function isConfiguredAdmin(ctx: Context): boolean {
37
+ return (
38
+ adminState.adminUserId !== 0 && ctx.from?.id === adminState.adminUserId
39
+ );
40
+ }
41
+
31
42
  export type RegisterDeps = {
32
43
  config: import("../../../core/config/index.js").TalonConfig;
33
44
  gateway?: { backend: Backend | null };