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.
@@ -19,12 +19,12 @@ import {
19
19
  validateTimeout,
20
20
  writeScriptFile,
21
21
  DEFAULT_TIMEOUT_SECONDS,
22
- MAX_ACTIVE_PER_CHAT,
23
22
  type TriggerLanguage,
24
23
  } from "../../../storage/triggers.js";
25
24
  import {
26
25
  cancelTrigger,
27
26
  spawnTrigger,
27
+ triggerCapError,
28
28
  } from "../../background/triggers/index.js";
29
29
  import { log } from "../../../util/log.js";
30
30
  import { validateJobModelOverride } from "./validation.js";
@@ -61,13 +61,11 @@ export const triggerHandlers: SharedActionHandlers = {
61
61
  error: `A trigger named "${name}" already exists in this chat. Cancel it first or pick a different name.`,
62
62
  };
63
63
  }
64
- const active = getActiveTriggersForChat(chatKey);
65
- if (active.length >= MAX_ACTIVE_PER_CHAT) {
66
- return {
67
- ok: false,
68
- error: `Per-chat trigger cap reached (${MAX_ACTIVE_PER_CHAT} active). Cancel one before creating another.`,
69
- };
70
- }
64
+ const capErr = triggerCapError(
65
+ getActiveTriggersForChat(chatKey),
66
+ persistent,
67
+ );
68
+ if (capErr) return { ok: false, error: capErr };
71
69
 
72
70
  // Validate the model up front so a bad id is rejected here instead of
73
71
  // silently failing at fire time.
@@ -40,7 +40,9 @@
40
40
  * 2. `TALON_BRIDGE_URL/health` stops responding for several
41
41
  * consecutive pings — Talon's gateway is gone. Catches the
42
42
  * "kilo serve / opencode serve outlives Talon" case where those
43
- * daemons keep our stdin open across Talon restarts.
43
+ * daemons keep our stdin open across Talon restarts. A ping that only
44
+ * times out (port still bound, gateway busy) is tolerated for minutes,
45
+ * not seconds — see BridgeWatchdog.
44
46
  */
45
47
 
46
48
  import crossSpawn from "cross-spawn";
