@songsid/agend 2.1.2-beta.3 → 2.1.2-beta.30

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