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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "5.10.0",
3
+ "version": "5.12.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
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
- const restoreReport = await bootPhase("staged restore", applyStagedRestore);
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 bootPhase("bootstrap", () => bootstrap());
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", (reason) => {
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 "../../util/log.js";
18
+ import { logWarn } from "../../../util/log.js";
19
19
  import type {
20
20
  PlanUsage,
21
21
  PlanWindow,
22
- } from "../../core/agent-runtime/capabilities.js";
23
-
24
- const USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
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({ execute: dispatcherExecute });
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 } : {}),