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