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
package/README.md
CHANGED
|
@@ -508,6 +508,33 @@ Config file: `~/.talon/config.json`
|
|
|
508
508
|
| `memory` | --- | Long-term memory backend selection: `mempalace` or `mem0` (see above) |
|
|
509
509
|
| `mempalace` | --- | Legacy MemPalace plugin config (prefer `memory`) |
|
|
510
510
|
| `playwright` | --- | Playwright plugin config (see above) |
|
|
511
|
+
| `triggers` | --- | Per-chat trigger caps, e.g. `{ "maxActivePerChat": 5, "maxPersistentPerChat": 3 }` ([Scaling limits](#scaling-limits)) |
|
|
512
|
+
| `agents` | --- | Sub-agent caps: `{ "maxConcurrent": 6, "maxDepth": 2, "defaultTimeoutMs": 900000 }` ([docs/agents.md](docs/agents.md)) |
|
|
513
|
+
|
|
514
|
+
### Scaling limits
|
|
515
|
+
|
|
516
|
+
Two caps bound how much background work Talon keeps alive. Both keep their
|
|
517
|
+
historical defaults and are raised in `~/.talon/config.json`; the error a
|
|
518
|
+
capped tool returns names the key to raise.
|
|
519
|
+
|
|
520
|
+
| Key | Default | Bounds | What it caps |
|
|
521
|
+
| ------------------------------- | ------- | ------ | ------------------------------------------------------------ |
|
|
522
|
+
| `triggers.maxActivePerChat` | `5` | 1–50 | Active (running or pending) triggers per chat |
|
|
523
|
+
| `triggers.maxPersistentPerChat` | unset | 1–50 | Optional separate budget for persistent triggers (see below) |
|
|
524
|
+
| `agents.maxConcurrent` | `6` | 1–64 | Live sub-agents daemon-wide, children included |
|
|
525
|
+
|
|
526
|
+
With `maxPersistentPerChat` unset, persistent and ad-hoc triggers share
|
|
527
|
+
`maxActivePerChat`, exactly as before. Set it and the two draw from separate
|
|
528
|
+
budgets: persistent triggers count only against `maxPersistentPerChat`, and
|
|
529
|
+
`maxActivePerChat` then bounds ad-hoc (non-persistent) triggers only — so a
|
|
530
|
+
chat running long-lived watchers still has room for a short CI wait. Caps are
|
|
531
|
+
checked at `trigger_create`; lowering one never kills a trigger already
|
|
532
|
+
running, and persistent triggers resumed after a restart are not re-checked.
|
|
533
|
+
|
|
534
|
+
```json
|
|
535
|
+
"triggers": { "maxActivePerChat": 5, "maxPersistentPerChat": 6 },
|
|
536
|
+
"agents": { "maxConcurrent": 12 }
|
|
537
|
+
```
|
|
511
538
|
|
|
512
539
|
### Background reasoning effort
|
|
513
540
|
|
package/package.json
CHANGED
package/src/app.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* are loaded dynamically — only the selected platform's dependencies are required.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { getFrontends } from "./core/config/index.js";
|
|
9
|
+
import { ConfigFileError, getFrontends } from "./core/config/index.js";
|
|
10
10
|
import { startUploadCleanup, stopUploadCleanup } from "./core/vfs/workspace.js";
|
|
11
11
|
import { flushDatabase } from "./storage/db.js";
|
|
12
12
|
import { getActiveCount, stopAllTurns } from "./core/engine/dispatcher.js";
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
crashCleanup,
|
|
41
41
|
crashStep,
|
|
42
42
|
handleUncaughtException,
|
|
43
|
+
handleUnhandledRejection,
|
|
43
44
|
} from "./core/daemon/crash.js";
|
|
44
45
|
import { log, logError, logWarn } from "./util/log.js";
|
|
45
46
|
import { bootPhase, bootReport } from "./core/daemon/boot-timer.js";
|
|
@@ -119,9 +120,31 @@ async function applyStagedRestore(): Promise<string | null> {
|
|
|
119
120
|
);
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
|
|
123
|
+
/**
|
|
124
|
+
* A present-but-invalid config.json is fatal at startup: print the file
|
|
125
|
+
* path and every problem, then exit non-zero. Booting on defaults instead
|
|
126
|
+
* would put the daemon in a surprising state (wrong frontend, no plugins).
|
|
127
|
+
*/
|
|
128
|
+
async function withConfigGuard<T>(fn: () => Promise<T>): Promise<T> {
|
|
129
|
+
try {
|
|
130
|
+
return await fn();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (err instanceof ConfigFileError) {
|
|
133
|
+
logError("config", err.message);
|
|
134
|
+
console.error(`talon: ${err.message}`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const restoreReport = await withConfigGuard(() =>
|
|
142
|
+
bootPhase("staged restore", applyStagedRestore),
|
|
143
|
+
);
|
|
123
144
|
|
|
124
|
-
const { config } = await
|
|
145
|
+
const { config } = await withConfigGuard(() =>
|
|
146
|
+
bootPhase("bootstrap", () => bootstrap()),
|
|
147
|
+
);
|
|
125
148
|
|
|
126
149
|
// Record this process as the daemon. The gateway port is appended once
|
|
127
150
|
// the gateway binds (it may fall back from the default on EADDRINUSE).
|
|
@@ -361,12 +384,7 @@ process.on("uncaughtException", (err) =>
|
|
|
361
384
|
handleUncaughtException(err, crashHooks),
|
|
362
385
|
);
|
|
363
386
|
|
|
364
|
-
process.on("unhandledRejection",
|
|
365
|
-
logWarn(
|
|
366
|
-
"bot",
|
|
367
|
-
`Unhandled rejection: ${reason instanceof Error ? reason.message : reason}`,
|
|
368
|
-
);
|
|
369
|
-
});
|
|
387
|
+
process.on("unhandledRejection", handleUnhandledRejection);
|
|
370
388
|
|
|
371
389
|
// ── Start ────────────────────────────────────────────────────────────────────
|
|
372
390
|
|
|
@@ -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,13 +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 "
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
} from "../../../core/agent-runtime/capabilities.js";
|
|
23
|
+
|
|
24
|
+
// `cedar_ember=1` asks the endpoint to include banked limit resets (the
|
|
25
|
+
// claude.ai "Reset for free" grants); `skip_spend=1` drops the spend block we
|
|
26
|
+
// don't render. Resets are only reported to the CLI surface — any other
|
|
27
|
+
// user agent gets `ineligible_reason: "surface"` — so the request identifies
|
|
28
|
+
// as the CLI, which is what the Agent SDK runs anyway.
|
|
29
|
+
export const USAGE_ENDPOINT =
|
|
30
|
+
"https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1";
|
|
31
|
+
export const CLI_USER_AGENT = "claude-cli/2.1.280 (external, cli)";
|
|
25
32
|
const REQUEST_TIMEOUT_MS = 5_000;
|
|
26
33
|
const CACHE_TTL_MS = 60_000;
|
|
27
34
|
|
|
@@ -41,7 +48,7 @@ interface OAuthCredentials {
|
|
|
41
48
|
subscriptionType?: string;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
async function readCredentials(): Promise<OAuthCredentials | undefined> {
|
|
51
|
+
export async function readCredentials(): Promise<OAuthCredentials | undefined> {
|
|
45
52
|
try {
|
|
46
53
|
const parsed = JSON.parse(await readFile(credentialsPath(), "utf8")) as {
|
|
47
54
|
claudeAiOauth?: OAuthCredentials;
|
|
@@ -77,6 +84,48 @@ function windowLabel(limit: RawLimit): string | undefined {
|
|
|
77
84
|
return undefined;
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
interface RawResetGrant {
|
|
88
|
+
resets_left?: number;
|
|
89
|
+
ends_at?: string | null;
|
|
90
|
+
paused?: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Banked limit resets still usable: unpaused grants whose window hasn't
|
|
95
|
+
* closed. Returns the count and the soonest deadline among grants that still
|
|
96
|
+
* hold a reset, or undefined when there's nothing to offer.
|
|
97
|
+
*/
|
|
98
|
+
export function parseBankedResets(
|
|
99
|
+
body: unknown,
|
|
100
|
+
now = Date.now(),
|
|
101
|
+
): { count: number; expiresAt?: string } | undefined {
|
|
102
|
+
const program = (body as { cedar_ember?: unknown } | null)?.cedar_ember as
|
|
103
|
+
{ eligible?: boolean; grants?: unknown } | null | undefined;
|
|
104
|
+
if (!program || program.eligible === false || !Array.isArray(program.grants))
|
|
105
|
+
return undefined;
|
|
106
|
+
|
|
107
|
+
let count = 0;
|
|
108
|
+
let expiresAt: string | undefined;
|
|
109
|
+
for (const grant of program.grants as RawResetGrant[]) {
|
|
110
|
+
const left = grant.resets_left;
|
|
111
|
+
if (typeof left !== "number" || !Number.isFinite(left) || left <= 0)
|
|
112
|
+
continue;
|
|
113
|
+
if (grant.paused === true) continue;
|
|
114
|
+
const ends =
|
|
115
|
+
typeof grant.ends_at === "string" ? Date.parse(grant.ends_at) : NaN;
|
|
116
|
+
if (Number.isFinite(ends) && ends <= now) continue;
|
|
117
|
+
count += Math.floor(left);
|
|
118
|
+
if (
|
|
119
|
+
typeof grant.ends_at === "string" &&
|
|
120
|
+
Number.isFinite(ends) &&
|
|
121
|
+
(!expiresAt || ends < Date.parse(expiresAt))
|
|
122
|
+
)
|
|
123
|
+
expiresAt = grant.ends_at;
|
|
124
|
+
}
|
|
125
|
+
if (count <= 0) return undefined;
|
|
126
|
+
return { count, ...(expiresAt ? { expiresAt } : {}) };
|
|
127
|
+
}
|
|
128
|
+
|
|
80
129
|
export function parsePlanUsage(
|
|
81
130
|
body: unknown,
|
|
82
131
|
subscriptionType?: string,
|
|
@@ -103,9 +152,16 @@ export function parsePlanUsage(
|
|
|
103
152
|
}
|
|
104
153
|
|
|
105
154
|
if (windows.length === 0) return undefined;
|
|
155
|
+
const banked = parseBankedResets(body);
|
|
106
156
|
return {
|
|
107
157
|
...(subscriptionType ? { plan: subscriptionType } : {}),
|
|
108
158
|
windows,
|
|
159
|
+
...(banked
|
|
160
|
+
? {
|
|
161
|
+
resetsAvailable: banked.count,
|
|
162
|
+
...(banked.expiresAt ? { resetsExpireAt: banked.expiresAt } : {}),
|
|
163
|
+
}
|
|
164
|
+
: {}),
|
|
109
165
|
fetchedAt: Date.now(),
|
|
110
166
|
};
|
|
111
167
|
}
|
|
@@ -119,6 +175,7 @@ async function load(): Promise<PlanUsage | undefined> {
|
|
|
119
175
|
headers: {
|
|
120
176
|
Authorization: `Bearer ${creds.accessToken}`,
|
|
121
177
|
"anthropic-beta": "oauth-2025-04-20",
|
|
178
|
+
"User-Agent": CLI_USER_AGENT,
|
|
122
179
|
},
|
|
123
180
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
124
181
|
});
|
package/src/bootstrap.ts
CHANGED
|
@@ -607,7 +607,10 @@ function initRecurringAgents(
|
|
|
607
607
|
* same delivery shape, so they are wired together.
|
|
608
608
|
*/
|
|
609
609
|
function initWakeSubsystems(config: TalonConfig): void {
|
|
610
|
-
initTriggers({
|
|
610
|
+
initTriggers({
|
|
611
|
+
execute: dispatcherExecute,
|
|
612
|
+
...(config.triggers ? { caps: config.triggers } : {}),
|
|
613
|
+
});
|
|
611
614
|
initAgents({
|
|
612
615
|
execute: dispatcherExecute,
|
|
613
616
|
...(config.agents ? { caps: config.agents } : {}),
|