talon-agent 3.16.1 → 3.17.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.17.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
+ }
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
+ }
@@ -29,6 +29,12 @@ export interface DoctorCheck {
29
29
  * starts but a backend won't work.
30
30
  */
31
31
  issue?: boolean;
32
+ /**
33
+ * A backend that is configured but not serving chats. Renderers group
34
+ * these away from the environment so a handful of idle providers can't
35
+ * bury the checks that describe the running deployment.
36
+ */
37
+ inactive?: boolean;
32
38
  }
33
39
 
34
40
  /** One embedded native module: provenance plus a live self-test result. */
@@ -59,6 +65,8 @@ export interface DoctorReport {
59
65
  export interface DoctorConfigSlice {
60
66
  frontend: string | string[];
61
67
  backend?: string;
68
+ /** Backends config exposes. Empty / absent means every registered one. */
69
+ enabledBackends?: string[];
62
70
  model?: string;
63
71
  heartbeatModel?: string;
64
72
  heartbeatBackend?: string;
@@ -284,12 +292,55 @@ async function checkClaudeConfiguredModels(
284
292
  return checks;
285
293
  }
286
294
 
287
- /** Binary / auth checks for the active backend only. */
295
+ /** Every backend id doctor knows how to inspect. */
296
+ const KNOWN_BACKENDS = [
297
+ "claude",
298
+ "codex",
299
+ "kilo",
300
+ "opencode",
301
+ "openai-agents",
302
+ ] as const;
303
+
304
+ /**
305
+ * Binary / auth checks across every backend the config exposes.
306
+ *
307
+ * Only the active one counts toward the issue total; the rest are reported
308
+ * so a switch doesn't have to be the thing that discovers a backend can't
309
+ * run. That distinction matters now that a chat can be rebound at runtime —
310
+ * a report of all-green while two backends are one click from failing is
311
+ * worse than no report.
312
+ */
288
313
  async function checkBackend(
289
314
  config: DoctorConfigSlice | undefined,
315
+ ): Promise<DoctorCheck[]> {
316
+ const active = config?.backend ?? "claude";
317
+ const exposed = config?.enabledBackends?.length
318
+ ? config.enabledBackends
319
+ : [...KNOWN_BACKENDS];
320
+
321
+ const checks = await checkOneBackend(active, config, true);
322
+ for (const id of exposed) {
323
+ if (id === active || !KNOWN_BACKENDS.includes(id as never)) continue;
324
+ // An idle backend's missing binary is a heads-up, not a fault of this
325
+ // deployment: downgrade it and keep it out of the issue count.
326
+ for (const check of await checkOneBackend(id, config, false)) {
327
+ checks.push({
328
+ ...check,
329
+ status: check.status === "fail" ? "warn" : check.status,
330
+ issue: false,
331
+ inactive: true,
332
+ });
333
+ }
334
+ }
335
+ return checks;
336
+ }
337
+
338
+ async function checkOneBackend(
339
+ backend: string,
340
+ config: DoctorConfigSlice | undefined,
341
+ isActive: boolean,
290
342
  ): Promise<DoctorCheck[]> {
291
343
  const checks: DoctorCheck[] = [];
292
- const backend = config?.backend ?? "claude";
293
344
 
294
345
  if (backend === "claude") {
295
346
  if (config?.claudeBinary) {
@@ -313,7 +364,9 @@ async function checkBackend(
313
364
  : { label: "Claude Code not found", status: "fail" },
314
365
  );
315
366
  }
316
- checks.push(...(await checkClaudeConfiguredModels(config)));
367
+ // Model resolution spawns a probe — worth it for the backend actually
368
+ // serving chats, wasteful for one nobody is using.
369
+ if (isActive) checks.push(...(await checkClaudeConfiguredModels(config)));
317
370
  } else if (backend === "codex") {
318
371
  if (!binaryOnPath("codex")) {
319
372
  checks.push({
@@ -349,11 +402,21 @@ async function checkBackend(
349
402
  });
350
403
  }
351
404
  } else if (backend === "kilo" || backend === "opencode") {
352
- // Bundled as npm deps — no external binary to check.
353
- checks.push({
354
- label: `${backend === "kilo" ? "Kilo" : "OpenCode"} SDK bundled`,
355
- status: "ok",
356
- });
405
+ // The SDK ships as an npm dep but only talks to a server it spawns from
406
+ // the CLI of the same name (`cross-spawn` → PATH lookup). A present
407
+ // package with an absent binary fails at the first turn with a bare
408
+ // ENOENT, so check what actually gets executed.
409
+ const label = backend === "kilo" ? "Kilo" : "OpenCode";
410
+ checks.push(
411
+ binaryOnPath(backend)
412
+ ? { label: `${label} CLI installed`, status: "ok" }
413
+ : {
414
+ label: `${label} CLI not found`,
415
+ status: "fail",
416
+ detail: `the ${label} SDK spawns \`${backend}\` — install it or this backend cannot start`,
417
+ issue: true,
418
+ },
419
+ );
357
420
  } else if (backend === "openai-agents") {
358
421
  checks.push({ label: "OpenAI Agents SDK bundled", status: "ok" });
359
422
  const hasEnvKey = Boolean(process.env.OPENAI_API_KEY);
@@ -68,6 +68,7 @@ import {
68
68
  DISCORD_MAX_TEXT,
69
69
  } from "../formatting.js";
70
70
  import { chatIdFromInteraction, type ComponentInteraction } from "./shared.js";
71
+ import { metricsViewRow, renderMetricsView } from "../commands/admin.js";
71
72
 
72
73
  export async function handleComponentInteraction(
73
74
  interaction: ComponentInteraction,
@@ -352,6 +353,21 @@ export async function handleComponentInteraction(
352
353
  return;
353
354
  }
354
355
 
356
+ // ── /metrics today ↔ all-time toggle ────────────────────────────────────
357
+ if (customId === "metrics:today" || customId === "metrics:all") {
358
+ const view = customId === "metrics:all" ? "all" : "today";
359
+ const messages = renderMetricsView(view);
360
+ try {
361
+ await interaction.update({
362
+ content: messages[0]!,
363
+ components: [metricsViewRow(view).toJSON()],
364
+ });
365
+ } catch {
366
+ /* ignore */
367
+ }
368
+ return;
369
+ }
370
+
355
371
  // ── AI-generated buttons (prefixed `ai:`) → forward to agent ──────────
356
372
  if (customId.startsWith("ai:")) {
357
373
  await forwardToAgent(interaction, gateway);
@@ -3,16 +3,37 @@
3
3
  * All gate on `isAdmin`.
4
4
  */
5
5
 
6
- import { type ChatInputCommandInteraction, MessageFlags } from "discord.js";
6
+ import {
7
+ type ChatInputCommandInteraction,
8
+ ActionRowBuilder,
9
+ ButtonBuilder,
10
+ ButtonStyle,
11
+ MessageFlags,
12
+ } from "discord.js";
7
13
  import type { TalonConfig } from "../../../util/config.js";
8
14
  import type { Gateway } from "../../../core/engine/gateway.js";
9
15
  import { respawnSelf } from "../../../util/respawn.js";
10
16
  import { forceDream } from "../../../core/background/dream.js";
11
- import { formatDuration, renderMetricsMessages } from "../helpers.js";
17
+ import {
18
+ formatDuration,
19
+ renderMetricsMessages,
20
+ renderDoctorMessages,
21
+ } from "../helpers.js";
22
+ import { collectDoctorReport } from "../../../core/doctor.js";
23
+ import { getSoul } from "../../../core/soul/service.js";
12
24
  import { getMetrics, getTodayMetrics } from "../../../storage/metrics.js";
13
25
  import { handleAdminSubcommand } from "../admin.js";
14
26
  import { isAdmin } from "../handlers/index.js";
15
- import { suppressMentions, DISCORD_MAX_TEXT } from "../formatting.js";
27
+ import {
28
+ suppressMentions,
29
+ DISCORD_MAX_TEXT,
30
+ safeSlice,
31
+ escapeForCodeBlock,
32
+ } from "../formatting.js";
33
+ import {
34
+ getRepoRoot,
35
+ runSelfUpdate,
36
+ } from "../../../core/update/self-update.js";
16
37
  import { reply } from "./shared.js";
17
38
 
18
39
  export async function handleRestart(
@@ -34,18 +55,112 @@ export async function handleMetrics(
34
55
  return;
35
56
  }
36
57
  // Ephemeral — admin counters (token usage, latencies, errors) shouldn't leak
37
- // into a public channel where non-admins can read them.
38
- const messages = [
39
- ...renderMetricsMessages(getMetrics()),
40
- ...renderMetricsMessages(
41
- getTodayMetrics(),
42
- undefined,
43
- "📊 Metrics — today (UTC)",
44
- ),
45
- ];
46
- for (const m of messages) {
47
- await reply(i, m, true);
58
+ // into a public channel where non-admins can read them. Both views are one
59
+ // button apart rather than two bursts of messages.
60
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
61
+ const messages = renderMetricsMessages(
62
+ getTodayMetrics(),
63
+ undefined,
64
+ "📊 Metrics — today (UTC)",
65
+ );
66
+ await i.editReply({
67
+ content: messages[0]!,
68
+ components: [metricsViewRow("today").toJSON()],
69
+ });
70
+ for (const extra of messages.slice(1)) {
71
+ await i.followUp({ content: extra, flags: MessageFlags.Ephemeral });
72
+ }
73
+ }
74
+
75
+ /** Today / all-time toggle under the metrics panel. */
76
+ export function metricsViewRow(
77
+ view: MetricsView,
78
+ ): ActionRowBuilder<ButtonBuilder> {
79
+ return new ActionRowBuilder<ButtonBuilder>().addComponents(
80
+ new ButtonBuilder()
81
+ .setCustomId("metrics:today")
82
+ .setLabel("Today")
83
+ .setStyle(view === "today" ? ButtonStyle.Primary : ButtonStyle.Secondary)
84
+ .setDisabled(view === "today"),
85
+ new ButtonBuilder()
86
+ .setCustomId("metrics:all")
87
+ .setLabel("All time")
88
+ .setStyle(view === "all" ? ButtonStyle.Primary : ButtonStyle.Secondary)
89
+ .setDisabled(view === "all"),
90
+ );
91
+ }
92
+
93
+ export type MetricsView = "today" | "all";
94
+
95
+ /** Panel body for one view — shared by the command and the toggle. */
96
+ export function renderMetricsView(view: MetricsView): string[] {
97
+ return view === "today"
98
+ ? renderMetricsMessages(
99
+ getTodayMetrics(),
100
+ undefined,
101
+ "📊 Metrics — today (UTC)",
102
+ )
103
+ : renderMetricsMessages(getMetrics(), undefined, "📊 Metrics — all time");
104
+ }
105
+
106
+ export async function handleDoctor(
107
+ i: ChatInputCommandInteraction,
108
+ config: TalonConfig,
109
+ ): Promise<void> {
110
+ if (!isAdmin(i.user.id)) {
111
+ await reply(i, "Not authorized.", true);
112
+ return;
113
+ }
114
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
115
+ await i.editReply("🩺 Running checks...");
116
+ try {
117
+ // Same checks as `talon doctor` — config exists by definition when the
118
+ // bot is processing this command.
119
+ const report = await collectDoctorReport({ config, hasConfigFile: true });
120
+ const messages = renderDoctorMessages(report);
121
+ await i.editReply(messages[0]!);
122
+ for (const extra of messages.slice(1))
123
+ await i.followUp({
124
+ content: extra,
125
+ flags: MessageFlags.Ephemeral,
126
+ });
127
+ } catch (err) {
128
+ const msg = err instanceof Error ? err.message : String(err);
129
+ await i.editReply(`🩺 Doctor failed: ${msg}`);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * /soul — read-only introspection of the compiled identity. `action:dream`
135
+ * runs the organic maintenance pass and is admin-only. Inert while the soul
136
+ * is disabled, so it is safe to ship dormant.
137
+ */
138
+ export async function handleSoul(
139
+ i: ChatInputCommandInteraction,
140
+ ): Promise<void> {
141
+ const soul = getSoul();
142
+ if (!soul.enabled) {
143
+ await reply(
144
+ i,
145
+ "Soul is disabled (set TALON_SOUL_ENABLED to enable).",
146
+ true,
147
+ );
148
+ return;
149
+ }
150
+ if (i.options.getString("action") === "dream") {
151
+ if (!isAdmin(i.user.id)) {
152
+ await reply(i, "Not authorized.", true);
153
+ return;
154
+ }
155
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
156
+ await i.editReply("🧠 Soul dreaming...");
157
+ soul
158
+ .dream()
159
+ .then(() => i.editReply("🧠 Soul dream complete."))
160
+ .catch(() => undefined);
161
+ return;
48
162
  }
163
+ await reply(i, suppressMentions(soul.introspect()), true);
49
164
  }
50
165
 
51
166
  export async function handleDream(
@@ -71,6 +186,65 @@ export async function handleDream(
71
186
  });
72
187
  }
73
188
 
189
+ /**
190
+ * /update — pull, reinstall, run setup, restart. Only reachable on developer
191
+ * builds running from a git checkout; the command is not registered at all
192
+ * otherwise (see buildCommandDefinitions).
193
+ */
194
+ export async function handleUpdate(
195
+ i: ChatInputCommandInteraction,
196
+ config: TalonConfig,
197
+ ): Promise<void> {
198
+ if (!isAdmin(i.user.id)) {
199
+ await reply(i, "Not authorized.", true);
200
+ return;
201
+ }
202
+ const repoRoot = config.devBuild ? getRepoRoot() : null;
203
+ if (!repoRoot) {
204
+ await reply(i, "Update is only available on developer builds.", true);
205
+ return;
206
+ }
207
+ const remote = config.update?.remote ?? "origin";
208
+ const branch = config.update?.branch ?? "main";
209
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
210
+ await i.editReply(`⏳ Updating from \`${remote}/${branch}\`…`);
211
+ const edit = (text: string) => i.editReply(safeSlice(text, DISCORD_MAX_TEXT));
212
+
213
+ // Fire-and-forget so the gateway keeps processing other interactions.
214
+ runSelfUpdate({
215
+ remote,
216
+ branch,
217
+ setup: config.update?.setup,
218
+ repoRoot,
219
+ })
220
+ .then(async (res) => {
221
+ if (!res.ok) {
222
+ const tail = res.steps[res.steps.length - 1]?.output ?? "";
223
+ await edit(
224
+ `⚠️ Update failed: ${res.error ?? "unknown error"}` +
225
+ (tail
226
+ ? `\n\`\`\`\n${escapeForCodeBlock(tail.slice(-1200))}\n\`\`\``
227
+ : ""),
228
+ );
229
+ return;
230
+ }
231
+ if (!res.changed) {
232
+ await edit(
233
+ `✅ Already up to date at \`${res.before ?? "?"}\` — no restart needed.`,
234
+ );
235
+ return;
236
+ }
237
+ await edit(
238
+ `✅ Updated \`${res.before ?? "?"}\` → \`${res.after ?? "?"}\`. ♻️ Restarting…`,
239
+ );
240
+ respawnSelf("discord /update");
241
+ })
242
+ .catch(async (err: unknown) => {
243
+ const msg = err instanceof Error ? err.message : String(err);
244
+ await edit(`⚠️ Update crashed: ${msg}`);
245
+ });
246
+ }
247
+
74
248
  export async function handleAdmin(
75
249
  i: ChatInputCommandInteraction,
76
250
  config: TalonConfig,
@@ -15,8 +15,9 @@
15
15
  import { type Client, REST, Routes, SlashCommandBuilder } from "discord.js";
16
16
  import type { TalonConfig } from "../../../util/config.js";
17
17
  import { log, logError, logWarn } from "../../../util/log.js";
18
+ import { getRepoRoot } from "../../../core/update/self-update.js";
18
19
 
19
- export function buildCommandDefinitions(): unknown[] {
20
+ export function buildCommandDefinitions(devBuild = false): unknown[] {
20
21
  return [
21
22
  new SlashCommandBuilder()
22
23
  .setName("start")
@@ -97,6 +98,40 @@ export function buildCommandDefinitions(): unknown[] {
97
98
  .setName("plugins")
98
99
  .setDescription("List loaded plugins")
99
100
  .toJSON(),
101
+ new SlashCommandBuilder()
102
+ .setName("usage")
103
+ .setDescription("Plan limits across every backend")
104
+ .toJSON(),
105
+ new SlashCommandBuilder()
106
+ .setName("doctor")
107
+ .setDescription("Environment and native-module health (admin)")
108
+ .toJSON(),
109
+ new SlashCommandBuilder()
110
+ .setName("mesh")
111
+ .setDescription("Ping and list mesh devices")
112
+ .toJSON(),
113
+ new SlashCommandBuilder()
114
+ .setName("soul")
115
+ .setDescription("Inspect the compiled identity")
116
+ .addStringOption((o) =>
117
+ o
118
+ .setName("action")
119
+ .setDescription("Leave empty to introspect")
120
+ .setRequired(false)
121
+ .addChoices({ name: "dream", value: "dream" }),
122
+ )
123
+ .toJSON(),
124
+ // /update only exists on developer builds running from a git checkout —
125
+ // a packaged binary has no source tree to pull into, so the command is
126
+ // never registered there (same gate as the Telegram handler).
127
+ ...(devBuild && getRepoRoot()
128
+ ? [
129
+ new SlashCommandBuilder()
130
+ .setName("update")
131
+ .setDescription("Pull latest, reinstall, restart (admin)")
132
+ .toJSON(),
133
+ ]
134
+ : []),
100
135
  new SlashCommandBuilder()
101
136
  .setName("admin")
102
137
  .setDescription("Admin operations (admin only)")
@@ -128,7 +163,7 @@ export async function registerCommandsForGuilds(
128
163
  ): Promise<void> {
129
164
  const dc = config.discord!;
130
165
  const rest = new REST({ version: "10" }).setToken(dc.botToken);
131
- const definitions = buildCommandDefinitions();
166
+ const definitions = buildCommandDefinitions(config.devBuild);
132
167
 
133
168
  // Step 1: clear or set global commands depending on DM setting.
134
169
  try {
@@ -7,8 +7,16 @@ import {
7
7
  type Client,
8
8
  MessageFlags,
9
9
  } from "discord.js";
10
- import { formatDuration } from "../helpers.js";
10
+ import {
11
+ formatDuration,
12
+ renderMeshReport,
13
+ renderUsageMessage,
14
+ } from "../helpers.js";
15
+ import type { TalonConfig } from "../../../util/config.js";
16
+ import { collectPlanUsage } from "../../shared/plan-usage-report.js";
11
17
  import { getLoadedPlugins } from "../../../core/plugin/index.js";
18
+ import { getMeshService } from "../../../core/mesh/index.js";
19
+ import type { MeshPingResult } from "../../../core/mesh/service.js";
12
20
  import { reply } from "./shared.js";
13
21
 
14
22
  export async function handleStart(
@@ -67,6 +75,35 @@ export async function handleHelp(
67
75
  );
68
76
  }
69
77
 
78
+ /**
79
+ * /usage — plan limits across every exposed backend, not just this chat's.
80
+ * Not admin-gated: it says how close the shared account is to a wall, which
81
+ * is exactly what a user hitting one needs to know.
82
+ */
83
+ export async function handleUsage(
84
+ i: ChatInputCommandInteraction,
85
+ config: TalonConfig,
86
+ ): Promise<void> {
87
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
88
+ const entries = await collectPlanUsage(config);
89
+ await i.editReply(renderUsageMessage(entries));
90
+ }
91
+
92
+ export async function handleMesh(
93
+ i: ChatInputCommandInteraction,
94
+ ): Promise<void> {
95
+ await i.deferReply({ flags: MessageFlags.Ephemeral });
96
+ await i.editReply("Pinging mesh devices…");
97
+ let results: MeshPingResult[];
98
+ try {
99
+ results = await getMeshService().pingAll();
100
+ } catch {
101
+ await i.editReply("Could not reach the mesh service.");
102
+ return;
103
+ }
104
+ await i.editReply(renderMeshReport(results));
105
+ }
106
+
70
107
  export async function handlePing(
71
108
  i: ChatInputCommandInteraction,
72
109
  ): Promise<void> {
@@ -22,7 +22,14 @@ import {
22
22
  handleModalSubmit,
23
23
  } from "../callbacks/index.js";
24
24
  import { chatIdFromInteraction, reply, client } from "./shared.js";
25
- import { handleStart, handleHelp, handlePing, handlePlugins } from "./info.js";
25
+ import {
26
+ handleStart,
27
+ handleHelp,
28
+ handlePing,
29
+ handlePlugins,
30
+ handleMesh,
31
+ handleUsage,
32
+ } from "./info.js";
26
33
  import { handleReset, handleStatus } from "./session.js";
27
34
  import {
28
35
  handleModel,
@@ -35,6 +42,9 @@ import {
35
42
  handleMetrics,
36
43
  handleDream,
37
44
  handleAdmin,
45
+ handleDoctor,
46
+ handleSoul,
47
+ handleUpdate,
38
48
  } from "./admin.js";
39
49
 
40
50
  export function registerInteractionRouter(
@@ -141,6 +151,16 @@ async function routeSlashCommand(
141
151
  return handleDream(interaction);
142
152
  case "plugins":
143
153
  return handlePlugins(interaction);
154
+ case "usage":
155
+ return handleUsage(interaction, config);
156
+ case "doctor":
157
+ return handleDoctor(interaction, config);
158
+ case "mesh":
159
+ return handleMesh(interaction);
160
+ case "soul":
161
+ return handleSoul(interaction);
162
+ case "update":
163
+ return handleUpdate(interaction, config);
144
164
  case "admin":
145
165
  return handleAdmin(interaction, config, gateway);
146
166
  default:
@@ -8,10 +8,18 @@
8
8
  */
9
9
 
10
10
  import { REASONING_LEVEL_DESCRIPTIONS } from "../../core/models/reasoning-levels.js";
11
- import { DISCORD_MAX_TEXT, DISCORD_SAFE_RESERVE } from "./formatting.js";
11
+ import {
12
+ DISCORD_MAX_TEXT,
13
+ DISCORD_SAFE_RESERVE,
14
+ splitMessage,
15
+ } from "./formatting.js";
16
+ import type { DoctorReport } from "../../core/doctor.js";
17
+ import type { MeshPingResult } from "../../core/mesh/service.js";
18
+ import type { BackendUsageEntry } from "../shared/plan-usage-report.js";
12
19
  import {
13
20
  DEFAULT_PULSE_INTERVAL_MS,
14
21
  formatDuration,
22
+ formatBytes,
15
23
  formatModelLabel,
16
24
  } from "../shared/format.js";
17
25
 
@@ -157,6 +165,145 @@ export function renderMetricsMessages(
157
165
  return chunks;
158
166
  }
159
167
 
168
+ const DOCTOR_ICONS: Record<string, string> = {
169
+ ok: "✅",
170
+ warn: "⚠️",
171
+ fail: "❌",
172
+ info: "▫️",
173
+ };
174
+
175
+ /**
176
+ * Render a DoctorReport as Discord markdown, split to fit the message cap.
177
+ * Same data as `talon doctor` and Telegram's /doctor.
178
+ */
179
+ export function renderDoctorMessages(
180
+ report: DoctorReport,
181
+ maxLen = DEFAULT_METRICS_MESSAGE_MAX,
182
+ ): string[] {
183
+ const lines = ["**🩺 Talon Doctor**", "", "**Environment**"];
184
+
185
+ const render = (check: DoctorReport["checks"][number]): string =>
186
+ `${DOCTOR_ICONS[check.status]} ${check.label}${check.detail ? ` (${check.detail})` : ""}`;
187
+
188
+ for (const check of report.checks.filter((c) => !c.inactive)) {
189
+ lines.push(render(check));
190
+ }
191
+
192
+ // Configured-but-idle backends get their own block: they describe what a
193
+ // switch would run into, not the state of the running deployment.
194
+ const idle = report.checks.filter((c) => c.inactive);
195
+ if (idle.length > 0) {
196
+ lines.push("", "**Other backends**", ...idle.map(render));
197
+ }
198
+
199
+ lines.push("", "**Native modules**");
200
+ for (const mod of report.native) {
201
+ const size =
202
+ mod.sizeBytes !== undefined ? ` · ${formatBytes(mod.sizeBytes)}` : "";
203
+ const note = mod.note ? ` (${mod.note})` : "";
204
+ lines.push(
205
+ `${mod.ok ? DOCTOR_ICONS.ok : DOCTOR_ICONS.fail} \`${mod.name}\` — ${mod.language} → ${mod.target}${size}${note}`,
206
+ );
207
+ }
208
+
209
+ lines.push(
210
+ "",
211
+ "**Process**",
212
+ `Uptime ${formatDuration(process.uptime() * 1000)} · PID ${process.pid} · Node ${process.versions.node}`,
213
+ "",
214
+ report.issues === 0
215
+ ? `${DOCTOR_ICONS.ok} All checks passed.`
216
+ : `${DOCTOR_ICONS.warn} ${report.issues} issue(s) found.`,
217
+ );
218
+
219
+ return splitMessage(lines.join("\n"), maxLen);
220
+ }
221
+
222
+ function meshDeviceLine(r: MeshPingResult, now: number): string {
223
+ const d = r.device;
224
+ const bits: string[] = [d.platform];
225
+ if (r.reachable && typeof r.latencyMs === "number") {
226
+ bits.push(`${r.latencyMs} ms`);
227
+ } else if (d.online && r.error) {
228
+ bits.push(r.error);
229
+ } else if (!d.online) {
230
+ bits.push(`last seen ${formatDuration(now - d.lastSeen)} ago`);
231
+ }
232
+ if (typeof d.battery === "number") {
233
+ bits.push(`${d.battery}%${d.charging ? " charging" : ""}`);
234
+ }
235
+ return ` **${d.name}** — ${bits.join(" · ")}`;
236
+ }
237
+
238
+ /**
239
+ * Render the /mesh fleet report as Discord markdown. Devices group under a
240
+ * state heading; empty groups are omitted.
241
+ */
242
+ export function renderMeshReport(
243
+ results: MeshPingResult[],
244
+ now = Date.now(),
245
+ ): string {
246
+ if (results.length === 0) {
247
+ return "**Mesh**\n\n_No devices have registered yet._";
248
+ }
249
+
250
+ const responding = results
251
+ .filter((r) => r.reachable)
252
+ .sort((a, b) => (a.latencyMs ?? Infinity) - (b.latencyMs ?? Infinity));
253
+ const unreachable = results
254
+ .filter((r) => !r.reachable && r.device.online)
255
+ .sort((a, b) => a.device.name.localeCompare(b.device.name));
256
+ const offline = results
257
+ .filter((r) => !r.reachable && !r.device.online)
258
+ .sort((a, b) => b.device.lastSeen - a.device.lastSeen);
259
+
260
+ const summary = [
261
+ `${results.length} device${results.length === 1 ? "" : "s"}`,
262
+ `${responding.length} responding`,
263
+ ...(unreachable.length > 0 ? [`${unreachable.length} unreachable`] : []),
264
+ ...(offline.length > 0 ? [`${offline.length} offline`] : []),
265
+ ].join(" · ");
266
+
267
+ const lines = ["**Mesh**", summary];
268
+ const section = (title: string, entries: MeshPingResult[]): void => {
269
+ if (entries.length === 0) return;
270
+ lines.push(
271
+ "",
272
+ `**${title}**`,
273
+ ...entries.map((r) => meshDeviceLine(r, now)),
274
+ );
275
+ };
276
+ section("Responding", responding);
277
+ section("Unreachable", unreachable);
278
+ section("Offline", offline);
279
+
280
+ return lines.join("\n");
281
+ }
282
+
283
+ /** Render the `/usage` report — one block per exposed backend. */
284
+ export function renderUsageMessage(entries: BackendUsageEntry[]): string {
285
+ const lines = ["**📊 Plan usage**"];
286
+
287
+ for (const entry of entries) {
288
+ const name = entry.label || entry.id;
289
+ if (!entry.plan) {
290
+ lines.push("", `**${name}** — _${entry.note ?? ""}_`);
291
+ continue;
292
+ }
293
+ const age = entry.plan.ageLabel ? ` *(${entry.plan.ageLabel})*` : "";
294
+ const plan = entry.plan.plan ? ` · ${entry.plan.plan}` : "";
295
+ lines.push("", `**${name}**${plan}${age}`);
296
+ for (const w of entry.plan.windows) {
297
+ const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
298
+ lines.push(
299
+ ` \`${w.label.padEnd(6)}${w.bar} ${String(w.percent).padStart(3)}%\`${reset}`,
300
+ );
301
+ }
302
+ }
303
+
304
+ return lines.join("\n");
305
+ }
306
+
160
307
  /** Settings panel: build the markdown body. */
161
308
  export function renderSettingsText(
162
309
  model: string,
@@ -73,8 +73,17 @@ export function createDiscordFrontend(
73
73
  GatewayIntentBits.MessageContent,
74
74
  GatewayIntentBits.DirectMessages,
75
75
  GatewayIntentBits.GuildMessageReactions,
76
+ GatewayIntentBits.DirectMessageReactions,
77
+ ],
78
+ // Partials.Reaction is what makes reactions on messages that predate the
79
+ // current cache arrive at all — without it the soul's reaction tap only
80
+ // ever sees freshly-cached messages.
81
+ partials: [
82
+ Partials.Channel,
83
+ Partials.Message,
84
+ Partials.User,
85
+ Partials.Reaction,
76
86
  ],
77
- partials: [Partials.Channel, Partials.Message, Partials.User],
78
87
  allowedMentions: { parse: [] }, // never ping anyone unless we explicitly opt in
79
88
  });
80
89
 
@@ -21,6 +21,7 @@ import { pushMessage } from "../../storage/history.js";
21
21
  import { registerChat } from "../../core/background/pulse.js";
22
22
  import { deriveNumericChatId } from "../../util/chat-id.js";
23
23
  import { handleMessage, getSenderName } from "./handlers/index.js";
24
+ import { recordReactionToBot } from "../../core/soul/taps.js";
24
25
 
25
26
  export function registerMiddleware(client: Client, config: TalonConfig): void {
26
27
  client.on("messageCreate", (msg: Message) => {
@@ -85,4 +86,27 @@ export function registerMiddleware(client: Client, config: TalonConfig): void {
85
86
  /* logged inside */
86
87
  });
87
88
  });
89
+
90
+ // ── Reaction tap — feed reactions on Talon's own messages to the soul ────
91
+ // The gateway records outgoing message ids as `Number(snowflake)`, so the
92
+ // lookup here must use the same conversion to match. Inert unless the soul
93
+ // is enabled.
94
+ client.on("messageReactionAdd", async (reaction, user) => {
95
+ if (user.id === client.user?.id) return;
96
+ try {
97
+ // A partial arrives when the message predates the cache; the chat id
98
+ // can't be built without the channel/guild it belongs to.
99
+ if (reaction.partial) await reaction.fetch();
100
+ } catch {
101
+ return;
102
+ }
103
+ const msg = reaction.message;
104
+ const emoji = reaction.emoji.name;
105
+ if (!emoji) return;
106
+ const chatId =
107
+ msg.channel.type === ChannelType.DM
108
+ ? `discord_dm_${msg.author?.id ?? user.id}`
109
+ : `discord_guild_${msg.guildId}_${msg.channelId}`;
110
+ recordReactionToBot(chatId, Number(msg.id), [emoji]);
111
+ });
88
112
  }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `/usage` data gathering — plan limits across every backend the config
3
+ * exposes, not just the one serving this chat.
4
+ *
5
+ * Most backends have no plan to report: a gateway (Kilo, OpenCode) bills
6
+ * through whichever provider it fronts, and an API-key install pays per
7
+ * token with no window to be near the end of. Those are listed with a
8
+ * reason rather than omitted, so the answer to "am I close to a limit?" is
9
+ * never silence.
10
+ */
11
+
12
+ import type { TalonConfig } from "../../util/config.js";
13
+ import type { PlanUsage } from "../../core/agent-runtime/capabilities.js";
14
+ import {
15
+ listAvailableBackends,
16
+ getPooledBackend,
17
+ } from "../../core/engine/backend-controller/index.js";
18
+ import { buildPlanDisplay, type PlanDisplay } from "./status-context.js";
19
+
20
+ export interface BackendUsageEntry {
21
+ id: string;
22
+ label: string;
23
+ /** Rendered windows, or null when this backend reported nothing. */
24
+ plan: PlanDisplay | null;
25
+ /** Why there is nothing to show. Absent when `plan` is set. */
26
+ note?: string;
27
+ }
28
+
29
+ /**
30
+ * One entry per exposed backend, in config order.
31
+ *
32
+ * Only backends already running are queried — booting one to read a
33
+ * number would spawn a subprocess or a server per idle provider, which is
34
+ * far more than a status command should cost.
35
+ */
36
+ export async function collectPlanUsage(
37
+ config: TalonConfig,
38
+ ): Promise<BackendUsageEntry[]> {
39
+ const entries: BackendUsageEntry[] = [];
40
+
41
+ for (const { id, label } of listAvailableBackends(config)) {
42
+ const backend = getPooledBackend(id);
43
+ if (!backend) {
44
+ entries.push({ id, label, plan: null, note: "not running" });
45
+ continue;
46
+ }
47
+ if (!backend.usage?.getPlanUsage) {
48
+ entries.push({
49
+ id,
50
+ label,
51
+ plan: null,
52
+ note: "no plan limits on this backend",
53
+ });
54
+ continue;
55
+ }
56
+
57
+ let usage: PlanUsage | undefined;
58
+ try {
59
+ usage = await backend.usage.getPlanUsage();
60
+ } catch {
61
+ usage = undefined;
62
+ }
63
+ const plan = buildPlanDisplay(usage);
64
+ entries.push(
65
+ plan
66
+ ? { id, label, plan }
67
+ : { id, label, plan: null, note: "no usage information available" },
68
+ );
69
+ }
70
+
71
+ return entries;
72
+ }
@@ -21,7 +21,9 @@ import {
21
21
  renderDoctorMessage,
22
22
  renderMetricsKeyboard,
23
23
  renderMetricsPanel,
24
+ renderUsageMessage,
24
25
  } from "../helpers/index.js";
26
+ import { collectPlanUsage } from "../../shared/plan-usage-report.js";
25
27
  import { collectDoctorReport } from "../../../core/doctor.js";
26
28
  import { handleAdminCommand } from "../admin.js";
27
29
  import { getTodayMetrics } from "../../../storage/metrics.js";
@@ -53,6 +55,14 @@ export function registerAdminCommands(
53
55
  });
54
56
  });
55
57
 
58
+ // /usage — plan limits across every exposed backend, not just this
59
+ // chat's. Not admin-gated: it says how close the shared account is to a
60
+ // wall, which is exactly what a user hitting one needs to know.
61
+ bot.command("usage", async (ctx) => {
62
+ const entries = await collectPlanUsage(config);
63
+ await ctx.reply(renderUsageMessage(entries), { parse_mode: "HTML" });
64
+ });
65
+
56
66
  bot.command("doctor", async (ctx) => {
57
67
  if (!isAuthorizedAdmin(ctx)) {
58
68
  await ctx.reply("Not authorized.");
@@ -28,6 +28,7 @@ export const TELEGRAM_COMMANDS: ReadonlyArray<{
28
28
  { command: "pulse", description: "Conversation engagement settings" },
29
29
  { command: "reset", description: "Clear session and start fresh" },
30
30
  { command: "restart", description: "Restart the bot (admin)" },
31
+ { command: "usage", description: "Plan limits across every backend" },
31
32
  { command: "metrics", description: "Aggregate performance metrics" },
32
33
  {
33
34
  command: "doctor",
@@ -5,6 +5,7 @@
5
5
  import { escapeHtml } from "../formatting.js";
6
6
  import type { DoctorReport } from "../../../core/doctor.js";
7
7
  import type { MeshPingResult } from "../../../core/mesh/service.js";
8
+ import type { BackendUsageEntry } from "../../shared/plan-usage-report.js";
8
9
  import type { SettingsButton } from "./menu.js";
9
10
  import { formatDuration, formatBytes } from "./format.js";
10
11
 
@@ -208,14 +209,47 @@ const DOCTOR_ICONS: Record<string, string> = {
208
209
  * when this renders, the bot is by definition running, so the CLI's
209
210
  * "is the bot up" probe becomes an uptime line instead.
210
211
  */
212
+ /** Render the `/usage` report — one block per exposed backend. */
213
+ export function renderUsageMessage(entries: BackendUsageEntry[]): string {
214
+ const lines = ["<b>📊 Plan usage</b>"];
215
+
216
+ for (const entry of entries) {
217
+ const name = escapeHtml(entry.label || entry.id);
218
+ if (!entry.plan) {
219
+ lines.push("", `<b>${name}</b> — <i>${escapeHtml(entry.note ?? "")}</i>`);
220
+ continue;
221
+ }
222
+ const age = entry.plan.ageLabel ? ` <i>(${entry.plan.ageLabel})</i>` : "";
223
+ const plan = entry.plan.plan ? ` · ${escapeHtml(entry.plan.plan)}` : "";
224
+ lines.push("", `<b>${name}</b>${plan}${age}`);
225
+ for (const w of entry.plan.windows) {
226
+ const reset = w.resetLabel ? ` reset ${w.resetLabel}` : "";
227
+ lines.push(
228
+ ` <code>${escapeHtml(w.label.padEnd(6))}${w.bar} ${String(w.percent).padStart(3)}%</code>${reset}`,
229
+ );
230
+ }
231
+ }
232
+
233
+ return lines.join("\n");
234
+ }
235
+
211
236
  export function renderDoctorMessage(report: DoctorReport): string {
212
237
  const lines = ["<b>🩺 Talon Doctor</b>", "", "<b>Environment</b>"];
213
238
 
214
- for (const check of report.checks) {
239
+ const render = (check: DoctorReport["checks"][number]): string => {
215
240
  const detail = check.detail ? ` (${escapeHtml(check.detail)})` : "";
216
- lines.push(
217
- `${DOCTOR_ICONS[check.status]} ${escapeHtml(check.label)}${detail}`,
218
- );
241
+ return `${DOCTOR_ICONS[check.status]} ${escapeHtml(check.label)}${detail}`;
242
+ };
243
+
244
+ for (const check of report.checks.filter((c) => !c.inactive)) {
245
+ lines.push(render(check));
246
+ }
247
+
248
+ // Configured-but-idle backends get their own block: they describe what a
249
+ // switch would run into, not the state of the running deployment.
250
+ const idle = report.checks.filter((c) => c.inactive);
251
+ if (idle.length > 0) {
252
+ lines.push("", "<b>Other backends</b>", ...idle.map(render));
219
253
  }
220
254
 
221
255
  lines.push("", "<b>Native modules</b>");
@@ -340,6 +340,16 @@ const configSchema = z.object({
340
340
  allowedUsers: z.array(z.number().int()).optional(), // Whitelist of user IDs allowed to DM the bot
341
341
  pulse: z.boolean().default(true),
342
342
  pulseIntervalMs: z.number().int().min(60000).default(300000),
343
+ /**
344
+ * Warn the admin chat when a subscription rate-limit window crosses
345
+ * `planAlertThreshold`. Off by default. Needs a backend that reports plan
346
+ * limits (Claude on a subscription); one message per window per reset
347
+ * cycle.
348
+ */
349
+ planAlerts: z.boolean().default(false),
350
+ planAlertThreshold: z.number().int().min(1).max(100).default(80),
351
+ /** Chat that receives plan warnings. Defaults to `adminUserId`. */
352
+ planAlertChatId: z.string().optional(),
343
353
  /** Background memory-consolidation (dream) runs. Mirrors `pulse`/`heartbeat`. */
344
354
  dream: z.boolean().default(true),
345
355
  /**