talon-agent 3.16.1 → 3.18.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "3.16.1",
3
+ "version": "3.18.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
@@ -11,6 +11,7 @@ import { startUploadCleanup, stopUploadCleanup } from "./util/workspace.js";
11
11
  import { flushDatabase } from "./storage/db.js";
12
12
  import { getActiveCount } from "./core/engine/dispatcher.js";
13
13
  import { startPulseTimer, stopPulseTimer } from "./core/background/pulse.js";
14
+ import { stopPlanAlerts } from "./core/background/plan-alerts.js";
14
15
  import {
15
16
  startHeartbeatTimer,
16
17
  stopHeartbeatTimer,
@@ -179,6 +180,7 @@ async function gracefulShutdown(signal: string): Promise<void> {
179
180
  await awaitHeartbeat();
180
181
  });
181
182
  await shutdownStep("cron timer", stopCronTimer);
183
+ await shutdownStep("plan alerts", stopPlanAlerts);
182
184
  await shutdownStep("trigger prune timer", () => {
183
185
  if (triggerPruneTimer) clearInterval(triggerPruneTimer);
184
186
  triggerPruneTimer = null;
@@ -16,7 +16,9 @@ import {
16
16
  type ChatBackend,
17
17
  type BackgroundRunner,
18
18
  type ModelCatalog,
19
+ type UsageTelemetry,
19
20
  } from "../../core/agent-runtime/capabilities.js";
21
+ import { getPlanUsage as getCodexPlanUsage } from "./plan-usage.js";
20
22
 
21
23
  import { initCodexAgent, getCodexAuthInfo } from "./init.js";
22
24
  import { handleMessage as codexHandleMessage } from "./handler/index.js";
@@ -77,6 +79,12 @@ const codexFactory: BackendFactory = {
77
79
  // the per-chat thread lifecycle directly via `setSessionId` on
78
80
  // `storage/sessions.ts`. No `sessions` slot needed; `/reset`
79
81
  // clears the stored thread id through the storage path.
82
+ // No per-session snapshot to offer, but a ChatGPT-plan install can
83
+ // report its rate-limit windows.
84
+ const usage: UsageTelemetry = {
85
+ getPlanUsage: () => getCodexPlanUsage(),
86
+ };
87
+
80
88
  const backend = composeBackend({
81
89
  id: "codex",
82
90
  label: "Codex",
@@ -84,6 +92,7 @@ const codexFactory: BackendFactory = {
84
92
  chat,
85
93
  background,
86
94
  models,
95
+ usage,
87
96
  });
88
97
 
89
98
  return {
@@ -0,0 +1,160 @@
1
+ /**
2
+ * ChatGPT subscription rate-limit windows for the Codex backend.
3
+ *
4
+ * The Codex CLI reads these from an endpoint on the ChatGPT backend and
5
+ * caches the result in its session transcripts; `/status` renders that cache
6
+ * rather than re-fetching. Talon queries the endpoint directly so `/usage`
7
+ * reports the current state instead of whatever the last turn happened to
8
+ * see, using the OAuth token `codex login` already stored.
9
+ *
10
+ * Degrades to `undefined` for API-key installs (no plan to report), a
11
+ * missing or expired token, or any transport failure.
12
+ */
13
+
14
+ import { readFile } from "node:fs/promises";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { logWarn } from "../../util/log.js";
18
+ import type {
19
+ PlanUsage,
20
+ PlanWindow,
21
+ } from "../../core/agent-runtime/capabilities.js";
22
+
23
+ const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
24
+ const REQUEST_TIMEOUT_MS = 5_000;
25
+ const CACHE_TTL_MS = 60_000;
26
+
27
+ let cache: { value: PlanUsage; fetchedAt: number } | undefined;
28
+ let inFlight: Promise<PlanUsage | undefined> | undefined;
29
+
30
+ function authPath(): string {
31
+ const home = process.env.CODEX_HOME?.trim();
32
+ return home && home.length > 0
33
+ ? join(home, "auth.json")
34
+ : join(homedir(), ".codex", "auth.json");
35
+ }
36
+
37
+ interface CodexAuth {
38
+ accessToken: string;
39
+ accountId?: string;
40
+ }
41
+
42
+ async function readAuth(): Promise<CodexAuth | undefined> {
43
+ try {
44
+ const parsed = JSON.parse(await readFile(authPath(), "utf8")) as {
45
+ auth_mode?: string;
46
+ tokens?: { access_token?: string; account_id?: string };
47
+ };
48
+ const token = parsed.tokens?.access_token;
49
+ if (!token) return undefined;
50
+ return {
51
+ accessToken: token,
52
+ ...(parsed.tokens?.account_id
53
+ ? { accountId: parsed.tokens.account_id }
54
+ : {}),
55
+ };
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ interface RawWindow {
62
+ used_percent?: number;
63
+ limit_window_seconds?: number;
64
+ reset_at?: number;
65
+ }
66
+
67
+ /**
68
+ * Window label from its length. The plan exposes a weekly window and,
69
+ * historically, a shorter one; naming them by duration keeps the label
70
+ * right whichever windows the account actually has.
71
+ */
72
+ function windowLabel(seconds: number | undefined): string {
73
+ if (!seconds || !Number.isFinite(seconds)) return "limit";
74
+ const hours = Math.round(seconds / 3600);
75
+ if (hours % 24 === 0 && hours >= 24) return `${hours / 24}d`;
76
+ return `${hours}h`;
77
+ }
78
+
79
+ function toWindow(raw: RawWindow | null | undefined): PlanWindow | undefined {
80
+ if (!raw || typeof raw.used_percent !== "number") return undefined;
81
+ return {
82
+ label: windowLabel(raw.limit_window_seconds),
83
+ percent: Math.max(0, Math.min(100, Math.round(raw.used_percent))),
84
+ // `reset_at` is unix seconds; the shared shape speaks ISO.
85
+ ...(typeof raw.reset_at === "number" && raw.reset_at > 0
86
+ ? { resetsAt: new Date(raw.reset_at * 1000).toISOString() }
87
+ : {}),
88
+ };
89
+ }
90
+
91
+ export function parseCodexUsage(body: unknown): PlanUsage | undefined {
92
+ const data = body as {
93
+ plan_type?: string;
94
+ rate_limit?: {
95
+ primary_window?: RawWindow | null;
96
+ secondary_window?: RawWindow | null;
97
+ };
98
+ } | null;
99
+ const limit = data?.rate_limit;
100
+ if (!limit) return undefined;
101
+
102
+ const windows = [
103
+ toWindow(limit.primary_window),
104
+ toWindow(limit.secondary_window),
105
+ ].filter((w): w is PlanWindow => Boolean(w));
106
+ if (windows.length === 0) return undefined;
107
+
108
+ return {
109
+ ...(data?.plan_type ? { plan: data.plan_type } : {}),
110
+ windows,
111
+ fetchedAt: Date.now(),
112
+ };
113
+ }
114
+
115
+ async function load(): Promise<PlanUsage | undefined> {
116
+ const auth = await readAuth();
117
+ if (!auth) return undefined;
118
+
119
+ try {
120
+ const res = await fetch(USAGE_ENDPOINT, {
121
+ headers: {
122
+ Authorization: `Bearer ${auth.accessToken}`,
123
+ ...(auth.accountId ? { "chatgpt-account-id": auth.accountId } : {}),
124
+ Accept: "application/json",
125
+ },
126
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
127
+ });
128
+ if (!res.ok) {
129
+ logWarn("agent", `codex usage: endpoint returned ${res.status}`);
130
+ return undefined;
131
+ }
132
+ return parseCodexUsage(await res.json());
133
+ } catch (err) {
134
+ logWarn(
135
+ "agent",
136
+ `codex usage: ${err instanceof Error ? err.message : String(err)}`,
137
+ );
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Plan windows for `/usage`, cached for a minute. A failed refresh keeps
144
+ * serving the last known values — `fetchedAt` lets the caller age them.
145
+ */
146
+ export async function getPlanUsage(): Promise<PlanUsage | undefined> {
147
+ if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) return cache.value;
148
+
149
+ inFlight ??= load().finally(() => {
150
+ inFlight = undefined;
151
+ });
152
+ const loaded = await inFlight;
153
+ if (loaded) cache = { value: loaded, fetchedAt: loaded.fetchedAt };
154
+ return loaded ?? cache?.value;
155
+ }
156
+
157
+ export function resetCodexPlanUsageForTest(): void {
158
+ cache = undefined;
159
+ inFlight = undefined;
160
+ }
@@ -69,25 +69,8 @@ const kiloFactory: BackendFactory = {
69
69
  // through to `config.backendDefaults.kilo`.
70
70
  getDefaultModelId: () => undefined,
71
71
  getRawModelInfo: (id) => kiloGetModelInfo(id),
72
- getSettingsPresentation: async (m, options) => {
73
- // Kilo's internal helper returns the bare picker shape;
74
- // wrap into the canonical `ModelPickerResult`. Kilo doesn't
75
- // expose pagination or a free-tier filter so the result is
76
- // always page 1 of 1 with filter "all".
77
- const inner = await kiloGetSettingsPresentation(
78
- m,
79
- options?.callbackPrefix,
80
- );
81
- return {
82
- ...inner,
83
- view: "models" as const,
84
- page: 1,
85
- totalPages: 1,
86
- filter: "all" as const,
87
- freeCount: 0,
88
- totalCount: inner.modelButtons.length,
89
- };
90
- },
72
+ getSettingsPresentation: (m, options) =>
73
+ kiloGetSettingsPresentation(m, options),
91
74
  getProviders: () => kiloGetProviders(),
92
75
  getProviderModels: (p, pg, ps) => kiloGetProviderModels(p, pg, ps),
93
76
  formatModelError: (q, r) => kiloFormatModelError(q, r),
@@ -66,21 +66,8 @@ const opencodeFactory: BackendFactory = {
66
66
  // Catalog-driven backend with no canonical default.
67
67
  getDefaultModelId: () => undefined,
68
68
  getRawModelInfo: (id) => ocGetModelInfo(id),
69
- getSettingsPresentation: async (m, options) => {
70
- const inner = await ocGetSettingsPresentation(
71
- m,
72
- options?.callbackPrefix,
73
- );
74
- return {
75
- ...inner,
76
- view: "models" as const,
77
- page: 1,
78
- totalPages: 1,
79
- filter: "all" as const,
80
- freeCount: 0,
81
- totalCount: inner.modelButtons.length,
82
- };
83
- },
69
+ getSettingsPresentation: (m, options) =>
70
+ ocGetSettingsPresentation(m, options),
84
71
  getProviders: () => ocGetProviders(),
85
72
  getProviderModels: (p, pg, ps) => ocGetProviderModels(p, pg, ps),
86
73
  formatModelError: (q, r) => ocFormatModelError(q, r),
@@ -35,8 +35,40 @@ export interface RemotePresentationOptions {
35
35
  * true where the transport accepts arbitrary characters (Discord).
36
36
  */
37
37
  allowCallbackSeparators: boolean;
38
- /** How many quick-pick buttons to offer (Telegram 4, Discord 24). */
38
+ /**
39
+ * Default page size when the frontend doesn't ask for one — also the
40
+ * legacy quick-pick count (Telegram 4, Discord 24).
41
+ */
39
42
  quickPickLimit: number;
43
+ /**
44
+ * Flat model lists longer than this collapse into provider chips, so a
45
+ * catalog of several hundred models opens as a handful of providers
46
+ * rather than dozens of indistinguishable pages. Defaults to 60.
47
+ */
48
+ groupThreshold?: number;
49
+ }
50
+
51
+ /** Paging / filtering knobs, mirroring `ModelPickerOptions`. */
52
+ export interface RemotePickerOptions {
53
+ callbackPrefix?: string;
54
+ navCallbackPrefix?: string;
55
+ page?: number;
56
+ pageSize?: number;
57
+ filter?: "all" | "free";
58
+ provider?: string;
59
+ }
60
+
61
+ /** What the frontend needs to render one page of the picker. */
62
+ export interface RemotePickerResult {
63
+ modelButtons: Array<ModelButton>;
64
+ modelDetails: Array<string>;
65
+ view: "groups" | "models";
66
+ page: number;
67
+ totalPages: number;
68
+ filter: "all" | "free";
69
+ freeCount: number;
70
+ totalCount: number;
71
+ provider?: string;
40
72
  }
41
73
 
42
74
  function getAvailabilityLabel(model: RemoteModelCatalogEntry) {
@@ -69,8 +101,8 @@ export interface RemoteModelPresentation {
69
101
  ): Array<RemoteModelCatalogEntry>;
70
102
  getSettingsPresentation(
71
103
  activeModel: string,
72
- callbackPrefix?: string,
73
- ): Promise<{ modelButtons: Array<ModelButton>; modelDetails: Array<string> }>;
104
+ options?: RemotePickerOptions,
105
+ ): Promise<RemotePickerResult>;
74
106
  renderModelSummary(
75
107
  activeModel: string,
76
108
  defaultModel: string,
@@ -92,6 +124,7 @@ export function createRemoteModelPresentation(
92
124
  maxCallbackIdLength,
93
125
  allowCallbackSeparators,
94
126
  quickPickLimit,
127
+ groupThreshold = 60,
95
128
  } = options;
96
129
 
97
130
  function isCallbackSafeModelID(modelID: string): boolean {
@@ -140,30 +173,64 @@ export function createRemoteModelPresentation(
140
173
 
141
174
  async function getSettingsPresentation(
142
175
  activeModel: string,
143
- callbackPrefix = "settings:model:",
144
- ): Promise<{
145
- modelButtons: Array<ModelButton>;
146
- modelDetails: Array<string>;
147
- }> {
176
+ options: RemotePickerOptions = {},
177
+ ): Promise<RemotePickerResult> {
178
+ const callbackPrefix = options.callbackPrefix ?? "settings:model:";
179
+ const navPrefix = options.navCallbackPrefix ?? "settings:models";
148
180
  const catalog = await getCatalog();
149
181
  const current = getRemoteModelInfo(catalog, activeModel);
150
- const picks = getQuickPickModels(catalog, activeModel);
151
182
 
152
- const modelButtons: Array<ModelButton> = picks.map((m) => {
153
- const btnLabel =
154
- m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
155
- const txt = m.free ? `${btnLabel} ★` : btnLabel;
156
- const sel =
157
- current && m.id === current.id && m.providerID === current.providerID;
158
- return {
159
- text: sel ? `✓ ${txt}` : txt,
160
- callback_data: `${callbackPrefix}${m.id}`,
161
- };
162
- });
163
- modelButtons.push({
164
- text: "Reset",
165
- callback_data: `${callbackPrefix}reset`,
166
- });
183
+ const filter = options.filter === "free" ? "free" : "all";
184
+ const selectable = catalog.connectedModels.filter((m) =>
185
+ isCallbackSafeModelID(m.id),
186
+ );
187
+ const freeCount = selectable.filter((m) => m.free).length;
188
+ const scoped = selectable.filter(
189
+ (m) =>
190
+ (filter === "all" || m.free) &&
191
+ (!options.provider || m.providerID === options.provider),
192
+ );
193
+
194
+ // A remote catalog can run to hundreds of models, and only the first
195
+ // page's worth ever fit on screen — so offer the provider list as the
196
+ // way in when nothing narrower was asked for and the flat list would be
197
+ // unreadable anyway.
198
+ const providers = catalog.connectedProviders;
199
+ const asGroups =
200
+ !options.provider &&
201
+ providers.length > 1 &&
202
+ scoped.length > groupThreshold;
203
+
204
+ const pageSize = Math.max(1, options.pageSize ?? quickPickLimit);
205
+ const source = asGroups ? providers : scoped;
206
+ const totalPages = Math.max(1, Math.ceil(source.length / pageSize));
207
+ const page = Math.min(Math.max(1, options.page ?? 1), totalPages);
208
+ const slice = source.slice((page - 1) * pageSize, page * pageSize);
209
+
210
+ const modelButtons: Array<ModelButton> = asGroups
211
+ ? (slice as typeof providers).map((p) => ({
212
+ text: `${p.name} (${p.modelCount})`,
213
+ callback_data: `${navPrefix}:provider:${p.id}`,
214
+ }))
215
+ : (slice as typeof scoped).map((m) => {
216
+ const btnLabel =
217
+ m.id.length <= 20 ? m.id : m.name.length <= 20 ? m.name : m.id;
218
+ const txt = m.free ? `${btnLabel} ★` : btnLabel;
219
+ const sel =
220
+ current &&
221
+ m.id === current.id &&
222
+ m.providerID === current.providerID;
223
+ return {
224
+ text: sel ? `✓ ${txt}` : txt,
225
+ callback_data: `${callbackPrefix}${m.id}`,
226
+ };
227
+ });
228
+ if (!asGroups) {
229
+ modelButtons.push({
230
+ text: "Reset",
231
+ callback_data: `${callbackPrefix}reset`,
232
+ });
233
+ }
167
234
 
168
235
  const details: Array<string> = [];
169
236
  if (current) {
@@ -189,7 +256,17 @@ export function createRemoteModelPresentation(
189
256
  );
190
257
  }
191
258
  details.push("Hint: use /model <name> to switch.");
192
- return { modelButtons, modelDetails: details };
259
+ return {
260
+ modelButtons,
261
+ modelDetails: details,
262
+ view: asGroups ? "groups" : "models",
263
+ page,
264
+ totalPages,
265
+ filter,
266
+ freeCount,
267
+ totalCount: selectable.length,
268
+ ...(options.provider ? { provider: options.provider } : {}),
269
+ };
193
270
  }
194
271
 
195
272
  async function renderModelSummary(
@@ -19,6 +19,10 @@ import type {
19
19
  RemoteModelCatalogEntry,
20
20
  RemoteModelResolution,
21
21
  } from "./types.js";
22
+ import type {
23
+ RemotePickerOptions,
24
+ RemotePickerResult,
25
+ } from "./presentation.js";
22
26
 
23
27
  export interface RemoteModelProviderDeps {
24
28
  /** Human label — "OpenCode" / "Kilo" — used in error strings. */
@@ -36,8 +40,8 @@ export interface RemoteModelProviderDeps {
36
40
  formatUnavailableModel(model: RemoteModelCatalogEntry): string;
37
41
  getSettingsPresentation(
38
42
  activeModel: string,
39
- callbackPrefix?: string,
40
- ): Promise<{ modelButtons: ModelButton[]; modelDetails: string[] }>;
43
+ options?: RemotePickerOptions,
44
+ ): Promise<RemotePickerResult>;
41
45
  }
42
46
 
43
47
  export interface RemoteModelProvider {
@@ -45,8 +49,8 @@ export interface RemoteModelProvider {
45
49
  getModelInfo(id: string): Promise<UnifiedModelInfo | undefined>;
46
50
  getSettingsPresentation(
47
51
  activeModel: string,
48
- callbackPrefix?: string,
49
- ): Promise<{ modelButtons: ModelButton[]; modelDetails: string[] }>;
52
+ options?: RemotePickerOptions,
53
+ ): Promise<RemotePickerResult>;
50
54
  getProviders(): Promise<UnifiedProviderInfo[]>;
51
55
  getProviderModels(
52
56
  providerId: string,
@@ -118,8 +122,8 @@ export function createRemoteModelProvider(
118
122
  return entry ? toUnifiedModelInfo(entry) : undefined;
119
123
  },
120
124
 
121
- getSettingsPresentation(activeModel, callbackPrefix = "settings:model:") {
122
- return deps.getSettingsPresentation(activeModel, callbackPrefix);
125
+ getSettingsPresentation(activeModel, options) {
126
+ return deps.getSettingsPresentation(activeModel, options);
123
127
  },
124
128
 
125
129
  async getProviders() {
package/src/bootstrap.ts CHANGED
@@ -26,6 +26,7 @@ import { bus } from "./core/bus/index.js";
26
26
  import { appendToJournal } from "./storage/journal.js";
27
27
  import { initPulse, resetPulseTimer } from "./core/background/pulse.js";
28
28
  import { initCron } from "./core/background/cron.js";
29
+ import { initPlanAlerts } from "./core/background/plan-alerts.js";
29
30
  import {
30
31
  initTriggers,
31
32
  resumeAfterRestart as resumeTriggersAfterRestart,
@@ -420,6 +421,19 @@ export async function initBackendAndDispatcher(
420
421
  log("triggers", `resumeAfterRestart failed: ${err}`),
421
422
  );
422
423
 
424
+ initPlanAlerts({
425
+ sendMessage: async (chatId: number, text: string, stringId?: string) =>
426
+ resolveFrontendByNumericId(chatId, stringId, frontends).sendMessage(
427
+ chatId,
428
+ text,
429
+ ),
430
+ enabled: config.planAlerts,
431
+ threshold: config.planAlertThreshold,
432
+ chatId:
433
+ config.planAlertChatId ??
434
+ (config.adminUserId ? String(config.adminUserId) : undefined),
435
+ });
436
+
423
437
  // Soul — initialize the identity kernel singleton from config so the prompt
424
438
  // injection / dream hooks see the right enabled state. Off by default; a
425
439
  // failure here must never block startup.
package/src/cli/doctor.ts CHANGED
@@ -25,9 +25,19 @@ export async function runDoctor(): Promise<void> {
25
25
  config: hasConfigFile ? loadConfig() : undefined,
26
26
  hasConfigFile,
27
27
  });
28
- for (const check of report.checks) {
28
+ const print = (check: (typeof report.checks)[number]): void => {
29
29
  const detail = check.detail ? ` ${pc.dim(`(${check.detail})`)}` : "";
30
30
  console.log(` ${DOCTOR_ICONS[check.status]} ${check.label}${detail}`);
31
+ };
32
+ for (const check of report.checks.filter((c) => !c.inactive)) print(check);
33
+
34
+ // Configured-but-idle backends describe what a switch would run into,
35
+ // not the state of the running deployment.
36
+ const idle = report.checks.filter((c) => c.inactive);
37
+ if (idle.length > 0) {
38
+ console.log(`\n ${pc.bold("Other backends")}\n`);
39
+ for (const check of idle) print(check);
40
+ console.log();
31
41
  }
32
42
  // Native plane, one line per embedded module with provenance.
33
43
  for (const mod of report.native) {
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Plan rate-limit warnings.
3
+ *
4
+ * Off unless `planAlerts` is enabled. Polls the subscription's windows on a
5
+ * timer and messages the admin chat the first time one crosses the
6
+ * threshold, so a long background run doesn't walk into the ceiling
7
+ * unannounced.
8
+ *
9
+ * One message per window per reset cycle: the reset timestamp identifies the
10
+ * cycle, and a window that drops back under the threshold re-arms (which is
11
+ * also what covers windows the plan reports no reset for).
12
+ */
13
+
14
+ import { getPooledBackend } from "../engine/backend-controller/index.js";
15
+ import { log, logWarn } from "../../util/log.js";
16
+ import { formatSmartTimestamp } from "../../util/time.js";
17
+
18
+ const CHECK_INTERVAL_MS = 5 * 60_000;
19
+
20
+ export interface PlanAlertDeps {
21
+ sendMessage: (
22
+ chatId: number,
23
+ text: string,
24
+ stringId?: string,
25
+ ) => Promise<void>;
26
+ enabled: boolean;
27
+ threshold: number;
28
+ /** Chat the warnings go to. Callers resolve the admin default. */
29
+ chatId: string | undefined;
30
+ }
31
+
32
+ let deps: PlanAlertDeps | undefined;
33
+ let timer: ReturnType<typeof setInterval> | null = null;
34
+
35
+ /** Window label → the reset cycle it was last warned about. */
36
+ const warned = new Map<string, string>();
37
+
38
+ export function initPlanAlerts(d: PlanAlertDeps): void {
39
+ deps = d;
40
+ stopPlanAlerts();
41
+ warned.clear();
42
+ if (!d.enabled) return;
43
+ if (!d.chatId) {
44
+ logWarn(
45
+ "bot",
46
+ "planAlerts is on but no chat to warn — set planAlertChatId or adminUserId",
47
+ );
48
+ return;
49
+ }
50
+ timer = setInterval(() => {
51
+ void checkPlanAlerts();
52
+ }, CHECK_INTERVAL_MS);
53
+ timer.unref?.();
54
+ log("bot", `Plan alerts: on (threshold ${d.threshold}%)`);
55
+ }
56
+
57
+ export function stopPlanAlerts(): void {
58
+ if (timer) {
59
+ clearInterval(timer);
60
+ timer = null;
61
+ }
62
+ }
63
+
64
+ function warningText(
65
+ label: string,
66
+ percent: number,
67
+ resetsAt: string | undefined,
68
+ ): string {
69
+ const ts = resetsAt ? Date.parse(resetsAt) : NaN;
70
+ const reset = Number.isFinite(ts)
71
+ ? `, resets ${formatSmartTimestamp(Math.round(ts / 60_000) * 60_000)}`
72
+ : "";
73
+ return `⚠️ Plan limit — ${label} at ${percent}% used${reset}.`;
74
+ }
75
+
76
+ /** One pass. Exported so the timer isn't the only way to drive it. */
77
+ export async function checkPlanAlerts(): Promise<void> {
78
+ const d = deps;
79
+ if (!d?.enabled || !d.chatId) return;
80
+
81
+ const usage = await getPooledBackend("claude")
82
+ ?.usage?.getPlanUsage?.()
83
+ .catch(() => undefined);
84
+ if (!usage) return;
85
+
86
+ for (const window of usage.windows) {
87
+ // An unwarned window that is back under the threshold re-arms.
88
+ if (window.percent < d.threshold) {
89
+ warned.delete(window.label);
90
+ continue;
91
+ }
92
+ const cycle = window.resetsAt ?? "";
93
+ if (warned.get(window.label) === cycle) continue;
94
+ warned.set(window.label, cycle);
95
+
96
+ const text = warningText(window.label, window.percent, window.resetsAt);
97
+ try {
98
+ await d.sendMessage(Number(d.chatId), text, d.chatId);
99
+ log("bot", `Plan alert sent: ${window.label} at ${window.percent}%`);
100
+ } catch (err) {
101
+ // Keep it marked as warned — a frontend that can't deliver now won't
102
+ // deliver on the next tick either, and retrying would spam on recovery.
103
+ logWarn(
104
+ "bot",
105
+ `Plan alert delivery failed: ${err instanceof Error ? err.message : err}`,
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ export function resetPlanAlertsForTest(): void {
112
+ stopPlanAlerts();
113
+ deps = undefined;
114
+ warned.clear();
115
+ }