@songsid/agend 2.1.2-beta.5 → 2.1.2-beta.50
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/dist/access-path.js +15 -6
- package/dist/access-path.js.map +1 -1
- package/dist/agent-cli-instructions.md +1 -0
- package/dist/agent-cli.js +14 -0
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +2 -0
- package/dist/agent-endpoint.js +25 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/antigravity.d.ts +20 -0
- package/dist/backend/antigravity.js +51 -4
- package/dist/backend/antigravity.js.map +1 -1
- package/dist/backend/claude-code.d.ts +59 -0
- package/dist/backend/claude-code.js +67 -1
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.d.ts +26 -0
- package/dist/backend/codex.js +100 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/grok.d.ts +25 -0
- package/dist/backend/grok.js +51 -0
- package/dist/backend/grok.js.map +1 -1
- package/dist/backend/kiro.d.ts +23 -0
- package/dist/backend/kiro.js +120 -2
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +58 -0
- package/dist/backend/types.js +13 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +15 -0
- package/dist/channel/adapters/discord.js +120 -8
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +21 -0
- package/dist/channel/adapters/telegram.js +86 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/ipc-bridge.d.ts +9 -1
- package/dist/channel/ipc-bridge.js +12 -3
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/ipc-timeouts.d.ts +46 -0
- package/dist/channel/ipc-timeouts.js +65 -0
- package/dist/channel/ipc-timeouts.js.map +1 -0
- package/dist/channel/mcp-server.js +30 -14
- package/dist/channel/mcp-server.js.map +1 -1
- package/dist/channel/mcp-tools.js +30 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/reconnect-backoff.d.ts +17 -0
- package/dist/channel/reconnect-backoff.js +21 -0
- package/dist/channel/reconnect-backoff.js.map +1 -0
- package/dist/channel/types.d.ts +29 -0
- package/dist/classic-channel-manager.js +1 -5
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +242 -91
- package/dist/cli.js.map +1 -1
- package/dist/completion.d.ts +27 -0
- package/dist/completion.js +121 -0
- package/dist/completion.js.map +1 -0
- package/dist/config-validator.js +21 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/cost-guard.d.ts +3 -1
- package/dist/cost-guard.js +3 -1
- package/dist/cost-guard.js.map +1 -1
- package/dist/daemon.d.ts +302 -8
- package/dist/daemon.js +1165 -312
- package/dist/daemon.js.map +1 -1
- package/dist/event-log.d.ts +31 -0
- package/dist/event-log.js +96 -0
- package/dist/event-log.js.map +1 -1
- package/dist/fleet-context.d.ts +14 -0
- package/dist/fleet-lock.d.ts +28 -0
- package/dist/fleet-lock.js +130 -0
- package/dist/fleet-lock.js.map +1 -0
- package/dist/fleet-manager.d.ts +347 -6
- package/dist/fleet-manager.js +1794 -215
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-instance.d.ts +9 -0
- package/dist/general-instance.js +11 -0
- package/dist/general-instance.js.map +1 -0
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +22 -0
- package/dist/general-knowledge/skills/model-discovery/SKILL.md +14 -23
- package/dist/general-knowledge/skills/session-management/SKILL.md +15 -34
- package/dist/hang-detector.d.ts +19 -13
- package/dist/hang-detector.js +19 -49
- package/dist/hang-detector.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +60 -3
- package/dist/instance-lifecycle.js +237 -48
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/instructions.d.ts +5 -0
- package/dist/instructions.js +9 -11
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +7 -1
- package/dist/locale.js.map +1 -1
- package/dist/logger.js +14 -0
- package/dist/logger.js.map +1 -1
- package/dist/mcp-liveness.d.ts +21 -0
- package/dist/mcp-liveness.js +27 -0
- package/dist/mcp-liveness.js.map +1 -0
- package/dist/outbound-handlers.d.ts +18 -0
- package/dist/outbound-handlers.js +215 -35
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +11 -3
- package/dist/outbound-schemas.js +16 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/pane-write-lock.d.ts +48 -0
- package/dist/pane-write-lock.js +73 -0
- package/dist/pane-write-lock.js.map +1 -0
- package/dist/process-memory.d.ts +31 -0
- package/dist/process-memory.js +79 -0
- package/dist/process-memory.js.map +1 -0
- package/dist/quickstart.js +17 -16
- package/dist/quickstart.js.map +1 -1
- package/dist/reply-dedup.d.ts +41 -0
- package/dist/reply-dedup.js +0 -0
- package/dist/reply-dedup.js.map +1 -0
- package/dist/restart-progress.d.ts +34 -0
- package/dist/restart-progress.js +87 -0
- package/dist/restart-progress.js.map +1 -0
- package/dist/scheduler/db.js +3 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/sd-notify.d.ts +27 -0
- package/dist/sd-notify.js +33 -1
- package/dist/sd-notify.js.map +1 -1
- package/dist/secret-file.d.ts +33 -0
- package/dist/secret-file.js +36 -0
- package/dist/secret-file.js.map +1 -0
- package/dist/service-installer.d.ts +28 -0
- package/dist/service-installer.js +72 -1
- package/dist/service-installer.js.map +1 -1
- package/dist/settings-api.js +5 -3
- package/dist/settings-api.js.map +1 -1
- package/dist/setup-wizard.js +9 -7
- package/dist/setup-wizard.js.map +1 -1
- package/dist/tmux-control.d.ts +58 -5
- package/dist/tmux-control.js +102 -14
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +31 -1
- package/dist/tmux-manager.js +40 -11
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +49 -9
- package/dist/topic-commands.js +279 -110
- package/dist/topic-commands.js.map +1 -1
- package/dist/tui-glyphs.d.ts +5 -1
- package/dist/tui-glyphs.js +6 -2
- package/dist/tui-glyphs.js.map +1 -1
- package/dist/types.d.ts +19 -2
- package/dist/ui/settings.html +1 -1
- package/dist/ui/view.html +123 -2
- package/dist/update-marker.d.ts +15 -0
- package/dist/update-marker.js +69 -0
- package/dist/update-marker.js.map +1 -0
- package/dist/usage/format-rich.d.ts +11 -0
- package/dist/usage/format-rich.js +121 -0
- package/dist/usage/format-rich.js.map +1 -0
- package/dist/usage/provider-alerts.d.ts +33 -0
- package/dist/usage/provider-alerts.js +57 -0
- package/dist/usage/provider-alerts.js.map +1 -0
- package/dist/usage/providers.d.ts +130 -0
- package/dist/usage/providers.js +1063 -0
- package/dist/usage/providers.js.map +1 -0
- package/dist/usage/statusline-usage.d.ts +20 -0
- package/dist/usage/statusline-usage.js +120 -0
- package/dist/usage/statusline-usage.js.map +1 -0
- package/dist/usage/usage-api.d.ts +50 -0
- package/dist/usage/usage-api.js +197 -0
- package/dist/usage/usage-api.js.map +1 -0
- package/dist/view-api.d.ts +3 -0
- package/dist/view-api.js +11 -2
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.js +7 -3
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +6 -0
- package/dist/web-auth.js +54 -0
- package/dist/web-auth.js.map +1 -0
- package/package.json +4 -1
- package/dist/channel/tool-tracker.d.ts +0 -13
- package/dist/channel/tool-tracker.js +0 -58
- package/dist/channel/tool-tracker.js.map +0 -1
- package/dist/daemon-entry.d.ts +0 -1
- package/dist/daemon-entry.js +0 -30
- package/dist/daemon-entry.js.map +0 -1
package/dist/topic-commands.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
/**
|
|
311
|
-
|
|
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
|
-
? this.ctx.classicChannels.getBackendByInstance(instanceName)
|
|
482
|
+
? this.ctx.classicChannels.getBackendByInstance(instanceName, this.ctx.fleetConfig?.defaults?.backend)
|
|
319
483
|
: undefined;
|
|
320
|
-
|
|
484
|
+
return this.ctx.fleetConfig?.instances[instanceName]?.backend
|
|
321
485
|
?? classicBackend
|
|
322
486
|
?? this.ctx.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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.
|
|
@@ -364,7 +505,11 @@ export class TopicCommands {
|
|
|
364
505
|
async sendCompact(instanceName) {
|
|
365
506
|
const ipc = this.ctx.instanceIpcClients.get(instanceName);
|
|
366
507
|
if (ipc?.connected) {
|
|
508
|
+
const classicBackend = this.ctx.classicChannels?.getChannelIdByInstance(instanceName)
|
|
509
|
+
? this.ctx.classicChannels.getBackendByInstance(instanceName, this.ctx.fleetConfig?.defaults?.backend)
|
|
510
|
+
: undefined;
|
|
367
511
|
const backend = this.ctx.fleetConfig?.instances[instanceName]?.backend
|
|
512
|
+
?? classicBackend
|
|
368
513
|
?? this.ctx.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
369
514
|
const cmd = compactCommandForBackend(backend);
|
|
370
515
|
ipc.send({ type: "raw_paste", content: cmd });
|
|
@@ -374,7 +519,11 @@ export class TopicCommands {
|
|
|
374
519
|
}
|
|
375
520
|
/** Send a backend-appropriate session-save command to a fleet-topic instance. */
|
|
376
521
|
async sendSave(instanceName, filename) {
|
|
522
|
+
const classicBackend = this.ctx.classicChannels?.getChannelIdByInstance(instanceName)
|
|
523
|
+
? this.ctx.classicChannels.getBackendByInstance(instanceName, this.ctx.fleetConfig?.defaults?.backend)
|
|
524
|
+
: undefined;
|
|
377
525
|
const backend = this.ctx.fleetConfig?.instances[instanceName]?.backend
|
|
526
|
+
?? classicBackend
|
|
378
527
|
?? this.ctx.fleetConfig?.defaults?.backend ?? "claude-code";
|
|
379
528
|
const cmd = saveCommandForBackend(backend, filename);
|
|
380
529
|
if (!cmd)
|
|
@@ -400,10 +549,44 @@ export class TopicCommands {
|
|
|
400
549
|
await adapter.sendText(chatId, t("restart.graceful"), { threadId });
|
|
401
550
|
process.kill(process.pid, "SIGUSR2");
|
|
402
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
|
+
}
|
|
403
580
|
async handleStatusCommand(msg) {
|
|
404
581
|
const adapter = this.getReplyAdapter(msg);
|
|
405
582
|
if (!adapter || !this.ctx.fleetConfig)
|
|
406
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
|
+
}
|
|
407
590
|
const text = await this.getStatusText();
|
|
408
591
|
await adapter.sendText(msg.chatId, text, { threadId: msg.threadId });
|
|
409
592
|
}
|
|
@@ -438,18 +621,16 @@ export class TopicCommands {
|
|
|
438
621
|
const costPaused = this.ctx.costGuard?.isLimited(name);
|
|
439
622
|
if (status === "paused")
|
|
440
623
|
pausedCount++;
|
|
441
|
-
let contextStr = "-";
|
|
442
|
-
try {
|
|
443
|
-
const data = JSON.parse(readFileSync(join(this.ctx.dataDir, "instances", name, "statusline.json"), "utf-8"));
|
|
444
|
-
if (data.context_window?.used_percentage != null) {
|
|
445
|
-
contextStr = `${Math.round(data.context_window.used_percentage)}%`;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
catch { /* file may not exist yet */ }
|
|
449
|
-
const costCents = this.ctx.costGuard?.getDailyCostCents(name) ?? 0;
|
|
450
624
|
const backend = classic
|
|
451
625
|
? this.ctx.classicChannels?.getBackendByInstance(name, this.ctx.fleetConfig.defaults?.backend) ?? "-"
|
|
452
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;
|
|
453
634
|
let icon;
|
|
454
635
|
if (costPaused || status === "paused")
|
|
455
636
|
icon = "⏸";
|
|
@@ -465,15 +646,21 @@ export class TopicCommands {
|
|
|
465
646
|
: executionState === "paused" ? "⏸ paused"
|
|
466
647
|
: "—";
|
|
467
648
|
const displayName = this.shortInstanceName(name);
|
|
468
|
-
|
|
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} |`);
|
|
469
656
|
}
|
|
470
657
|
if (rows.length === 0)
|
|
471
658
|
return "No instances configured.";
|
|
472
659
|
const lines = [
|
|
473
660
|
"## Fleet Status",
|
|
474
661
|
"",
|
|
475
|
-
"| Instance | Backend | Ctx | Cost | Status | State |",
|
|
476
|
-
"
|
|
662
|
+
"| Instance | Backend | Ctx | Effort | Cost | Status | State | IPC |",
|
|
663
|
+
"|----------|---------|-----|--------|------|--------|-------|-----|",
|
|
477
664
|
...rows,
|
|
478
665
|
"",
|
|
479
666
|
`Paused instances: ${pausedCount}`,
|
|
@@ -504,63 +691,33 @@ export class TopicCommands {
|
|
|
504
691
|
const text = this.getSysInfoText();
|
|
505
692
|
await adapter.sendText(msg.chatId, text, { threadId: msg.threadId });
|
|
506
693
|
}
|
|
507
|
-
/**
|
|
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
|
+
*/
|
|
508
702
|
getSysInfoText() {
|
|
509
703
|
const info = this.ctx.getSysInfo();
|
|
510
704
|
const upHours = Math.floor(info.uptime_seconds / 3600);
|
|
511
705
|
const upMins = Math.floor((info.uptime_seconds % 3600) / 60);
|
|
512
706
|
const require = createRequire(import.meta.url);
|
|
513
707
|
const agendVersion = require("../package.json").version ?? "unknown";
|
|
514
|
-
|
|
708
|
+
return [
|
|
515
709
|
"## System Info",
|
|
516
710
|
"",
|
|
517
711
|
"| Metric | Value |",
|
|
518
712
|
"|--------|-------|",
|
|
519
713
|
`| AgEnD | v${agendVersion} |`,
|
|
714
|
+
`| OS | ${process.platform} ${osRelease()} (${process.arch}) |`,
|
|
715
|
+
`| Node | ${process.version} |`,
|
|
716
|
+
`| tmux | ${tmuxVersion()} |`,
|
|
520
717
|
`| Uptime | ${upHours}h ${upMins}m |`,
|
|
521
718
|
`| Memory | ${info.memory_mb.rss} MB RSS |`,
|
|
522
719
|
`| Heap | ${info.memory_mb.heapUsed} / ${info.memory_mb.heapTotal} MB |`,
|
|
523
|
-
|
|
524
|
-
"## Instances",
|
|
525
|
-
"",
|
|
526
|
-
"| Name | State | IPC | Cost | Rate |",
|
|
527
|
-
"|------|-------|-----|------|------|",
|
|
528
|
-
];
|
|
529
|
-
const fleetNames = new Set(info.instances.map(inst => inst.name));
|
|
530
|
-
const classicInstances = (this.ctx.classicChannels?.getAll() ?? [])
|
|
531
|
-
.filter(ch => !fleetNames.has(ch.instanceName))
|
|
532
|
-
.map(ch => ({
|
|
533
|
-
name: ch.instanceName,
|
|
534
|
-
status: this.ctx.getInstanceStatus(ch.instanceName),
|
|
535
|
-
state: this.ctx.getInstanceExecutionState?.(ch.instanceName) ?? null,
|
|
536
|
-
ipc: this.ctx.instanceIpcClients.has(ch.instanceName),
|
|
537
|
-
costCents: this.ctx.costGuard?.getDailyCostCents(ch.instanceName) ?? 0,
|
|
538
|
-
rateLimits: null,
|
|
539
|
-
classic: true,
|
|
540
|
-
}));
|
|
541
|
-
const instances = [
|
|
542
|
-
...info.instances.map(inst => ({ ...inst, classic: false })),
|
|
543
|
-
...classicInstances,
|
|
544
|
-
];
|
|
545
|
-
for (const inst of instances) {
|
|
546
|
-
const executionState = inst.state;
|
|
547
|
-
const displayState = inst.status === "paused" ? "paused"
|
|
548
|
-
: inst.status === "stopped" || inst.status === "crashed" ? inst.status
|
|
549
|
-
: executionState ?? "running";
|
|
550
|
-
const icon = displayState === "idle" ? "🟢"
|
|
551
|
-
: displayState === "working" ? "🔵"
|
|
552
|
-
: displayState === "stuck" || displayState === "crashed" ? "🔴"
|
|
553
|
-
: displayState === "paused" ? "⏸"
|
|
554
|
-
: displayState === "stopped" ? "⚪" : "🟢";
|
|
555
|
-
const ipc = inst.ipc ? "✓" : "✗";
|
|
556
|
-
const rate = inst.rateLimits ? `5h:${inst.rateLimits.five_hour_pct}%` : "-";
|
|
557
|
-
const displayName = this.shortInstanceName(inst.name);
|
|
558
|
-
lines.push(`| ${icon} ${displayName} | ${displayState} | ${ipc} | ${formatCents(inst.costCents)} | ${rate} |`);
|
|
559
|
-
}
|
|
560
|
-
if (info.fleet_cost_limit_cents > 0) {
|
|
561
|
-
lines.push("", `Fleet cost: ${formatCents(info.fleet_cost_cents)} / ${formatCents(info.fleet_cost_limit_cents)} daily`);
|
|
562
|
-
}
|
|
563
|
-
return lines.join("\n");
|
|
720
|
+
].join("\n");
|
|
564
721
|
}
|
|
565
722
|
async handleUpdateCommand(msg) {
|
|
566
723
|
const adapter = this.getReplyAdapter(msg);
|
|
@@ -597,17 +754,26 @@ export class TopicCommands {
|
|
|
597
754
|
return;
|
|
598
755
|
}
|
|
599
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;
|
|
600
763
|
try {
|
|
601
|
-
const {
|
|
602
|
-
const
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
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";
|
|
606
771
|
}
|
|
607
772
|
catch (err) {
|
|
608
|
-
const
|
|
609
|
-
|
|
773
|
+
const e = err;
|
|
774
|
+
output = stripAnsi(e.stdout ?? e.message ?? "Doctor failed");
|
|
610
775
|
}
|
|
776
|
+
await adapter.sendText(chatId, output, { threadId });
|
|
611
777
|
}
|
|
612
778
|
/** Reply with redirect when message arrives in an unbound topic */
|
|
613
779
|
async handleUnboundTopic(msg) {
|
|
@@ -726,18 +892,20 @@ export class TopicCommands {
|
|
|
726
892
|
headers: { "Content-Type": "application/json" },
|
|
727
893
|
body: JSON.stringify({
|
|
728
894
|
commands: [
|
|
729
|
-
{ command: "status", description: "
|
|
895
|
+
{ command: "status", description: "🔒 Fleet status, per-instance costs and health" },
|
|
730
896
|
{ command: "sysinfo", description: "System diagnostics" },
|
|
731
897
|
{ command: "dashboard", description: "🔒 Get dashboard URLs" },
|
|
732
898
|
{ command: "ctx", description: "Show context usage" },
|
|
733
899
|
{ command: "compact", description: "Compact agent context" },
|
|
734
900
|
{ command: "model", description: "🔒 Switch model (admin only)" },
|
|
901
|
+
{ command: "effort", description: "🔒 Set reasoning effort (admin only)" },
|
|
735
902
|
{ command: "pause", description: "🔒 Pause an idle instance" },
|
|
736
903
|
{ command: "wake", description: "🔒 Wake a paused instance" },
|
|
737
904
|
{ command: "restart", description: "🔒 Graceful restart all instances" },
|
|
738
905
|
{ command: "collab", description: "🔒 Toggle bot/webhook mode" },
|
|
739
906
|
{ command: "update", description: "🔒 Update AgEnD to latest" },
|
|
740
907
|
{ command: "doctor", description: "🔒 Run health diagnostics" },
|
|
908
|
+
{ command: "usage", description: "AI subscription usage" },
|
|
741
909
|
],
|
|
742
910
|
scope: { type: "chat", chat_id: ch.group_id },
|
|
743
911
|
}),
|
|
@@ -752,6 +920,7 @@ export class TopicCommands {
|
|
|
752
920
|
{ command: "stop", description: "🔒 Stop the agent" },
|
|
753
921
|
{ command: "compact", description: "🔒 Compact agent context" },
|
|
754
922
|
{ command: "model", description: "🔒 Switch model (admin only)" },
|
|
923
|
+
{ command: "effort", description: "🔒 Set reasoning effort (admin only)" },
|
|
755
924
|
{ command: "pause", description: "🔒 Pause the agent" },
|
|
756
925
|
{ command: "wake", description: "🔒 Wake the agent" },
|
|
757
926
|
{ command: "ctx", description: "Show context usage" },
|