@@ -203,7 +205,75 @@ export function wrapMcpCommand(command: readonly string[]): string[] {
203
205
  // within ~1 minute.
204
206
  const BRIDGE_PING_INTERVAL_MS = 15_000;
205
207
  const BRIDGE_PING_TIMEOUT_MS = 2_000;
206
- const BRIDGE_FAILURES_BEFORE_EXIT = 4;
208
+ export const BRIDGE_FAILURES_BEFORE_EXIT = 4;
209
+ // A ping that TIMES OUT means the port is still bound — the kernel accepted
210
+ // the connection — but the gateway is too busy to answer within 2s (event
211
+ // loop saturated by a burst of agents, a big synchronous write, …). That is
212
+ // a live Talon, not a dead one, so it gets a much longer budget (~5 min)
213
+ // before the child is evicted. Only a truly wedged daemon reaches it.
214
+ export const BRIDGE_UNRESPONSIVE_BEFORE_EXIT = 20;
215
+
216
+ /**
217
+ * Outcome of one bridge health ping:
218
+ * - "ok": /health answered 2xx.
219
+ * - "unreachable": nothing healthy behind the port (connection refused or
220
+ * reset, non-2xx reply) — Talon is gone or restarting.
221
+ * - "unresponsive": the request timed out — something holds the port but
222
+ * is slow to answer; Talon is alive but busy.
223
+ */
224
+ export type BridgePingOutcome = "ok" | "unreachable" | "unresponsive";
225
+
226
+ /** Classify a rejected health fetch. Timeouts/aborts mean "busy", not "gone". */
227
+ export function classifyBridgePingError(
228
+ err: unknown,
229
+ ): Exclude<BridgePingOutcome, "ok"> {
230
+ const name = (err as { name?: unknown } | null)?.name;
231
+ return name === "TimeoutError" || name === "AbortError"
232
+ ? "unresponsive"
233
+ : "unreachable";
234
+ }
235
+
236
+ /** Ping `${bridgeUrl}/health` once. Never throws. */
237
+ export async function pingBridge(
238
+ bridgeUrl: string,
239
+ timeoutMs: number = BRIDGE_PING_TIMEOUT_MS,
240
+ ): Promise<BridgePingOutcome> {
241
+ try {
242
+ const resp = await fetch(`${bridgeUrl}/health`, {
243
+ signal: AbortSignal.timeout(timeoutMs),
244
+ });
245
+ // Drain the body so the socket is released promptly.
246
+ await resp.arrayBuffer().catch(() => undefined);
247
+ return resp.ok ? "ok" : "unreachable";
248
+ } catch (err) {
249
+ return classifyBridgePingError(err);
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Consecutive-failure bookkeeping for the bridge watchdog. `record()`
255
+ * returns true once the child should be shut down: after
256
+ * BRIDGE_FAILURES_BEFORE_EXIT failures ending in an "unreachable" ping (the
257
+ * port is really closed), or after BRIDGE_UNRESPONSIVE_BEFORE_EXIT failures
258
+ * of any kind (the daemon is wedged, not just busy).
259
+ */
260
+ export class BridgeWatchdog {
261
+ consecutiveFailures = 0;
262
+
263
+ record(outcome: BridgePingOutcome): boolean {
264
+ if (outcome === "ok") {
265
+ this.consecutiveFailures = 0;
266
+ return false;
267
+ }
268
+ this.consecutiveFailures += 1;
269
+ if (this.consecutiveFailures >= BRIDGE_UNRESPONSIVE_BEFORE_EXIT)
270
+ return true;
271
+ return (
272
+ outcome === "unreachable" &&
273
+ this.consecutiveFailures >= BRIDGE_FAILURES_BEFORE_EXIT
274
+ );
275
+ }
276
+ }
207
277
 
208
278
  /**
209
279
  * Run the supervisor over `argvTail` = [cmd, ...args].
@@ -346,29 +416,19 @@ export function runSupervisor(argvTail: string[]): Promise<never> {
346
416
  // (every Talon-spawned MCP server has it; ad-hoc supervisor uses
347
417
  // without the env var keep the stdin-EOF-only behavior).
348
418
  if (BRIDGE_URL) {
349
- let consecutiveFailures = 0;
419
+ const watchdog = new BridgeWatchdog();
350
420
  const tick = async (): Promise<void> => {
351
421
  if (terminating) return;
352
- try {
353
- const resp = await fetch(`${BRIDGE_URL}/health`, {
354
- signal: AbortSignal.timeout(BRIDGE_PING_TIMEOUT_MS),
355
- });
356
- if (resp.ok) {
357
- consecutiveFailures = 0;
358
- return;
359
- }
360
- consecutiveFailures += 1;
361
- } catch {
362
- consecutiveFailures += 1;
363
- }
364
- if (consecutiveFailures >= BRIDGE_FAILURES_BEFORE_EXIT) {
365
- // Talon's gateway is gone. The MCP child has nothing useful to
366
- // serve — bridge calls would 404 against a dead port — so shut
367
- // down. Kilo/OpenCode notice the stdio close on the next
368
- // interaction and drop the registration on their side.
422
+ const outcome = await pingBridge(BRIDGE_URL);
423
+ if (terminating) return;
424
+ if (watchdog.record(outcome)) {
425
+ // Talon's gateway is gone (or wedged for minutes). The MCP child
426
+ // has nothing useful to serve — bridge calls would 404 against a
427
+ // dead port — so shut down. Kilo/OpenCode notice the stdio close
428
+ // on the next interaction and drop the registration on their side.
369
429
  process.stderr.write(
370
- `mcp-launcher: bridge ${BRIDGE_URL} unreachable for ${
371
- consecutiveFailures * (BRIDGE_PING_INTERVAL_MS / 1000)
430
+ `mcp-launcher: bridge ${BRIDGE_URL} ${outcome} for ${
431
+ watchdog.consecutiveFailures * (BRIDGE_PING_INTERVAL_MS / 1000)
372
432
  }s; shutting down child\n`,
373
433
  );
374
434
  terminate(0);
@@ -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,
@@ -57,7 +57,8 @@ shutdown/crash are respawned (not ones that exited on their own), the
57
57
  script must be safe to re-run from scratch, and timeout_seconds is ignored
58
58
  (persistent triggers run until cancelled or until Talon shuts down).
59
59
 
60
- Per-chat cap of 5 active triggers.`;
60
+ Per-chat cap on active triggers (default 5; config triggers.maxActivePerChat,
61
+ with an optional separate triggers.maxPersistentPerChat budget).`;
61
62
 
62
63
  export const triggerTools: ToolDefinition[] = [
63
64
  {
@@ -462,7 +462,10 @@ export function renderUsageMessage(
462
462
  if (entry.plan.resetsAvailable) {
463
463
  const n = entry.plan.resetsAvailable;
464
464
  const resets = `usage limit reset${n === 1 ? "" : "s"} available`;
465
- lines.push(` • You have ${fmt.bold(String(n))} ${resets}`);
465
+ const by = entry.plan.resetsExpireLabel
466
+ ? ` ${fmt.escape(`(use by ${entry.plan.resetsExpireLabel})`)}`
467
+ : "";
468
+ lines.push(` • You have ${fmt.bold(String(n))} ${resets}${by}`);
466
469
  }
467
470
  for (const w of entry.plan.windows) {
468
471
  const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
@@ -404,6 +404,8 @@ export interface PlanDisplay {
404
404
  windows: PlanWindowDisplay[];
405
405
  /** Set only when the account still has one-shot rate-limit resets left. */
406
406
  resetsAvailable: number | undefined;
407
+ /** Deadline for the soonest-expiring banked reset, when the plan reports one. */
408
+ resetsExpireLabel?: string;
407
409
  /** Set only when the figures have aged, e.g. "12m ago". */
408
410
  ageLabel: string | undefined;
409
411
  }
@@ -432,6 +434,9 @@ export function buildPlanDisplay(
432
434
  return {
433
435
  plan: usage.plan,
434
436
  resetsAvailable: usage.resetsAvailable,
437
+ resetsExpireLabel: usage.resetsAvailable
438
+ ? planResetLabel(usage.resetsExpireAt)
439
+ : undefined,
435
440
  ageLabel:
436
441
  age > PLAN_STALE_AFTER_MS
437
442
  ? formatRelativeAge(usage.fetchedAt)
@@ -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 };