@songsid/agend 2.1.2-beta.8 → 2.1.2

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.
Files changed (174) hide show
  1. package/dist/access-path.js +15 -6
  2. package/dist/access-path.js.map +1 -1
  3. package/dist/agent-cli-instructions.md +1 -0
  4. package/dist/agent-cli.js +14 -0
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.d.ts +2 -0
  7. package/dist/agent-endpoint.js +25 -1
  8. package/dist/agent-endpoint.js.map +1 -1
  9. package/dist/backend/antigravity.d.ts +20 -0
  10. package/dist/backend/antigravity.js +51 -4
  11. package/dist/backend/antigravity.js.map +1 -1
  12. package/dist/backend/claude-code.d.ts +59 -0
  13. package/dist/backend/claude-code.js +67 -1
  14. package/dist/backend/claude-code.js.map +1 -1
  15. package/dist/backend/codex.d.ts +26 -0
  16. package/dist/backend/codex.js +100 -0
  17. package/dist/backend/codex.js.map +1 -1
  18. package/dist/backend/grok.d.ts +25 -0
  19. package/dist/backend/grok.js +51 -0
  20. package/dist/backend/grok.js.map +1 -1
  21. package/dist/backend/kiro.d.ts +23 -0
  22. package/dist/backend/kiro.js +120 -2
  23. package/dist/backend/kiro.js.map +1 -1
  24. package/dist/backend/types.d.ts +58 -0
  25. package/dist/backend/types.js +13 -1
  26. package/dist/backend/types.js.map +1 -1
  27. package/dist/channel/adapters/discord.d.ts +15 -0
  28. package/dist/channel/adapters/discord.js +120 -8
  29. package/dist/channel/adapters/discord.js.map +1 -1
  30. package/dist/channel/adapters/telegram.d.ts +21 -0
  31. package/dist/channel/adapters/telegram.js +86 -0
  32. package/dist/channel/adapters/telegram.js.map +1 -1
  33. package/dist/channel/ipc-bridge.d.ts +9 -1
  34. package/dist/channel/ipc-bridge.js +12 -3
  35. package/dist/channel/ipc-bridge.js.map +1 -1
  36. package/dist/channel/ipc-timeouts.d.ts +46 -0
  37. package/dist/channel/ipc-timeouts.js +65 -0
  38. package/dist/channel/ipc-timeouts.js.map +1 -0
  39. package/dist/channel/mcp-server.js +25 -14
  40. package/dist/channel/mcp-server.js.map +1 -1
  41. package/dist/channel/mcp-tools.js +30 -1
  42. package/dist/channel/mcp-tools.js.map +1 -1
  43. package/dist/channel/reconnect-backoff.d.ts +17 -0
  44. package/dist/channel/reconnect-backoff.js +21 -0
  45. package/dist/channel/reconnect-backoff.js.map +1 -0
  46. package/dist/channel/types.d.ts +29 -0
  47. package/dist/classic-channel-manager.js +1 -5
  48. package/dist/classic-channel-manager.js.map +1 -1
  49. package/dist/cli.js +242 -91
  50. package/dist/cli.js.map +1 -1
  51. package/dist/completion.d.ts +27 -0
  52. package/dist/completion.js +121 -0
  53. package/dist/completion.js.map +1 -0
  54. package/dist/config-validator.js +21 -0
  55. package/dist/config-validator.js.map +1 -1
  56. package/dist/config.js +2 -0
  57. package/dist/config.js.map +1 -1
  58. package/dist/cost-guard.d.ts +3 -1
  59. package/dist/cost-guard.js +3 -1
  60. package/dist/cost-guard.js.map +1 -1
  61. package/dist/daemon.d.ts +299 -7
  62. package/dist/daemon.js +1152 -311
  63. package/dist/daemon.js.map +1 -1
  64. package/dist/event-log.d.ts +31 -0
  65. package/dist/event-log.js +96 -0
  66. package/dist/event-log.js.map +1 -1
  67. package/dist/fleet-context.d.ts +14 -0
  68. package/dist/fleet-lock.d.ts +28 -0
  69. package/dist/fleet-lock.js +130 -0
  70. package/dist/fleet-lock.js.map +1 -0
  71. package/dist/fleet-manager.d.ts +348 -6
  72. package/dist/fleet-manager.js +1860 -262
  73. package/dist/fleet-manager.js.map +1 -1
  74. package/dist/general-instance.d.ts +9 -0
  75. package/dist/general-instance.js +11 -0
  76. package/dist/general-instance.js.map +1 -0
  77. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
  78. package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
  79. package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
  80. package/dist/hang-detector.d.ts +19 -13
  81. package/dist/hang-detector.js +19 -49
  82. package/dist/hang-detector.js.map +1 -1
  83. package/dist/instance-lifecycle.d.ts +58 -2
  84. package/dist/instance-lifecycle.js +231 -46
  85. package/dist/instance-lifecycle.js.map +1 -1
  86. package/dist/locale.js +7 -1
  87. package/dist/locale.js.map +1 -1
  88. package/dist/logger.js +14 -0
  89. package/dist/logger.js.map +1 -1
  90. package/dist/mcp-liveness.d.ts +21 -0
  91. package/dist/mcp-liveness.js +27 -0
  92. package/dist/mcp-liveness.js.map +1 -0
  93. package/dist/outbound-handlers.d.ts +9 -0
  94. package/dist/outbound-handlers.js +194 -31
  95. package/dist/outbound-handlers.js.map +1 -1
  96. package/dist/outbound-schemas.d.ts +11 -3
  97. package/dist/outbound-schemas.js +16 -2
  98. package/dist/outbound-schemas.js.map +1 -1
  99. package/dist/pane-write-lock.d.ts +48 -0
  100. package/dist/pane-write-lock.js +73 -0
  101. package/dist/pane-write-lock.js.map +1 -0
  102. package/dist/process-memory.d.ts +31 -0
  103. package/dist/process-memory.js +79 -0
  104. package/dist/process-memory.js.map +1 -0
  105. package/dist/quickstart.js +17 -16
  106. package/dist/quickstart.js.map +1 -1
  107. package/dist/reply-dedup.d.ts +41 -0
  108. package/dist/reply-dedup.js +0 -0
  109. package/dist/reply-dedup.js.map +1 -0
  110. package/dist/restart-progress.d.ts +41 -0
  111. package/dist/restart-progress.js +97 -0
  112. package/dist/restart-progress.js.map +1 -0
  113. package/dist/scheduler/db.js +3 -0
  114. package/dist/scheduler/db.js.map +1 -1
  115. package/dist/sd-notify.d.ts +27 -0
  116. package/dist/sd-notify.js +33 -1
  117. package/dist/sd-notify.js.map +1 -1
  118. package/dist/secret-file.d.ts +33 -0
  119. package/dist/secret-file.js +36 -0
  120. package/dist/secret-file.js.map +1 -0
  121. package/dist/service-installer.d.ts +28 -0
  122. package/dist/service-installer.js +72 -1
  123. package/dist/service-installer.js.map +1 -1
  124. package/dist/settings-api.js +5 -3
  125. package/dist/settings-api.js.map +1 -1
  126. package/dist/setup-wizard.js +9 -7
  127. package/dist/setup-wizard.js.map +1 -1
  128. package/dist/tmux-control.d.ts +58 -5
  129. package/dist/tmux-control.js +102 -14
  130. package/dist/tmux-control.js.map +1 -1
  131. package/dist/tmux-manager.d.ts +31 -1
  132. package/dist/tmux-manager.js +40 -11
  133. package/dist/tmux-manager.js.map +1 -1
  134. package/dist/topic-commands.d.ts +49 -9
  135. package/dist/topic-commands.js +270 -109
  136. package/dist/topic-commands.js.map +1 -1
  137. package/dist/tui-glyphs.d.ts +5 -1
  138. package/dist/tui-glyphs.js +6 -2
  139. package/dist/tui-glyphs.js.map +1 -1
  140. package/dist/types.d.ts +19 -2
  141. package/dist/ui/settings.html +1 -1
  142. package/dist/ui/view.html +123 -2
  143. package/dist/update-marker.d.ts +15 -0
  144. package/dist/update-marker.js +69 -0
  145. package/dist/update-marker.js.map +1 -0
  146. package/dist/usage/format-rich.d.ts +11 -0
  147. package/dist/usage/format-rich.js +121 -0
  148. package/dist/usage/format-rich.js.map +1 -0
  149. package/dist/usage/provider-alerts.d.ts +33 -0
  150. package/dist/usage/provider-alerts.js +57 -0
  151. package/dist/usage/provider-alerts.js.map +1 -0
  152. package/dist/usage/providers.d.ts +130 -0
  153. package/dist/usage/providers.js +1063 -0
  154. package/dist/usage/providers.js.map +1 -0
  155. package/dist/usage/statusline-usage.d.ts +20 -0
  156. package/dist/usage/statusline-usage.js +120 -0
  157. package/dist/usage/statusline-usage.js.map +1 -0
  158. package/dist/usage/usage-api.d.ts +50 -0
  159. package/dist/usage/usage-api.js +197 -0
  160. package/dist/usage/usage-api.js.map +1 -0
  161. package/dist/view-api.js +8 -3
  162. package/dist/view-api.js.map +1 -1
  163. package/dist/web-api.js +7 -3
  164. package/dist/web-api.js.map +1 -1
  165. package/dist/web-auth.d.ts +6 -0
  166. package/dist/web-auth.js +54 -0
  167. package/dist/web-auth.js.map +1 -0
  168. package/package.json +4 -1
  169. package/dist/channel/tool-tracker.d.ts +0 -13
  170. package/dist/channel/tool-tracker.js +0 -58
  171. package/dist/channel/tool-tracker.js.map +0 -1
  172. package/dist/daemon-entry.d.ts +0 -1
  173. package/dist/daemon-entry.js +0 -30
  174. package/dist/daemon-entry.js.map +0 -1
