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 +1 -1
- 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} +5 -5
- package/src/cli/config.ts +112 -6
- package/src/cli/index.ts +81 -79
- package/src/core/agent-runtime/capabilities.ts +61 -0
- package/src/core/mesh/devices/registry.ts +25 -3
- 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/package.json
CHANGED
|
@@ -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 "
|
|
18
|
+
import { logWarn } from "../../../util/log.js";
|
|
19
19
|
import type {
|
|
20
20
|
PlanUsage,
|
|
21
21
|
PlanWindow,
|
|
22
|
-
} from "
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
159
|
+
raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new ConfigFileError(
|
|
162
|
+
`Cannot read ${CONFIG_FILE}: ${err instanceof Error ? err.message : err}`,
|
|
163
|
+
CONFIG_FILE,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const result = parseConfigJson(raw);
|
|
167
|
+
if (!result.ok) {
|
|
168
|
+
throw new ConfigFileError(
|
|
169
|
+
`Invalid config in ${CONFIG_FILE}: ${result.problem}. ` +
|
|
170
|
+
`The file was left untouched — fix it and try again.`,
|
|
171
|
+
CONFIG_FILE,
|
|
172
|
+
);
|
|
97
173
|
}
|
|
98
|
-
return { ...DEFAULTS };
|
|
174
|
+
return { ...DEFAULTS, ...result.data };
|
|
99
175
|
}
|
|
100
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Save ~/.talon/config.json. Refuses to overwrite an existing file that
|
|
179
|
+
* isn't valid JSON. The normal caller shape is load → edit → save, and
|
|
180
|
+
* `loadConfig` above already throws before such a caller ever reaches
|
|
181
|
+
* this point — this check is the backstop for any caller that saves
|
|
182
|
+
* without a fresh load, or for the file changing under us between the
|
|
183
|
+
* two calls: writing `config` in either case would silently replace
|
|
184
|
+
* whatever is actually on disk with the caller's best guess.
|
|
185
|
+
*/
|
|
101
186
|
export function saveConfig(config: Config): void {
|
|
187
|
+
if (existsSync(CONFIG_FILE)) {
|
|
188
|
+
let raw: string;
|
|
189
|
+
try {
|
|
190
|
+
raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
191
|
+
} catch (err) {
|
|
192
|
+
throw new ConfigFileError(
|
|
193
|
+
`Refusing to write ${CONFIG_FILE}: cannot read the existing file ` +
|
|
194
|
+
`(${err instanceof Error ? err.message : err}). It was left untouched.`,
|
|
195
|
+
CONFIG_FILE,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const result = parseConfigJson(raw);
|
|
199
|
+
if (!result.ok) {
|
|
200
|
+
throw new ConfigFileError(
|
|
201
|
+
`Refusing to write ${CONFIG_FILE}: the existing file is invalid ` +
|
|
202
|
+
`(${result.problem}). It was left untouched — fix it manually, ` +
|
|
203
|
+
`or delete it to start over.`,
|
|
204
|
+
CONFIG_FILE,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
102
208
|
if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
|
|
103
209
|
const clean = Object.fromEntries(
|
|
104
210
|
Object.entries(config).filter(([, v]) => v !== undefined),
|
package/src/cli/index.ts
CHANGED
|
@@ -24,7 +24,7 @@ import pc from "picocolors";
|
|
|
24
24
|
// inlines the JSON at compile time; tsx/node resolve it from the package.
|
|
25
25
|
import pkg from "../../package.json" with { type: "json" };
|
|
26
26
|
import { PKG_ROOT } from "./context.js";
|
|
27
|
-
import { printBanner } from "./config.js";
|
|
27
|
+
import { printBanner, ConfigFileError } from "./config.js";
|
|
28
28
|
import { runSetup } from "./setup.js";
|
|
29
29
|
import { showStatus } from "./status.js";
|
|
30
30
|
import { viewConfig } from "./config-view.js";
|
|
@@ -121,84 +121,86 @@ function printHelp(): void {
|
|
|
121
121
|
/** Route a `talon <command>` invocation. Called by the entry point. */
|
|
122
122
|
export async function runCli(): Promise<void> {
|
|
123
123
|
const command = process.argv[2];
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
printBanner();
|
|
139
|
-
await daemonStart();
|
|
140
|
-
break;
|
|
141
|
-
case "stop":
|
|
142
|
-
printBanner();
|
|
143
|
-
await daemonStop();
|
|
144
|
-
break;
|
|
145
|
-
case "restart":
|
|
146
|
-
printBanner();
|
|
147
|
-
await daemonRestart();
|
|
148
|
-
break;
|
|
149
|
-
case "run":
|
|
150
|
-
process.chdir(PKG_ROOT);
|
|
151
|
-
import("../index.js");
|
|
152
|
-
break;
|
|
153
|
-
case "chat":
|
|
154
|
-
process.chdir(PKG_ROOT);
|
|
155
|
-
startChat();
|
|
156
|
-
break;
|
|
157
|
-
case "doctor":
|
|
158
|
-
runDoctor();
|
|
159
|
-
break;
|
|
160
|
-
case "ps":
|
|
161
|
-
await showTasks(process.argv[3] === "--all" || process.argv[3] === "-a");
|
|
162
|
-
break;
|
|
163
|
-
case "kill":
|
|
164
|
-
await killTask(process.argv[3]);
|
|
165
|
-
break;
|
|
166
|
-
case "events":
|
|
167
|
-
await showEvents(eventsOptions(process.argv.slice(3)));
|
|
168
|
-
break;
|
|
169
|
-
case "backup":
|
|
170
|
-
await runBackupCommand(process.argv.slice(3));
|
|
171
|
-
break;
|
|
172
|
-
case "plugin":
|
|
173
|
-
await runPluginCommand(process.argv.slice(3));
|
|
174
|
-
break;
|
|
175
|
-
case "skill":
|
|
176
|
-
await runSkillCommand(process.argv.slice(3));
|
|
177
|
-
break;
|
|
178
|
-
case "memory":
|
|
179
|
-
runMemoryCommand(process.argv.slice(3));
|
|
180
|
-
break;
|
|
181
|
-
case "--version":
|
|
182
|
-
case "-v": {
|
|
183
|
-
console.log(pkg.version);
|
|
184
|
-
break;
|
|
185
|
-
}
|
|
186
|
-
case "--help":
|
|
187
|
-
case "-h":
|
|
188
|
-
printHelp();
|
|
189
|
-
break;
|
|
190
|
-
case undefined:
|
|
191
|
-
mainMenu();
|
|
192
|
-
break;
|
|
193
|
-
default: {
|
|
194
|
-
// "did you mean ...?" via the native similarity core (native/strsim-wasm).
|
|
195
|
-
const { closestMatch } = await import("../native/strsim.js");
|
|
196
|
-
const suggestion = closestMatch(command, CLI_COMMANDS);
|
|
197
|
-
const hint = suggestion
|
|
198
|
-
? `Did you mean ${pc.cyan(`talon ${suggestion.value}`)}?`
|
|
199
|
-
: `Run ${pc.cyan("talon --help")} for usage.`;
|
|
200
|
-
console.error(` Unknown command: ${command}\n ${hint}\n`);
|
|
201
|
-
process.exit(1);
|
|
124
|
+
try {
|
|
125
|
+
await dispatch(command);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// A present-but-invalid config.json: every command below that reads
|
|
128
|
+
// config (directly, or via `mainMenu`'s "is this configured?" check)
|
|
129
|
+
// is async but was previously invoked without `await`, so this throw
|
|
130
|
+
// would otherwise surface as a bare unhandled-rejection stack trace —
|
|
131
|
+
// or, worse for the main menu, never happen at all, because the old
|
|
132
|
+
// loader swallowed the error and returned defaults, sending a broken
|
|
133
|
+
// install into the first-run wizard, which then saves over the file.
|
|
134
|
+
if (err instanceof ConfigFileError) {
|
|
135
|
+
console.error(`\n ${pc.red("✖")} ${err.message}\n`);
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
return;
|
|
202
138
|
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type CommandHandler = (args: string[]) => void | Promise<void>;
|
|
144
|
+
|
|
145
|
+
/** Every `talon <command>`, keyed by name. Handlers get the argv after the command. */
|
|
146
|
+
const COMMANDS: Record<string, CommandHandler> = {
|
|
147
|
+
setup: () => runSetup(),
|
|
148
|
+
status: () => showStatus(),
|
|
149
|
+
config: () => viewConfig(),
|
|
150
|
+
logs: () => tailLogs(),
|
|
151
|
+
start: async () => {
|
|
152
|
+
printBanner();
|
|
153
|
+
await daemonStart();
|
|
154
|
+
},
|
|
155
|
+
stop: async () => {
|
|
156
|
+
printBanner();
|
|
157
|
+
await daemonStop();
|
|
158
|
+
},
|
|
159
|
+
restart: async () => {
|
|
160
|
+
printBanner();
|
|
161
|
+
await daemonRestart();
|
|
162
|
+
},
|
|
163
|
+
run: () => {
|
|
164
|
+
process.chdir(PKG_ROOT);
|
|
165
|
+
void import("../index.js");
|
|
166
|
+
},
|
|
167
|
+
chat: () => {
|
|
168
|
+
process.chdir(PKG_ROOT);
|
|
169
|
+
startChat();
|
|
170
|
+
},
|
|
171
|
+
doctor: () => runDoctor(),
|
|
172
|
+
ps: (args) => showTasks(args[0] === "--all" || args[0] === "-a"),
|
|
173
|
+
kill: (args) => killTask(args[0]),
|
|
174
|
+
events: (args) => showEvents(eventsOptions(args)),
|
|
175
|
+
backup: (args) => runBackupCommand(args),
|
|
176
|
+
plugin: (args) => runPluginCommand(args),
|
|
177
|
+
skill: (args) => runSkillCommand(args),
|
|
178
|
+
memory: (args) => runMemoryCommand(args),
|
|
179
|
+
"--version": () => console.log(pkg.version),
|
|
180
|
+
"-v": () => console.log(pkg.version),
|
|
181
|
+
"--help": () => printHelp(),
|
|
182
|
+
"-h": () => printHelp(),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
async function unknownCommand(command: string): Promise<never> {
|
|
186
|
+
// "did you mean ...?" via the native similarity core (native/strsim-wasm).
|
|
187
|
+
const { closestMatch } = await import("../native/strsim.js");
|
|
188
|
+
const suggestion = closestMatch(command, CLI_COMMANDS);
|
|
189
|
+
const hint = suggestion
|
|
190
|
+
? `Did you mean ${pc.cyan(`talon ${suggestion.value}`)}?`
|
|
191
|
+
: `Run ${pc.cyan("talon --help")} for usage.`;
|
|
192
|
+
console.error(` Unknown command: ${command}\n ${hint}\n`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function dispatch(command: string | undefined): Promise<void> {
|
|
197
|
+
if (command === undefined) {
|
|
198
|
+
await mainMenu();
|
|
199
|
+
return;
|
|
203
200
|
}
|
|
201
|
+
const handler = Object.hasOwn(COMMANDS, command)
|
|
202
|
+
? COMMANDS[command]
|
|
203
|
+
: undefined;
|
|
204
|
+
if (!handler) return unknownCommand(command);
|
|
205
|
+
await handler(process.argv.slice(3));
|
|
204
206
|
}
|
|
@@ -243,6 +243,67 @@ export interface UsageTelemetry {
|
|
|
243
243
|
* plan concept; resolves `undefined` when the data can't be read.
|
|
244
244
|
*/
|
|
245
245
|
getPlanUsage?(): Promise<PlanUsage | undefined>;
|
|
246
|
+
/**
|
|
247
|
+
* Banked one-shot limit resets, where the plan has them. Spending one is
|
|
248
|
+
* irreversible, so this is reachable only from a human-pressed confirm
|
|
249
|
+
* button — never exposed as an agent tool.
|
|
250
|
+
*/
|
|
251
|
+
bankedResets?: BankedResetControl;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** One banked reset grant, as the plan reports it. */
|
|
255
|
+
export interface BankedResetGrant {
|
|
256
|
+
id: string;
|
|
257
|
+
label: string;
|
|
258
|
+
resetsLeft: number;
|
|
259
|
+
/** ISO deadline, when the grant has one. */
|
|
260
|
+
endsAt?: string;
|
|
261
|
+
/** Plan windows the reset clears (`five_hour`, `seven_day`, …). */
|
|
262
|
+
clears: string[];
|
|
263
|
+
/** Current utilisation per cleared window, 0-100. */
|
|
264
|
+
percentUsed: Record<string, number>;
|
|
265
|
+
/** The reset can only be spent while the account is at a limit. */
|
|
266
|
+
useRequiresLimit: boolean;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The grant a claim would spend, plus the account state around it. */
|
|
270
|
+
export interface BankedResetOffer {
|
|
271
|
+
grant: BankedResetGrant;
|
|
272
|
+
atLimit: boolean;
|
|
273
|
+
/** ISO end of a post-claim cooldown, while one is running. */
|
|
274
|
+
cooldownUntil?: string;
|
|
275
|
+
/** Resets left across every usable grant. */
|
|
276
|
+
totalResetsLeft: number;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export type BankedResetResult =
|
|
280
|
+
| "reset"
|
|
281
|
+
| "already_used"
|
|
282
|
+
| "not_limited"
|
|
283
|
+
| "cooldown"
|
|
284
|
+
| "ineligible"
|
|
285
|
+
| "unavailable"
|
|
286
|
+
| "rate_limited"
|
|
287
|
+
| "auth_error"
|
|
288
|
+
| "error";
|
|
289
|
+
|
|
290
|
+
export interface BankedResetClaim {
|
|
291
|
+
result: BankedResetResult;
|
|
292
|
+
/** Server-side reason code, when it gave one. */
|
|
293
|
+
reason?: string;
|
|
294
|
+
resetsLeft?: number;
|
|
295
|
+
cleared: string[];
|
|
296
|
+
weeklyResetsAt?: string;
|
|
297
|
+
cooldownUntil?: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface BankedResetControl {
|
|
301
|
+
getOffer(): Promise<BankedResetOffer | undefined>;
|
|
302
|
+
/**
|
|
303
|
+
* Spend one reset from `grantId`. `requestId` is the idempotency key:
|
|
304
|
+
* reuse it when retrying the same user action so a retry can't spend twice.
|
|
305
|
+
*/
|
|
306
|
+
claim(grantId: string, requestId: string): Promise<BankedResetClaim>;
|
|
246
307
|
}
|
|
247
308
|
|
|
248
309
|
/** One subscription rate-limit window, as `/status` renders it. */
|
|
@@ -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,
|
|
@@ -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 };
|