@@ -1,14 +1,16 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
- import { exec } from "node:child_process";
2
+ import { exec, execFileSync } from "node:child_process";
3
3
  import { promisify } from "node:util";
4
4
  import { join, basename } from "node:path";
5
+ import { release } from "node:os";
5
6
  import { createRequire } from "node:module";
6
7
  const execAsync = promisify(exec);
7
8
  import { DEFAULT_INSTANCE_CONFIG } from "./config.js";
8
9
  import { formatCents } from "./cost-guard.js";
9
- import { getAgendHome } from "./paths.js";
10
+ import { getTmuxSocketName, getTmuxSessionName } from "./paths.js";
10
11
  import { t } from "./locale.js";
11
12
  import { PIE_PERCENT_RE } from "./tui-glyphs.js";
13
+ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
12
14
  /** Sanitize a directory name into a valid instance name. Keeps Unicode letters (incl. CJK). */
13
15
  export function sanitizeInstanceName(name) {
14
16
  const sanitized = name.toLowerCase().replace(/[^\p{L}\d-]/gu, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
@@ -48,6 +50,32 @@ export function parsePauseWakeCommand(text) {
48
50
  * Antigravity (agy) has NO summarizing compact — its only manual context-reset
49
51
  * is "/clear" (a full reset; it also auto-summarizes at a token threshold).
50
52
  */
53
+ /** os.release(), guarded — purely informational, must never break /sysinfo. */
54
+ function osRelease() {
55
+ try {
56
+ return release();
57
+ }
58
+ catch {
59
+ return "?";
60
+ }
61
+ }
62
+ /**
63
+ * `tmux -V`, memoised. One short synchronous exec for the lifetime of the
64
+ * process — /sysinfo is on-demand and rare, and the version cannot change under
65
+ * a running fleet (the server would have to restart with it).
66
+ */
67
+ let cachedTmuxVersion = null;
68
+ function tmuxVersion() {
69
+ if (cachedTmuxVersion === null) {
70
+ try {
71
+ cachedTmuxVersion = execFileSync("tmux", ["-V"], { encoding: "utf-8", timeout: 3000 }).trim();
72
+ }
73
+ catch {
74
+ cachedTmuxVersion = "not found";
75
+ }
76
+ }
77
+ return cachedTmuxVersion;
78
+ }
51
79
  export function compactCommandForBackend(backend) {
52
80
  if (backend === "antigravity")
53
81
  return "/clear";
@@ -101,6 +129,115 @@ export function parseContextPercent(pane) {
101
129
  }
102
130
  return null;
103
131
  }
132
+ /** Claude Code statusline.json context used % (null if missing / unreadable). */
133
+ export function readStatuslineContextPct(dataDir, instanceName) {
134
+ try {
135
+ const statusFile = join(dataDir, "instances", instanceName, "statusline.json");
136
+ if (!existsSync(statusFile))
137
+ return null;
138
+ const data = JSON.parse(readFileSync(statusFile, "utf-8"));
139
+ const pct = data.context_window?.used_percentage;
140
+ return typeof pct === "number" && Number.isFinite(pct) ? pct : null;
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ /** Live-pane scrape used by /ctx, /status Ctx, and /view sidebar. */
147
+ export function scrapePaneContext(instanceName, backend) {
148
+ try {
149
+ const socketName = getTmuxSocketName();
150
+ // Scrollback (-S -60) so a recent footer/statusline is kept even mid-output.
151
+ const baseArgs = ["capture-pane", "-t", `${getTmuxSessionName()}:${instanceName}`, "-p", "-S", "-60"];
152
+ const tmuxArgs = socketName ? ["-L", socketName, ...baseArgs] : baseArgs;
153
+ const pane = execFileSync("tmux", tmuxArgs, {
154
+ encoding: "utf-8",
155
+ timeout: 2000,
156
+ stdio: ["pipe", "pipe", "pipe"],
157
+ });
158
+ const tokenRatio = backend === "grok" ? parseTokenContextRatio(pane) : null;
159
+ const context = tokenRatio?.percentage ?? parseContextPercent(pane);
160
+ return { context, tokenRatio };
161
+ }
162
+ catch {
163
+ return { context: null, tokenRatio: null };
164
+ }
165
+ }
166
+ const paneContextCache = new Map();
167
+ // Below the dashboard's 10s SSE tick on purpose: at the previous 12s the cache was
168
+ // guaranteed to be stale on roughly every other tick, which is what made the
169
+ // blocking scrape fire so often. Now a tick either hits the cache or triggers a
170
+ // background refresh, never a synchronous capture.
171
+ const PANE_CONTEXT_CACHE_MS = 8_000;
172
+ /** Instances with a background scrape in flight, so polls don't pile up captures. */
173
+ const paneScrapeInFlight = new Set();
174
+ /** Async twin of scrapePaneContext — same parsers, no blocking. */
175
+ async function scrapePaneContextAsync(instanceName, backend) {
176
+ try {
177
+ const socketName = getTmuxSocketName();
178
+ const baseArgs = ["capture-pane", "-t", `${getTmuxSessionName()}:${instanceName}`, "-p", "-S", "-60"];
179
+ const tmuxArgs = socketName ? ["-L", socketName, ...baseArgs] : baseArgs;
180
+ const { promisify } = await import("node:util");
181
+ const { execFile } = await import("node:child_process");
182
+ const { stdout } = await promisify(execFile)("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000 });
183
+ const pane = stdout.toString();
184
+ const tokenRatio = backend === "grok" ? parseTokenContextRatio(pane) : null;
185
+ return { context: tokenRatio?.percentage ?? parseContextPercent(pane), tokenRatio };
186
+ }
187
+ catch {
188
+ return { context: null, tokenRatio: null };
189
+ }
190
+ }
191
+ /** Refresh one instance's cached context in the background (deduped per instance). */
192
+ function refreshPaneContext(instanceName, backend) {
193
+ if (paneScrapeInFlight.has(instanceName))
194
+ return;
195
+ paneScrapeInFlight.add(instanceName);
196
+ void scrapePaneContextAsync(instanceName, backend)
197
+ .then(scraped => { paneContextCache.set(instanceName, { at: Date.now(), ...scraped }); })
198
+ .finally(() => { paneScrapeInFlight.delete(instanceName); });
199
+ }
200
+ /** Forget a deleted instance's cached context so the map can't grow forever. */
201
+ export function forgetInstanceContext(instanceName) {
202
+ paneContextCache.delete(instanceName);
203
+ }
204
+ /**
205
+ * Single source of truth for instance context % across /ctx, /status, and View.
206
+ * Claude-code prefers statusline.json (authoritative, no TUI scrape); everyone
207
+ * else scrapes the live pane with the same parsers /ctx uses.
208
+ *
209
+ * Non-blocking by default (stale-while-revalidate): a fresh cache entry is
210
+ * returned as-is; a stale or missing one is returned immediately anyway while a
211
+ * background refresh runs. This used to scrape synchronously with `execFileSync`
212
+ * (2s timeout) on a cache miss, and the 12s TTL is LONGER than the dashboard's
213
+ * 10s poll — so roughly every other tick did N blocking captures. With ten
214
+ * non-claude-code instances and a slow tmux that froze the entire fleet event
215
+ * loop for up to 20s per tick: no IPC, no message delivery, no watchdog ping.
216
+ * Three open browser tabs ran three independent polls.
217
+ *
218
+ * Pass `bypassCache` for a synchronous, authoritative read — used by `/ctx`,
219
+ * where a user is asking right now and 2s of blocking is the correct trade.
220
+ */
221
+ export function resolveInstanceContext(dataDir, instanceName, backend, opts) {
222
+ if (backend === "claude-code") {
223
+ const fromFile = readStatuslineContextPct(dataDir, instanceName);
224
+ if (fromFile != null)
225
+ return { context: fromFile, tokenRatio: null };
226
+ }
227
+ if (opts?.bypassCache) {
228
+ const scraped = scrapePaneContext(instanceName, backend);
229
+ paneContextCache.set(instanceName, { at: Date.now(), ...scraped });
230
+ return scraped;
231
+ }
232
+ const hit = paneContextCache.get(instanceName);
233
+ if (hit && Date.now() - hit.at < PANE_CONTEXT_CACHE_MS) {
234
+ return { context: hit.context, tokenRatio: hit.tokenRatio };
235
+ }
236
+ // Stale or absent: kick off the refresh and answer with what we have. A brand-new
237
+ // instance reads as "no data" for one tick rather than blocking the fleet.
238
+ refreshPaneContext(instanceName, backend);
239
+ return hit ? { context: hit.context, tokenRatio: hit.tokenRatio } : { context: null, tokenRatio: null };
240
+ }
104
241
  export class TopicCommands {
105
242
  ctx;
106
243
  constructor(ctx) {
@@ -135,6 +272,10 @@ export class TopicCommands {
135
272
  await this.handleDoctorCommand(msg);
136
273
  return true;
137
274
  }
275
+ if (text === "/usage" || text.startsWith("/usage@")) {
276
+ await this.handleUsageCommand(msg);
277
+ return true;
278
+ }
138
279
  if (text === "/update" || text.startsWith("/update@")) {
139
280
  await this.handleUpdateCommand(msg);
140
281
  return true;
@@ -155,11 +296,10 @@ export class TopicCommands {
155
296
  getDashboardText(htmlSpoiler = false) {
156
297
  const port = this.ctx.fleetConfig?.health_port ?? 19280;
157
298
  const host = this.ctx.fleetConfig?.hostname || "localhost";
158
- let token = "";
159
- try {
160
- token = readFileSync(join(getAgendHome(), "web.token"), "utf-8").trim();
161
- }
162
- catch { /* not started yet */ }
299
+ const access = this.ctx.getDashboardAccess?.();
300
+ if (!access?.ready || !access.token)
301
+ return t("dashboard.starting");
302
+ const token = access.token;
163
303
  const base = `http://${host}:${port}`;
164
304
  const hide = (u) => htmlSpoiler ? `<tg-spoiler>${u}</tg-spoiler>` : u;
165
305
  return [
@@ -217,6 +357,10 @@ export class TopicCommands {
217
357
  await adapter.sendText(msg.chatId, t("instance.not_found", target), { threadId: msg.threadId });
218
358
  return true;
219
359
  }
360
+ if (pauseWake.action === "pause" && isGeneralInstance(this.ctx.fleetConfig, target)) {
361
+ await adapter.sendText(msg.chatId, GENERAL_PAUSE_ERROR, { threadId: msg.threadId });
362
+ return true;
363
+ }
220
364
  await adapter.sendText(msg.chatId, await this.runPauseWake(target, pauseWake.action), { threadId: msg.threadId });
221
365
  return true;
222
366
  }
@@ -230,6 +374,28 @@ export class TopicCommands {
230
374
  : t("collab.off"), { threadId: msg.threadId });
231
375
  return true;
232
376
  }
377
+ if (text === "/effort" || text.startsWith("/effort ") || text.startsWith("/effort@")) {
378
+ const adapter = this.getReplyAdapter(msg);
379
+ if (!adapter)
380
+ return false;
381
+ if (!this.ctx.isFleetAdmin(msg.userId, msg.adapterId)) {
382
+ await adapter.sendText(msg.chatId, t("permission.denied"), { threadId: msg.threadId });
383
+ return true;
384
+ }
385
+ const level = text.replace(/^\/effort(@\S+)?/, "").trim();
386
+ if (level) {
387
+ const reply = await this.ctx.applyEffort?.(instanceName, level)
388
+ ?? "Effort switching is unavailable.";
389
+ await adapter.sendText(msg.chatId, reply, { threadId: msg.threadId });
390
+ }
391
+ else if (this.ctx.promptEffortMenu) {
392
+ // No arg → inline keyboard menu (TG), same shape as /model.
393
+ const fallback = await this.ctx.promptEffortMenu(instanceName, msg.userId, msg.threadId ?? msg.chatId, adapter, msg.chatId, msg.threadId);
394
+ if (fallback)
395
+ await adapter.sendText(msg.chatId, fallback, { threadId: msg.threadId });
396
+ }
397
+ return true;
398
+ }
233
399
  if (text === "/model" || text.startsWith("/model ") || text.startsWith("/model@")) {
234
400
  const adapter = this.getReplyAdapter(msg);
235
401
  if (!adapter)
@@ -297,6 +463,9 @@ export class TopicCommands {
297
463
  return true;
298
464
  }
299
465
  async runPauseWake(instanceName, action) {
466
+ if (action === "pause" && isGeneralInstance(this.ctx.fleetConfig, instanceName)) {
467
+ return GENERAL_PAUSE_ERROR;
468
+ }
300
469
  try {
301
470
  const result = await this.ctx.changeInstancePauseState(instanceName, action);
302
471
  if (result === "not_idle")
@@ -307,50 +476,22 @@ export class TopicCommands {
307
476
  return t(`${action}.failed`, instanceName, err.message);
308
477
  }
309
478
  }
310
- /** Get context usage text for an instance (shared by TG + DC) */
311
- async getCtxText(instanceName) {
312
- // Classic instances live in classicBot.yaml, not fleet.yaml → consult the
313
- // classic channel manager for those so we don't mis-report defaults.backend.
314
- // getBackendByInstance always returns a string (its own fallback), so only
315
- // use it when the instance is ACTUALLY classic — otherwise a fleet instance
316
- // that inherits its backend would wrongly pick up the classic default.
479
+ /** Resolve the effective backend name for fleet or classic instances. */
480
+ effectiveBackend(instanceName) {
317
481
  const classicBackend = this.ctx.classicChannels?.getChannelIdByInstance(instanceName)
318
482
  ? this.ctx.classicChannels.getBackendByInstance(instanceName, this.ctx.fleetConfig?.defaults?.backend)
319
483
  : undefined;
320
- const backend = this.ctx.fleetConfig?.instances[instanceName]?.backend
484
+ return this.ctx.fleetConfig?.instances[instanceName]?.backend
321
485
  ?? classicBackend
322
486
  ?? this.ctx.fleetConfig?.defaults?.backend ?? "claude-code";
323
- let context = null;
324
- let tokenRatio = null;
325
- // Only claude-code writes statusline.json. Reading it for other backends
326
- // risks a stale value left over from a previous backend (e.g. after
327
- // switching claude-code kiro-cli), so those go straight to capture-pane.
328
- if (backend === "claude-code") {
329
- try {
330
- const statusFile = join(this.ctx.dataDir, "instances", instanceName, "statusline.json");
331
- if (existsSync(statusFile)) {
332
- const d = JSON.parse(readFileSync(statusFile, "utf-8"));
333
- context = d.context_window?.used_percentage ?? null;
334
- }
335
- }
336
- catch { /* ignore */ }
337
- }
338
- if (context == null) {
339
- try {
340
- const { execFileSync } = await import("node:child_process");
341
- const { getTmuxSocketName, getTmuxSessionName } = await import("./paths.js");
342
- const socketName = getTmuxSocketName();
343
- // Include scrollback (-S -60) so a recent prompt is captured even when
344
- // the CLI is mid-output and the bottom of the visible pane has no prompt.
345
- const baseArgs = ["capture-pane", "-t", `${getTmuxSessionName()}:${instanceName}`, "-p", "-S", "-60"];
346
- const tmuxArgs = socketName ? ["-L", socketName, ...baseArgs] : baseArgs;
347
- const pane = execFileSync("tmux", tmuxArgs, { encoding: "utf-8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] });
348
- if (backend === "grok")
349
- tokenRatio = parseTokenContextRatio(pane);
350
- context = tokenRatio?.percentage ?? parseContextPercent(pane);
351
- }
352
- catch { /* ignore */ }
353
- }
487
+ }
488
+ /** Get context usage text for an instance (shared by TG + DC) */
489
+ async getCtxText(instanceName) {
490
+ // Classic instances live in classicBot.yaml, not fleet.yaml consult the
491
+ // classic channel manager for those so we don't mis-report defaults.backend.
492
+ const backend = this.effectiveBackend(instanceName);
493
+ // Fresh scrape for /ctx (user is asking right now) — bypass short cache.
494
+ const { context, tokenRatio } = resolveInstanceContext(this.ctx.dataDir, instanceName, backend, { bypassCache: true });
354
495
  const contextLine = context == null ? null : formatContextUsageLine(context, tokenRatio);
355
496
  // Effective model (resolves per-instance → fleet default → classic channel →
356
497
  // the CLI's own default) via the shared resolver, so /ctx and /model agree.
@@ -408,10 +549,44 @@ export class TopicCommands {
408
549
  await adapter.sendText(chatId, t("restart.graceful"), { threadId });
409
550
  process.kill(process.pid, "SIGUSR2");
410
551
  }
552
+ /**
553
+ * `/usage` — AI subscription usage for every CLI backend logged in on this
554
+ * machine, one compact message. Same permission level as /ctx (none): whoever
555
+ * can talk to the fleet may see how much headroom it has left. Vendor rate
556
+ * limits are protected by the shared 5-minute cache, not by gating callers.
557
+ */
558
+ async handleUsageCommand(msg) {
559
+ const adapter = this.getReplyAdapter(msg);
560
+ if (!adapter)
561
+ return;
562
+ try {
563
+ const { getUsageSnapshot } = await import("./usage/usage-api.js");
564
+ const { renderUsageHtml, renderUsageMarkdown } = await import("./usage/format-rich.js");
565
+ const payload = await getUsageSnapshot();
566
+ // Each platform gets its native rich format: Telegram needs parse_mode
567
+ // HTML; Discord renders Markdown in plain content. Anything else falls back
568
+ // to Markdown, which degrades to readable text.
569
+ if (adapter.type === "telegram") {
570
+ await adapter.sendText(msg.chatId, renderUsageHtml(payload), { threadId: msg.threadId, format: "html" });
571
+ }
572
+ else {
573
+ await adapter.sendText(msg.chatId, renderUsageMarkdown(payload), { threadId: msg.threadId });
574
+ }
575
+ }
576
+ catch (err) {
577
+ await adapter.sendText(msg.chatId, `⚠️ Usage fetch failed: ${err.message}`, { threadId: msg.threadId });
578
+ }
579
+ }
411
580
  async handleStatusCommand(msg) {
412
581
  const adapter = this.getReplyAdapter(msg);
413
582
  if (!adapter || !this.ctx.fleetConfig)
414
583
  return;
584
+ // Admin-gated: with the /sysinfo instance table folded in, /status now shows
585
+ // the whole fleet's per-instance costs and IPC health in one message.
586
+ if (!this.ctx.isFleetAdmin(msg.userId, msg.adapterId)) {
587
+ await adapter.sendText(msg.chatId, t("cmd.admin_required", "/status"), { threadId: msg.threadId });
588
+ return;
589
+ }
415
590
  const text = await this.getStatusText();
416
591
  await adapter.sendText(msg.chatId, text, { threadId: msg.threadId });
417
592
  }
@@ -446,18 +621,16 @@ export class TopicCommands {
446
621
  const costPaused = this.ctx.costGuard?.isLimited(name);
447
622
  if (status === "paused")
448
623
  pausedCount++;
449
- let contextStr = "-";
450
- try {
451
- const data = JSON.parse(readFileSync(join(this.ctx.dataDir, "instances", name, "statusline.json"), "utf-8"));
452
- if (data.context_window?.used_percentage != null) {
453
- contextStr = `${Math.round(data.context_window.used_percentage)}%`;
454
- }
455
- }
456
- catch { /* file may not exist yet */ }
457
- const costCents = this.ctx.costGuard?.getDailyCostCents(name) ?? 0;
458
624
  const backend = classic
459
625
  ? this.ctx.classicChannels?.getBackendByInstance(name, this.ctx.fleetConfig.defaults?.backend) ?? "-"
460
626
  : this.ctx.fleetConfig.instances[name]?.backend ?? this.ctx.fleetConfig.defaults?.backend ?? "-";
627
+ // Same source as /ctx (statusline for claude-code, pane scrape otherwise).
628
+ // Without this, kiro/grok/codex always showed "-" in the Ctx column.
629
+ const { context } = backend === "-"
630
+ ? { context: null }
631
+ : resolveInstanceContext(this.ctx.dataDir, name, backend);
632
+ const contextStr = context == null ? "-" : `${Math.round(context)}%`;
633
+ const costCents = this.ctx.costGuard?.getDailyCostCents(name) ?? 0;
461
634
  let icon;
462
635
  if (costPaused || status === "paused")
463
636
  icon = "⏸";
@@ -473,15 +646,21 @@ export class TopicCommands {
473
646
  : executionState === "paused" ? "⏸ paused"
474
647
  : "—";
475
648
  const displayName = this.shortInstanceName(name);
476
- rows.push(`| ${displayName} | ${backend} | ${contextStr} | ${formatCents(costCents)} | ${icon} | ${stateLabel} |`);
649
+ // IPC reachability moved here from /sysinfo: it is per-instance health, and
650
+ // /status is now the one place that shows the fleet per instance.
651
+ const ipc = this.ctx.instanceIpcClients.has(name) ? "✓" : "✗";
652
+ // "-" distinguishes "backend has no effort setting" from an unset one:
653
+ // an empty cell would read as missing data rather than not-applicable.
654
+ const effort = this.ctx.resolveInstanceEffort?.(name).effort ?? "-";
655
+ rows.push(`| ${displayName} | ${backend} | ${contextStr} | ${effort} | ${formatCents(costCents)} | ${icon} | ${stateLabel} | ${ipc} |`);
477
656
  }
478
657
  if (rows.length === 0)
479
658
  return "No instances configured.";
480
659
  const lines = [
481
660
  "## Fleet Status",
482
661
  "",
483
- "| Instance | Backend | Ctx | Cost | Status | State |",
484
- "|----------|---------|-----|------|--------|-------|",
662
+ "| Instance | Backend | Ctx | Effort | Cost | Status | State | IPC |",
663
+ "|----------|---------|-----|--------|------|--------|-------|-----|",
485
664
  ...rows,
486
665
  "",
487
666
  `Paused instances: ${pausedCount}`,
@@ -512,63 +691,33 @@ export class TopicCommands {
512
691
  const text = this.getSysInfoText();
513
692
  await adapter.sendText(msg.chatId, text, { threadId: msg.threadId });
514
693
  }
515
- /** Get system info as markdown text (shared by TG + DC) */
694
+ /**
695
+ * Get system info as markdown text (shared by TG + DC).
696
+ *
697
+ * System-level only. The per-instance table (state/IPC/cost) moved to /status,
698
+ * which was already the fleet-per-instance view — the two tables overlapped on
699
+ * everything but the IPC column, and each command answered half of "is the
700
+ * machine fine and are the instances fine".
701
+ */
516
702
  getSysInfoText() {
517
703
  const info = this.ctx.getSysInfo();
518
704
  const upHours = Math.floor(info.uptime_seconds / 3600);
519
705
  const upMins = Math.floor((info.uptime_seconds % 3600) / 60);
520
706
  const require = createRequire(import.meta.url);
521
707
  const agendVersion = require("../package.json").version ?? "unknown";
522
- const lines = [
708
+ return [
523
709
  "## System Info",
524
710
  "",
525
711
  "| Metric | Value |",
526
712
  "|--------|-------|",
527
713
  `| AgEnD | v${agendVersion} |`,
714
+ `| OS | ${process.platform} ${osRelease()} (${process.arch}) |`,
715
+ `| Node | ${process.version} |`,
716
+ `| tmux | ${tmuxVersion()} |`,
528
717
  `| Uptime | ${upHours}h ${upMins}m |`,
529
718
  `| Memory | ${info.memory_mb.rss} MB RSS |`,
530
719
  `| Heap | ${info.memory_mb.heapUsed} / ${info.memory_mb.heapTotal} MB |`,
531
- "",
532
- "## Instances",
533
- "",
534
- "| Name | State | IPC | Cost | Rate |",
535
- "|------|-------|-----|------|------|",
536
- ];
537
- const fleetNames = new Set(info.instances.map(inst => inst.name));
538
- const classicInstances = (this.ctx.classicChannels?.getAll() ?? [])
539
- .filter(ch => !fleetNames.has(ch.instanceName))
540
- .map(ch => ({
541
- name: ch.instanceName,
542
- status: this.ctx.getInstanceStatus(ch.instanceName),
543
- state: this.ctx.getInstanceExecutionState?.(ch.instanceName) ?? null,
544
- ipc: this.ctx.instanceIpcClients.has(ch.instanceName),
545
- costCents: this.ctx.costGuard?.getDailyCostCents(ch.instanceName) ?? 0,
546
- rateLimits: null,
547
- classic: true,
548
- }));
549
- const instances = [
550
- ...info.instances.map(inst => ({ ...inst, classic: false })),
551
- ...classicInstances,
552
- ];
553
- for (const inst of instances) {
554
- const executionState = inst.state;
555
- const displayState = inst.status === "paused" ? "paused"
556
- : inst.status === "stopped" || inst.status === "crashed" ? inst.status
557
- : executionState ?? "running";
558
- const icon = displayState === "idle" ? "🟢"
559
- : displayState === "working" ? "🔵"
560
- : displayState === "stuck" || displayState === "crashed" ? "🔴"
561
- : displayState === "paused" ? "⏸"
562
- : displayState === "stopped" ? "⚪" : "🟢";
563
- const ipc = inst.ipc ? "✓" : "✗";
564
- const rate = inst.rateLimits ? `5h:${inst.rateLimits.five_hour_pct}%` : "-";
565
- const displayName = this.shortInstanceName(inst.name);
566
- lines.push(`| ${icon} ${displayName} | ${displayState} | ${ipc} | ${formatCents(inst.costCents)} | ${rate} |`);
567
- }
568
- if (info.fleet_cost_limit_cents > 0) {
569
- lines.push("", `Fleet cost: ${formatCents(info.fleet_cost_cents)} / ${formatCents(info.fleet_cost_limit_cents)} daily`);
570
- }
571
- return lines.join("\n");
720
+ ].join("\n");
572
721
  }
573
722
  async handleUpdateCommand(msg) {
574
723
  const adapter = this.getReplyAdapter(msg);
@@ -605,17 +754,26 @@ export class TopicCommands {
605
754
  return;
606
755
  }
607
756
  await adapter.sendText(chatId, t("doctor.running"), { threadId });
757
+ // Async, and execFile rather than a shell string: as execSync this froze the
758
+ // whole fleet event loop for up to 30s — no IPC, no delivery, no WATCHDOG
759
+ // ping — and any allowlisted user could trigger it with /doctor.
760
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
761
+ const backend = this.ctx.fleetConfig?.defaults?.backend || "claude-code";
762
+ let output;
608
763
  try {
609
- const { execSync } = await import("node:child_process");
610
- const backend = this.ctx.fleetConfig?.defaults?.backend || "claude-code";
611
- const result = execSync(`agend backend doctor ${backend}`, { timeout: 30_000, encoding: "utf-8" });
612
- const clean = result.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
613
- await adapter.sendText(chatId, clean || "No output", { threadId });
764
+ const { execFile } = await import("node:child_process");
765
+ const { promisify } = await import("node:util");
766
+ const { stdout } = await promisify(execFile)("agend", ["backend", "doctor", backend], {
767
+ timeout: 30_000,
768
+ encoding: "utf-8",
769
+ });
770
+ output = stripAnsi(stdout) || "No output";
614
771
  }
615
772
  catch (err) {
616
- const output = (err.stdout ?? err.message ?? "Doctor failed").replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
617
- await adapter.sendText(chatId, output, { threadId });
773
+ const e = err;
774
+ output = stripAnsi(e.stdout ?? e.message ?? "Doctor failed");
618
775
  }
776
+ await adapter.sendText(chatId, output, { threadId });
619
777
  }
620
778
  /** Reply with redirect when message arrives in an unbound topic */
621
779
  async handleUnboundTopic(msg) {
@@ -734,18 +892,20 @@ export class TopicCommands {
734
892
  headers: { "Content-Type": "application/json" },
735
893
  body: JSON.stringify({
736
894
  commands: [
737
- { command: "status", description: "Show fleet status and costs" },
895
+ { command: "status", description: "🔒 Fleet status, per-instance costs and health" },
738
896
  { command: "sysinfo", description: "System diagnostics" },
739
897
  { command: "dashboard", description: "🔒 Get dashboard URLs" },
740
898
  { command: "ctx", description: "Show context usage" },
741
899
  { command: "compact", description: "Compact agent context" },
742
900
  { command: "model", description: "🔒 Switch model (admin only)" },
901
+ { command: "effort", description: "🔒 Set reasoning effort (admin only)" },
743
902
  { command: "pause", description: "🔒 Pause an idle instance" },
744
903
  { command: "wake", description: "🔒 Wake a paused instance" },
745
904
  { command: "restart", description: "🔒 Graceful restart all instances" },
746
905
  { command: "collab", description: "🔒 Toggle bot/webhook mode" },
747
906
  { command: "update", description: "🔒 Update AgEnD to latest" },
748
907
  { command: "doctor", description: "🔒 Run health diagnostics" },
908
+ { command: "usage", description: "AI subscription usage" },
749
909
  ],
750
910
  scope: { type: "chat", chat_id: ch.group_id },
751
911
  }),
@@ -760,6 +920,7 @@ export class TopicCommands {
760
920
  { command: "stop", description: "🔒 Stop the agent" },
761
921
  { command: "compact", description: "🔒 Compact agent context" },
762
922
  { command: "model", description: "🔒 Switch model (admin only)" },
923
+ { command: "effort", description: "🔒 Set reasoning effort (admin only)" },
763
924
  { command: "pause", description: "🔒 Pause the agent" },
764
925
  { command: "wake", description: "🔒 Wake the agent" },
765
926
  { command: "ctx", description: "Show context usage" },