@songsid/agend 2.1.11-beta.1 → 2.1.11-beta.3
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/backend/kiro-engine-ledger.d.ts +36 -0
- package/dist/backend/kiro-engine-ledger.js +113 -0
- package/dist/backend/kiro-engine-ledger.js.map +1 -0
- package/dist/backend/kiro-kas-store.d.ts +11 -0
- package/dist/backend/kiro-kas-store.js +67 -0
- package/dist/backend/kiro-kas-store.js.map +1 -0
- package/dist/backend/kiro-v3-identity.d.ts +28 -0
- package/dist/backend/kiro-v3-identity.js +213 -0
- package/dist/backend/kiro-v3-identity.js.map +1 -0
- package/dist/backend/kiro.d.ts +7 -0
- package/dist/backend/kiro.js +34 -1
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/opencode-permission.d.ts +49 -0
- package/dist/backend/opencode-permission.js +181 -0
- package/dist/backend/opencode-permission.js.map +1 -0
- package/dist/backend/opencode.d.ts +6 -0
- package/dist/backend/opencode.js +46 -2
- package/dist/backend/opencode.js.map +1 -1
- package/dist/backend/types.d.ts +15 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.js +26 -25
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/ipc-bridge.d.ts +1 -0
- package/dist/channel/ipc-bridge.js +1 -1
- package/dist/channel/ipc-bridge.js.map +1 -1
- package/dist/channel/mcp-tools.js +2 -0
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/cli.js +27 -6
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +99 -0
- package/dist/command-table.js +125 -0
- package/dist/command-table.js.map +1 -0
- package/dist/daemon.d.ts +36 -0
- package/dist/daemon.js +165 -2
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +16 -0
- package/dist/delivery-outbox.js +55 -6
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/e2e-isolation.d.ts +122 -0
- package/dist/e2e-isolation.js +386 -0
- package/dist/e2e-isolation.js.map +1 -0
- package/dist/fleet-manager.d.ts +24 -0
- package/dist/fleet-manager.js +301 -489
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +56 -0
- package/dist/instance-lifecycle.d.ts +2 -0
- package/dist/instance-lifecycle.js +14 -0
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/kiro-engine-status.d.ts +71 -0
- package/dist/kiro-engine-status.js +68 -0
- package/dist/kiro-engine-status.js.map +1 -0
- package/dist/locale.js +16 -4
- package/dist/locale.js.map +1 -1
- package/dist/login-flows.js +10 -0
- package/dist/login-flows.js.map +1 -1
- package/dist/outbound-handlers.js +16 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +3 -0
- package/dist/outbound-schemas.js +3 -0
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/paths.d.ts +19 -0
- package/dist/paths.js +18 -5
- package/dist/paths.js.map +1 -1
- package/dist/tool-permissions.js +4 -2
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.js +3 -2
- package/dist/topic-commands.js.map +1 -1
- package/dist/ui/web-terminal/terminal-input.js +69 -0
- package/dist/ui/web-terminal/terminal.css +6 -0
- package/dist/ui/web-terminal/terminal.html +9 -1
- package/dist/ui/web-terminal/terminal.js +31 -2
- package/dist/update-check.d.ts +40 -2
- package/dist/update-check.js +101 -3
- package/dist/update-check.js.map +1 -1
- package/dist/web-terminal-http.js +1 -0
- package/dist/web-terminal-http.js.map +1 -1
- package/package.json +1 -1
package/dist/fleet-manager.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
|
|
2
|
-
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { freemem, totalmem, cpus, homedir } from "node:os";
|
|
5
5
|
import { createServer } from "node:http";
|
|
@@ -62,6 +62,8 @@ import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
|
|
|
62
62
|
import { LoginSession } from "./login-manager.js";
|
|
63
63
|
import { tightenInstanceDirs } from "./private-dir.js";
|
|
64
64
|
import { decideSlash } from "./slash-authz.js";
|
|
65
|
+
import { commandSpec, decideCommand } from "./command-table.js";
|
|
66
|
+
import { UPDATE_COMMAND, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
|
|
65
67
|
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
66
68
|
import { runBeforeDeadline } from "./deadline.js";
|
|
67
69
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
@@ -457,6 +459,7 @@ export class FleetManager {
|
|
|
457
459
|
startupRetryNotices = new Map();
|
|
458
460
|
/** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
|
|
459
461
|
unsupportedCliNotices = new Map();
|
|
462
|
+
kiroIncompatNotices = new Map();
|
|
460
463
|
/** Backoff between automatic startup retries; the last step repeats while the backend is down. */
|
|
461
464
|
static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
|
|
462
465
|
/** Hard cap on automatic startup retries (3 backoff steps + up to 3 more during a backend outage). */
|
|
@@ -1426,7 +1429,7 @@ export class FleetManager {
|
|
|
1426
1429
|
const action = data.command;
|
|
1427
1430
|
const classicName = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1428
1431
|
if (classicName) {
|
|
1429
|
-
if (!this.
|
|
1432
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
1430
1433
|
await data.respond(t("permission.denied"));
|
|
1431
1434
|
return;
|
|
1432
1435
|
}
|
|
@@ -1490,8 +1493,10 @@ export class FleetManager {
|
|
|
1490
1493
|
this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
|
|
1491
1494
|
}
|
|
1492
1495
|
const { spawn } = await import("node:child_process");
|
|
1493
|
-
|
|
1494
|
-
|
|
1496
|
+
// Plain `agend update`: the CLI picks the channel from the version it is about to replace (a beta install
|
|
1497
|
+
// stays on beta). Deciding here, from the package.json next to THIS code, read a source checkout's 1.22.0 as
|
|
1498
|
+
// "not a beta" and sent a beta install to @latest.
|
|
1499
|
+
const command = UPDATE_COMMAND;
|
|
1495
1500
|
const origin = `slash /update by ${adapterId}:${data.userId}`;
|
|
1496
1501
|
recordInternalRequest(this.dataDir, "update", origin);
|
|
1497
1502
|
const child = spawn("sh", ["-c", `sleep 2 && ${command}`], {
|
|
@@ -2220,6 +2225,230 @@ export class FleetManager {
|
|
|
2220
2225
|
return "disabled";
|
|
2221
2226
|
return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
|
|
2222
2227
|
}
|
|
2228
|
+
/**
|
|
2229
|
+
* Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
|
|
2230
|
+
*
|
|
2231
|
+
* Two checks before any command's own code runs, and the order matters: the DOOR (src/slash-authz.ts — may
|
|
2232
|
+
* this caller speak here at all) and then the COMMAND TABLE (src/command-table.ts — does this command apply
|
|
2233
|
+
* in this kind of channel, and which kind of admin does it need). The table can only narrow what the door
|
|
2234
|
+
* let through. A command nobody registered is answered, not left to time out.
|
|
2235
|
+
*/
|
|
2236
|
+
async dispatchSlash(data, adapterId, adapter) {
|
|
2237
|
+
const scope = await this.authorizeSlash(data, adapterId);
|
|
2238
|
+
if (!scope)
|
|
2239
|
+
return;
|
|
2240
|
+
const spec = commandSpec(data.command);
|
|
2241
|
+
if (!spec) {
|
|
2242
|
+
this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
|
|
2243
|
+
await data.respond(t("slash.unknown_command")).catch(() => { });
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
const decision = decideCommand(spec, scope, {
|
|
2247
|
+
fleetAdmin: () => this.fleetAdminGate(data.userId, adapterId),
|
|
2248
|
+
channelAdmin: () => this.isModelAdmin(data.userId, data.channelId, adapterId),
|
|
2249
|
+
classicAdmin: () => !!this.classicChannels?.isAdmin(data.userId),
|
|
2250
|
+
});
|
|
2251
|
+
if (!decision.allow) {
|
|
2252
|
+
this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
|
|
2253
|
+
const [key, ...args] = decision.reply;
|
|
2254
|
+
await data.respond(t(key, ...args)).catch(() => { });
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
if (data.command === "start") {
|
|
2258
|
+
await this.handleClassicStartSlash(data, adapterId);
|
|
2259
|
+
}
|
|
2260
|
+
else if (data.command === "stop") {
|
|
2261
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
2262
|
+
await data.respond(reply);
|
|
2263
|
+
}
|
|
2264
|
+
else if (data.command === "pause" || data.command === "wake") {
|
|
2265
|
+
await this.handlePauseWakeSlash(data, adapterId);
|
|
2266
|
+
}
|
|
2267
|
+
else if (data.command === "chat") {
|
|
2268
|
+
const text = data.text ?? "";
|
|
2269
|
+
if (!text) {
|
|
2270
|
+
await data.respond(t("chat.usage"));
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
2274
|
+
if (!name) {
|
|
2275
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
|
|
2279
|
+
const replyMsgId = await data.respond(textForm(status.platform, status.received));
|
|
2280
|
+
const username = data.username ?? data.userId;
|
|
2281
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
2282
|
+
await this.forwardToClassicInstance(name, text, {
|
|
2283
|
+
chatId: data.channelId,
|
|
2284
|
+
threadId: data.channelId,
|
|
2285
|
+
messageId: replyMsgId ?? "",
|
|
2286
|
+
userId: data.userId,
|
|
2287
|
+
username,
|
|
2288
|
+
source: "discord",
|
|
2289
|
+
timestamp: new Date(),
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
else if (data.command === "save") {
|
|
2293
|
+
await this.handleSlashSave(data, adapterId);
|
|
2294
|
+
}
|
|
2295
|
+
else if (data.command === "load") {
|
|
2296
|
+
// load is kiro-cli/classic only — no claude-code equivalent. (Classic admins only: the command table.)
|
|
2297
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
2298
|
+
if (!name) {
|
|
2299
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
const filename = data.options?.filename;
|
|
2303
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
2304
|
+
await data.respond(t("filename.invalid"));
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
2308
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
2309
|
+
}
|
|
2310
|
+
else if (data.command === "model") {
|
|
2311
|
+
await this.handleModelSlash(data, adapterId);
|
|
2312
|
+
}
|
|
2313
|
+
else if (data.command === "clear") {
|
|
2314
|
+
await this.handleClearSlash(data, adapterId);
|
|
2315
|
+
}
|
|
2316
|
+
else if (data.command === "effort") {
|
|
2317
|
+
await this.handleEffortSlash(data, adapterId);
|
|
2318
|
+
}
|
|
2319
|
+
else if (data.command === "cancel") {
|
|
2320
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2321
|
+
if (!name) {
|
|
2322
|
+
await data.respond(t("classic.no_agent"));
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
const ok = this.cancelInstance(name);
|
|
2326
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
2327
|
+
}
|
|
2328
|
+
else if (data.command === "ctx") {
|
|
2329
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2330
|
+
if (!name) {
|
|
2331
|
+
await data.respond(t("classic.no_agent"));
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
2335
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
2336
|
+
}
|
|
2337
|
+
else if (data.command === "collab") {
|
|
2338
|
+
// The scope the door and the table authorized is the scope this acts in. A channel can be in both the
|
|
2339
|
+
// ClassicBot registry and the routing table (two configurations that each validate); the door and
|
|
2340
|
+
// `isModelAdmin` read the registry first, so a ClassicBot admin was judged for the ClassicBot channel
|
|
2341
|
+
// here and must not be handed the fleet instance's collab switch by a second, routing-first lookup.
|
|
2342
|
+
const collabTarget2 = scope === "classic" ? undefined : this.routing.resolve(data.channelId);
|
|
2343
|
+
if (collabTarget2) {
|
|
2344
|
+
const isCollab = this.toggleFleetCollab(collabTarget2.name);
|
|
2345
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
if (!this.classicChannels?.isClassicChannel(data.channelId, adapterId)) {
|
|
2349
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
2353
|
+
await data.respond(newState
|
|
2354
|
+
? t("collab.on.classic")
|
|
2355
|
+
: t("collab.off.classic"));
|
|
2356
|
+
}
|
|
2357
|
+
else if (data.command === "update") {
|
|
2358
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2359
|
+
}
|
|
2360
|
+
else if (data.command === "doctor") {
|
|
2361
|
+
await data.respond(await this.runBackendDoctor());
|
|
2362
|
+
}
|
|
2363
|
+
else if (data.command === "usage") {
|
|
2364
|
+
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
2365
|
+
// the adapter defers non-chat commands that way — so it never spams the
|
|
2366
|
+
// channel either way.
|
|
2367
|
+
try {
|
|
2368
|
+
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2369
|
+
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2370
|
+
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2371
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2372
|
+
}
|
|
2373
|
+
catch (err) {
|
|
2374
|
+
await data.respond(t("usage.failed", err.message));
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
else if (data.command === "tips") {
|
|
2378
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2379
|
+
}
|
|
2380
|
+
else if (data.command === "status") {
|
|
2381
|
+
// Admin-gated (like the topic path): the merged table shows every
|
|
2382
|
+
// instance's cost and IPC health.
|
|
2383
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
2384
|
+
await data.respond(t("cmd.admin_required", "/status"));
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
const text = await this.topicCommands.getStatusText();
|
|
2388
|
+
await data.respond(text);
|
|
2389
|
+
}
|
|
2390
|
+
else if (data.command === "sysinfo") {
|
|
2391
|
+
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
2392
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
2393
|
+
}
|
|
2394
|
+
else if (data.command === "dashboard") {
|
|
2395
|
+
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
2396
|
+
// the web-token-bearing URLs are only visible to the caller.
|
|
2397
|
+
await data.respond(this.topicCommands.getDashboardText());
|
|
2398
|
+
}
|
|
2399
|
+
else if (data.command === "restart") {
|
|
2400
|
+
await this.handleRestartSlash(data, adapterId);
|
|
2401
|
+
}
|
|
2402
|
+
else if (data.command === "compact") {
|
|
2403
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2404
|
+
if (!name) {
|
|
2405
|
+
await data.respond(t("classic.no_agent"));
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
2409
|
+
await data.respond(result);
|
|
2410
|
+
}
|
|
2411
|
+
else if (data.command === "steer") {
|
|
2412
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2413
|
+
if (!name) {
|
|
2414
|
+
await data.respond(t("classic.no_agent"));
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2418
|
+
if (!steerText) {
|
|
2419
|
+
await data.respond(t("steer.usage"));
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2423
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2424
|
+
// rerouting the instance's replies to the slash context.
|
|
2425
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2426
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2427
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2428
|
+
});
|
|
2429
|
+
await data.respond(result);
|
|
2430
|
+
}
|
|
2431
|
+
else if (data.command === "btw") {
|
|
2432
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2433
|
+
if (!name) {
|
|
2434
|
+
await data.respond(t("classic.no_agent"));
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2438
|
+
if (!btwText) {
|
|
2439
|
+
await data.respond(t("btw.usage"));
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2443
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2444
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2445
|
+
});
|
|
2446
|
+
await data.respond(result);
|
|
2447
|
+
}
|
|
2448
|
+
else if (data.command === "login") {
|
|
2449
|
+
await this.handleLoginSlash(data, adapterId, adapter);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2223
2452
|
/**
|
|
2224
2453
|
* The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning).
|
|
2225
2454
|
* Answers the caller itself when it refuses, so a refused command costs one reply and does nothing else.
|
|
@@ -2229,6 +2458,8 @@ export class FleetManager {
|
|
|
2229
2458
|
const classic = !!this.classicChannels?.isClassicChannel(channelId, adapterId);
|
|
2230
2459
|
const fleetTarget = classic ? undefined : this.routing.resolve(channelId);
|
|
2231
2460
|
const scope = classic ? "classic" : fleetTarget ? "fleet" : "none";
|
|
2461
|
+
// The table tells the General dispatcher from an instance's own channel; the door does not need to.
|
|
2462
|
+
const commandScope = classic ? "classic" : fleetTarget ? (fleetTarget.kind === "general" ? "general" : "fleet") : "none";
|
|
2232
2463
|
let speaker = "denied";
|
|
2233
2464
|
if (scope !== "classic") {
|
|
2234
2465
|
// The same world the typed-message path would consult: the adapter that owns the channel's instance,
|
|
@@ -2253,12 +2484,12 @@ export class FleetManager {
|
|
|
2253
2484
|
};
|
|
2254
2485
|
const decision = decideSlash(facts);
|
|
2255
2486
|
if (decision.allow)
|
|
2256
|
-
return
|
|
2487
|
+
return commandScope;
|
|
2257
2488
|
this.logger.info({ command: data.command, reason: decision.reason, adapterId, guildId: data.guildId ?? null, channelId, scope }, "Slash command refused");
|
|
2258
2489
|
await data.respond(t(decision.reason === "dm" ? "slash.dm_unsupported"
|
|
2259
2490
|
: decision.reason === "wrong-guild" ? "slash.wrong_server"
|
|
2260
2491
|
: "not_authorized")).catch(() => { });
|
|
2261
|
-
return
|
|
2492
|
+
return null;
|
|
2262
2493
|
}
|
|
2263
2494
|
/** Phase 2: delivery_worker for a target (instance override → fleet default → wake_only). */
|
|
2264
2495
|
deliveryWorkerMode(target) {
|
|
@@ -2868,8 +3099,58 @@ export class FleetManager {
|
|
|
2868
3099
|
return false;
|
|
2869
3100
|
this.logger.error({ name, reason: err.message }, `Not starting ${what}: the installed CLI cannot run it as configured`);
|
|
2870
3101
|
this.queueUnsupportedCliNotice(name, err.message);
|
|
3102
|
+
this.queueKiroIncompatNotice(name, err.message);
|
|
2871
3103
|
return true;
|
|
2872
3104
|
}
|
|
3105
|
+
/**
|
|
3106
|
+
* LifecycleContext: a kiro instance the installed kiro-cli cannot run as
|
|
3107
|
+
* configured (#1109) — at start, or at a respawn after kiro-cli replaced
|
|
3108
|
+
* itself. The operator already gets the plain notice; this tells every
|
|
3109
|
+
* General, as an agent, so it can explain the kiro engine move when asked.
|
|
3110
|
+
* One notice per refusal reason, like the operator's.
|
|
3111
|
+
*/
|
|
3112
|
+
queueKiroIncompatNotice(name, reason) {
|
|
3113
|
+
// The effective backend, ClassicBot channels included: they are not in fleetConfig.instances.
|
|
3114
|
+
if (this.backendNameForInstance(name) !== "kiro-cli")
|
|
3115
|
+
return;
|
|
3116
|
+
const pending = this.kiroIncompatNotices.get(reason);
|
|
3117
|
+
if (pending) {
|
|
3118
|
+
if (!pending.names.includes(name))
|
|
3119
|
+
pending.names.push(name);
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
const timer = setTimeout(() => {
|
|
3123
|
+
const entry = this.kiroIncompatNotices.get(reason);
|
|
3124
|
+
this.kiroIncompatNotices.delete(reason);
|
|
3125
|
+
if (entry)
|
|
3126
|
+
this.sendKiroIncompatToGenerals(entry.names, reason);
|
|
3127
|
+
}, FleetManager.STARTUP_RETRY_NOTICE_AGGREGATE_MS);
|
|
3128
|
+
timer.unref?.();
|
|
3129
|
+
this.kiroIncompatNotices.set(reason, { names: [name], timer });
|
|
3130
|
+
}
|
|
3131
|
+
/**
|
|
3132
|
+
* Held in the delivery outbox until each General takes it: the same kiro-cli
|
|
3133
|
+
* may have stopped the General too, and it should still hear once it is back.
|
|
3134
|
+
* Admitted once per day per refusal (reason + names) and General.
|
|
3135
|
+
*/
|
|
3136
|
+
sendKiroIncompatToGenerals(names, reason, now = new Date()) {
|
|
3137
|
+
const generals = Object.entries(this.fleetConfig?.instances ?? {})
|
|
3138
|
+
.filter(([, config]) => config.general_topic === true).map(([general]) => general);
|
|
3139
|
+
if (generals.length === 0)
|
|
3140
|
+
return;
|
|
3141
|
+
try {
|
|
3142
|
+
this.ensureDeliveryOutbox();
|
|
3143
|
+
const sorted = [...names].sort();
|
|
3144
|
+
const digest = createHash("sha256").update(`${reason}\u0000${sorted.join(",")}`).digest("hex").slice(0, 16);
|
|
3145
|
+
const content = t("fleet.kiro_incompat_general", sorted.join(", "), reason);
|
|
3146
|
+
for (const general of generals) {
|
|
3147
|
+
this.deliveryOutbox.admitSystemNotice(general, `kiro-incompat:${now.toISOString().slice(0, 10)}:${digest}:${general}`, content, now);
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
catch (err) {
|
|
3151
|
+
this.logger.warn({ err, names }, "Could not queue the kiro incompatibility notice for General");
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
2873
3154
|
/**
|
|
2874
3155
|
* One notice per refusal reason: a kiro-cli that dropped the legacy UI
|
|
2875
3156
|
* refuses every kiro instance at once, and that is one message, not ten.
|
|
@@ -4230,270 +4511,7 @@ export class FleetManager {
|
|
|
4230
4511
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
4231
4512
|
if (!isCurrentAdapter())
|
|
4232
4513
|
return;
|
|
4233
|
-
|
|
4234
|
-
return;
|
|
4235
|
-
if (data.command === "start") {
|
|
4236
|
-
await this.handleClassicStartSlash(data, adapterId);
|
|
4237
|
-
}
|
|
4238
|
-
else if (data.command === "stop") {
|
|
4239
|
-
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
4240
|
-
await data.respond(reply);
|
|
4241
|
-
}
|
|
4242
|
-
else if (data.command === "pause" || data.command === "wake") {
|
|
4243
|
-
await this.handlePauseWakeSlash(data, adapterId);
|
|
4244
|
-
}
|
|
4245
|
-
else if (data.command === "chat") {
|
|
4246
|
-
const text = data.text ?? "";
|
|
4247
|
-
if (!text) {
|
|
4248
|
-
await data.respond(t("chat.usage"));
|
|
4249
|
-
return;
|
|
4250
|
-
}
|
|
4251
|
-
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
4252
|
-
if (!name) {
|
|
4253
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4254
|
-
return;
|
|
4255
|
-
}
|
|
4256
|
-
const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
|
|
4257
|
-
const replyMsgId = await data.respond(textForm(status.platform, status.received));
|
|
4258
|
-
const username = data.username ?? data.userId;
|
|
4259
|
-
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
4260
|
-
await this.forwardToClassicInstance(name, text, {
|
|
4261
|
-
chatId: data.channelId,
|
|
4262
|
-
threadId: data.channelId,
|
|
4263
|
-
messageId: replyMsgId ?? "",
|
|
4264
|
-
userId: data.userId,
|
|
4265
|
-
username,
|
|
4266
|
-
source: "discord",
|
|
4267
|
-
timestamp: new Date(),
|
|
4268
|
-
});
|
|
4269
|
-
}
|
|
4270
|
-
else if (data.command === "save") {
|
|
4271
|
-
await this.handleSlashSave(data, adapterId);
|
|
4272
|
-
}
|
|
4273
|
-
else if (data.command === "load") {
|
|
4274
|
-
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
4275
|
-
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
4276
|
-
await data.respond(t("admin.required"));
|
|
4277
|
-
return;
|
|
4278
|
-
}
|
|
4279
|
-
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
4280
|
-
if (!name) {
|
|
4281
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4282
|
-
return;
|
|
4283
|
-
}
|
|
4284
|
-
const filename = data.options?.filename;
|
|
4285
|
-
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
4286
|
-
await data.respond(t("filename.invalid"));
|
|
4287
|
-
return;
|
|
4288
|
-
}
|
|
4289
|
-
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
4290
|
-
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
4291
|
-
}
|
|
4292
|
-
else if (data.command === "compact") {
|
|
4293
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4294
|
-
if (!name) {
|
|
4295
|
-
await data.respond(t("classic.no_agent"));
|
|
4296
|
-
return;
|
|
4297
|
-
}
|
|
4298
|
-
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
4299
|
-
await data.respond(result);
|
|
4300
|
-
}
|
|
4301
|
-
else if (data.command === "steer") {
|
|
4302
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4303
|
-
if (!name) {
|
|
4304
|
-
await data.respond(t("classic.no_agent"));
|
|
4305
|
-
return;
|
|
4306
|
-
}
|
|
4307
|
-
const steerText = String(data.options?.message ?? "").trim();
|
|
4308
|
-
if (!steerText) {
|
|
4309
|
-
await data.respond(t("steer.usage"));
|
|
4310
|
-
return;
|
|
4311
|
-
}
|
|
4312
|
-
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
4313
|
-
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
4314
|
-
// rerouting the instance's replies to the slash context.
|
|
4315
|
-
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
4316
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4317
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4318
|
-
});
|
|
4319
|
-
await data.respond(result);
|
|
4320
|
-
}
|
|
4321
|
-
else if (data.command === "btw") {
|
|
4322
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4323
|
-
if (!name) {
|
|
4324
|
-
await data.respond(t("classic.no_agent"));
|
|
4325
|
-
return;
|
|
4326
|
-
}
|
|
4327
|
-
const btwText = String(data.options?.message ?? "").trim();
|
|
4328
|
-
if (!btwText) {
|
|
4329
|
-
await data.respond(t("btw.usage"));
|
|
4330
|
-
return;
|
|
4331
|
-
}
|
|
4332
|
-
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
4333
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4334
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4335
|
-
});
|
|
4336
|
-
await data.respond(result);
|
|
4337
|
-
}
|
|
4338
|
-
else if (data.command === "login") {
|
|
4339
|
-
await this.handleLoginSlash(data, adapterId, this.adapter);
|
|
4340
|
-
}
|
|
4341
|
-
else if (data.command === "clear") {
|
|
4342
|
-
await this.handleClearSlash(data, adapterId);
|
|
4343
|
-
}
|
|
4344
|
-
else if (data.command === "model") {
|
|
4345
|
-
await this.handleModelSlash(data, adapterId);
|
|
4346
|
-
}
|
|
4347
|
-
else if (data.command === "effort") {
|
|
4348
|
-
await this.handleEffortSlash(data, adapterId);
|
|
4349
|
-
}
|
|
4350
|
-
else if (data.command === "cancel") {
|
|
4351
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4352
|
-
if (!name) {
|
|
4353
|
-
await data.respond(t("classic.no_agent"));
|
|
4354
|
-
return;
|
|
4355
|
-
}
|
|
4356
|
-
const ok = this.cancelInstance(name);
|
|
4357
|
-
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
4358
|
-
}
|
|
4359
|
-
else if (data.command === "ctx") {
|
|
4360
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4361
|
-
if (!name) {
|
|
4362
|
-
await data.respond(t("classic.no_agent"));
|
|
4363
|
-
return;
|
|
4364
|
-
}
|
|
4365
|
-
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
4366
|
-
await data.respond(await this.topicCommands.getCtxText(name));
|
|
4367
|
-
}
|
|
4368
|
-
else if (data.command === "collab") {
|
|
4369
|
-
// Classic no longer lives in the routing engine, so a routing hit here is
|
|
4370
|
-
// always a fleet-topic instance.
|
|
4371
|
-
const collabTarget = this.routing.resolve(data.channelId);
|
|
4372
|
-
if (collabTarget) {
|
|
4373
|
-
if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
|
|
4374
|
-
await data.respond(t("not_authorized"));
|
|
4375
|
-
return;
|
|
4376
|
-
}
|
|
4377
|
-
const isCollab = this.toggleFleetCollab(collabTarget.name);
|
|
4378
|
-
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
4379
|
-
return;
|
|
4380
|
-
}
|
|
4381
|
-
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
4382
|
-
await data.respond(t("admin.required"));
|
|
4383
|
-
return;
|
|
4384
|
-
}
|
|
4385
|
-
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
4386
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4387
|
-
return;
|
|
4388
|
-
}
|
|
4389
|
-
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
4390
|
-
await data.respond(newState
|
|
4391
|
-
? t("collab.on.classic")
|
|
4392
|
-
: t("collab.off.classic"));
|
|
4393
|
-
}
|
|
4394
|
-
else if (data.command === "update") {
|
|
4395
|
-
await this.handleUpdateSlash(data, adapterId);
|
|
4396
|
-
}
|
|
4397
|
-
else if (data.command === "doctor") {
|
|
4398
|
-
if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
|
|
4399
|
-
await data.respond(t("not_authorized"));
|
|
4400
|
-
return;
|
|
4401
|
-
}
|
|
4402
|
-
await data.respond(await this.runBackendDoctor());
|
|
4403
|
-
}
|
|
4404
|
-
else if (data.command === "usage") {
|
|
4405
|
-
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
4406
|
-
// the adapter defers non-chat commands that way — so it never spams the
|
|
4407
|
-
// channel either way.
|
|
4408
|
-
try {
|
|
4409
|
-
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
4410
|
-
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
4411
|
-
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
4412
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
4413
|
-
}
|
|
4414
|
-
catch (err) {
|
|
4415
|
-
await data.respond(t("usage.failed", err.message));
|
|
4416
|
-
}
|
|
4417
|
-
}
|
|
4418
|
-
else if (data.command === "tips") {
|
|
4419
|
-
await this.handleTipsSlash(data, adapterId);
|
|
4420
|
-
}
|
|
4421
|
-
else if (data.command === "status") {
|
|
4422
|
-
// Admin-gated (like the topic path): the merged table shows every
|
|
4423
|
-
// instance's cost and IPC health.
|
|
4424
|
-
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
4425
|
-
await data.respond(t("cmd.admin_required", "/status"));
|
|
4426
|
-
return;
|
|
4427
|
-
}
|
|
4428
|
-
const text = await this.topicCommands.getStatusText();
|
|
4429
|
-
await data.respond(text);
|
|
4430
|
-
}
|
|
4431
|
-
else if (data.command === "sysinfo") {
|
|
4432
|
-
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
4433
|
-
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
4434
|
-
}
|
|
4435
|
-
else if (data.command === "dashboard") {
|
|
4436
|
-
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
4437
|
-
// the web-token-bearing URLs are only visible to the caller.
|
|
4438
|
-
const gate = this.fleetAdminGate(data.userId, adapterId);
|
|
4439
|
-
if (gate !== "ok") {
|
|
4440
|
-
await data.respond(t(gate === "disabled" ? "dashboard.disabled" : "not_authorized"));
|
|
4441
|
-
return;
|
|
4442
|
-
}
|
|
4443
|
-
await data.respond(this.topicCommands.getDashboardText());
|
|
4444
|
-
}
|
|
4445
|
-
else if (data.command === "restart") {
|
|
4446
|
-
await this.handleRestartSlash(data, adapterId);
|
|
4447
|
-
}
|
|
4448
|
-
else if (data.command === "compact") {
|
|
4449
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4450
|
-
if (!name) {
|
|
4451
|
-
await data.respond(t("classic.no_agent"));
|
|
4452
|
-
return;
|
|
4453
|
-
}
|
|
4454
|
-
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
4455
|
-
await data.respond(result);
|
|
4456
|
-
}
|
|
4457
|
-
else if (data.command === "steer") {
|
|
4458
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4459
|
-
if (!name) {
|
|
4460
|
-
await data.respond(t("classic.no_agent"));
|
|
4461
|
-
return;
|
|
4462
|
-
}
|
|
4463
|
-
const steerText = String(data.options?.message ?? "").trim();
|
|
4464
|
-
if (!steerText) {
|
|
4465
|
-
await data.respond(t("steer.usage"));
|
|
4466
|
-
return;
|
|
4467
|
-
}
|
|
4468
|
-
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
4469
|
-
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
4470
|
-
// rerouting the instance's replies to the slash context.
|
|
4471
|
-
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
4472
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4473
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4474
|
-
});
|
|
4475
|
-
await data.respond(result);
|
|
4476
|
-
}
|
|
4477
|
-
else if (data.command === "btw") {
|
|
4478
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4479
|
-
if (!name) {
|
|
4480
|
-
await data.respond(t("classic.no_agent"));
|
|
4481
|
-
return;
|
|
4482
|
-
}
|
|
4483
|
-
const btwText = String(data.options?.message ?? "").trim();
|
|
4484
|
-
if (!btwText) {
|
|
4485
|
-
await data.respond(t("btw.usage"));
|
|
4486
|
-
return;
|
|
4487
|
-
}
|
|
4488
|
-
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
4489
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4490
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4491
|
-
});
|
|
4492
|
-
await data.respond(result);
|
|
4493
|
-
}
|
|
4494
|
-
else if (data.command === "login") {
|
|
4495
|
-
await this.handleLoginSlash(data, adapterId, this.adapter);
|
|
4496
|
-
}
|
|
4514
|
+
await this.dispatchSlash(data, adapterId, adapter);
|
|
4497
4515
|
}, this.logger, "adapter.slash_command"));
|
|
4498
4516
|
await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
|
|
4499
4517
|
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
@@ -4597,221 +4615,7 @@ export class FleetManager {
|
|
|
4597
4615
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
4598
4616
|
if (!isCurrentAdapter())
|
|
4599
4617
|
return;
|
|
4600
|
-
|
|
4601
|
-
return;
|
|
4602
|
-
if (data.command === "start") {
|
|
4603
|
-
await this.handleClassicStartSlash(data, adapterId);
|
|
4604
|
-
}
|
|
4605
|
-
else if (data.command === "stop") {
|
|
4606
|
-
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
4607
|
-
await data.respond(reply);
|
|
4608
|
-
}
|
|
4609
|
-
else if (data.command === "pause" || data.command === "wake") {
|
|
4610
|
-
await this.handlePauseWakeSlash(data, adapterId);
|
|
4611
|
-
}
|
|
4612
|
-
else if (data.command === "chat") {
|
|
4613
|
-
const text = data.text ?? "";
|
|
4614
|
-
if (!text) {
|
|
4615
|
-
await data.respond(t("chat.usage"));
|
|
4616
|
-
return;
|
|
4617
|
-
}
|
|
4618
|
-
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
4619
|
-
if (!name) {
|
|
4620
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4621
|
-
return;
|
|
4622
|
-
}
|
|
4623
|
-
const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
|
|
4624
|
-
const replyMsgId = await data.respond(textForm(status.platform, status.received));
|
|
4625
|
-
const username = data.username ?? data.userId;
|
|
4626
|
-
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
4627
|
-
await this.forwardToClassicInstance(name, text, {
|
|
4628
|
-
chatId: data.channelId,
|
|
4629
|
-
threadId: data.channelId,
|
|
4630
|
-
messageId: replyMsgId ?? "",
|
|
4631
|
-
userId: data.userId,
|
|
4632
|
-
username,
|
|
4633
|
-
source: channelConfig.type,
|
|
4634
|
-
timestamp: new Date(),
|
|
4635
|
-
});
|
|
4636
|
-
}
|
|
4637
|
-
else if (data.command === "save") {
|
|
4638
|
-
await this.handleSlashSave(data, adapterId);
|
|
4639
|
-
}
|
|
4640
|
-
else if (data.command === "load") {
|
|
4641
|
-
// load is kiro-cli/classic only — no claude-code equivalent.
|
|
4642
|
-
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
4643
|
-
await data.respond(t("admin.required"));
|
|
4644
|
-
return;
|
|
4645
|
-
}
|
|
4646
|
-
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
4647
|
-
if (!name) {
|
|
4648
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4649
|
-
return;
|
|
4650
|
-
}
|
|
4651
|
-
const filename = data.options?.filename;
|
|
4652
|
-
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
4653
|
-
await data.respond(t("filename.invalid"));
|
|
4654
|
-
return;
|
|
4655
|
-
}
|
|
4656
|
-
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
4657
|
-
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
4658
|
-
}
|
|
4659
|
-
else if (data.command === "model") {
|
|
4660
|
-
await this.handleModelSlash(data, adapterId);
|
|
4661
|
-
}
|
|
4662
|
-
else if (data.command === "clear") {
|
|
4663
|
-
await this.handleClearSlash(data, adapterId);
|
|
4664
|
-
}
|
|
4665
|
-
else if (data.command === "effort") {
|
|
4666
|
-
await this.handleEffortSlash(data, adapterId);
|
|
4667
|
-
}
|
|
4668
|
-
else if (data.command === "cancel") {
|
|
4669
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4670
|
-
if (!name) {
|
|
4671
|
-
await data.respond(t("classic.no_agent"));
|
|
4672
|
-
return;
|
|
4673
|
-
}
|
|
4674
|
-
const ok = this.cancelInstance(name);
|
|
4675
|
-
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
4676
|
-
}
|
|
4677
|
-
else if (data.command === "ctx") {
|
|
4678
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4679
|
-
if (!name) {
|
|
4680
|
-
await data.respond(t("classic.no_agent"));
|
|
4681
|
-
return;
|
|
4682
|
-
}
|
|
4683
|
-
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
4684
|
-
await data.respond(await this.topicCommands.getCtxText(name));
|
|
4685
|
-
}
|
|
4686
|
-
else if (data.command === "collab") {
|
|
4687
|
-
// Classic no longer lives in the routing engine, so a routing hit here is
|
|
4688
|
-
// always a fleet-topic instance.
|
|
4689
|
-
const collabTarget2 = this.routing.resolve(data.channelId);
|
|
4690
|
-
if (collabTarget2) {
|
|
4691
|
-
if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
|
|
4692
|
-
await data.respond(t("not_authorized"));
|
|
4693
|
-
return;
|
|
4694
|
-
}
|
|
4695
|
-
const isCollab = this.toggleFleetCollab(collabTarget2.name);
|
|
4696
|
-
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
4697
|
-
return;
|
|
4698
|
-
}
|
|
4699
|
-
if (!this.classicChannels?.isAdmin(data.userId)) {
|
|
4700
|
-
await data.respond(t("admin.required"));
|
|
4701
|
-
return;
|
|
4702
|
-
}
|
|
4703
|
-
if (!this.classicChannels.isClassicChannel(data.channelId, adapterId)) {
|
|
4704
|
-
await data.respond(t("classic.no_agent_start"));
|
|
4705
|
-
return;
|
|
4706
|
-
}
|
|
4707
|
-
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
4708
|
-
await data.respond(newState
|
|
4709
|
-
? t("collab.on.classic")
|
|
4710
|
-
: t("collab.off.classic"));
|
|
4711
|
-
}
|
|
4712
|
-
else if (data.command === "update") {
|
|
4713
|
-
await this.handleUpdateSlash(data, adapterId);
|
|
4714
|
-
}
|
|
4715
|
-
else if (data.command === "doctor") {
|
|
4716
|
-
if (this.fleetAdminGate(data.userId, adapterId) !== "ok") {
|
|
4717
|
-
await data.respond(t("not_authorized"));
|
|
4718
|
-
return;
|
|
4719
|
-
}
|
|
4720
|
-
await data.respond(await this.runBackendDoctor());
|
|
4721
|
-
}
|
|
4722
|
-
else if (data.command === "usage") {
|
|
4723
|
-
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
4724
|
-
// the adapter defers non-chat commands that way — so it never spams the
|
|
4725
|
-
// channel either way.
|
|
4726
|
-
try {
|
|
4727
|
-
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
4728
|
-
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
4729
|
-
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
4730
|
-
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
4731
|
-
}
|
|
4732
|
-
catch (err) {
|
|
4733
|
-
await data.respond(t("usage.failed", err.message));
|
|
4734
|
-
}
|
|
4735
|
-
}
|
|
4736
|
-
else if (data.command === "tips") {
|
|
4737
|
-
await this.handleTipsSlash(data, adapterId);
|
|
4738
|
-
}
|
|
4739
|
-
else if (data.command === "status") {
|
|
4740
|
-
// Admin-gated (like the topic path): the merged table shows every
|
|
4741
|
-
// instance's cost and IPC health.
|
|
4742
|
-
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
4743
|
-
await data.respond(t("cmd.admin_required", "/status"));
|
|
4744
|
-
return;
|
|
4745
|
-
}
|
|
4746
|
-
const text = await this.topicCommands.getStatusText();
|
|
4747
|
-
await data.respond(text);
|
|
4748
|
-
}
|
|
4749
|
-
else if (data.command === "sysinfo") {
|
|
4750
|
-
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
4751
|
-
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
4752
|
-
}
|
|
4753
|
-
else if (data.command === "dashboard") {
|
|
4754
|
-
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
4755
|
-
// the web-token-bearing URLs are only visible to the caller.
|
|
4756
|
-
const gate = this.fleetAdminGate(data.userId, adapterId);
|
|
4757
|
-
if (gate !== "ok") {
|
|
4758
|
-
await data.respond(t(gate === "disabled" ? "dashboard.disabled" : "not_authorized"));
|
|
4759
|
-
return;
|
|
4760
|
-
}
|
|
4761
|
-
await data.respond(this.topicCommands.getDashboardText());
|
|
4762
|
-
}
|
|
4763
|
-
else if (data.command === "restart") {
|
|
4764
|
-
await this.handleRestartSlash(data, adapterId);
|
|
4765
|
-
}
|
|
4766
|
-
else if (data.command === "compact") {
|
|
4767
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4768
|
-
if (!name) {
|
|
4769
|
-
await data.respond(t("classic.no_agent"));
|
|
4770
|
-
return;
|
|
4771
|
-
}
|
|
4772
|
-
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
4773
|
-
await data.respond(result);
|
|
4774
|
-
}
|
|
4775
|
-
else if (data.command === "steer") {
|
|
4776
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4777
|
-
if (!name) {
|
|
4778
|
-
await data.respond(t("classic.no_agent"));
|
|
4779
|
-
return;
|
|
4780
|
-
}
|
|
4781
|
-
const steerText = String(data.options?.message ?? "").trim();
|
|
4782
|
-
if (!steerText) {
|
|
4783
|
-
await data.respond(t("steer.usage"));
|
|
4784
|
-
return;
|
|
4785
|
-
}
|
|
4786
|
-
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
4787
|
-
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
4788
|
-
// rerouting the instance's replies to the slash context.
|
|
4789
|
-
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
4790
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4791
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4792
|
-
});
|
|
4793
|
-
await data.respond(result);
|
|
4794
|
-
}
|
|
4795
|
-
else if (data.command === "btw") {
|
|
4796
|
-
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
4797
|
-
if (!name) {
|
|
4798
|
-
await data.respond(t("classic.no_agent"));
|
|
4799
|
-
return;
|
|
4800
|
-
}
|
|
4801
|
-
const btwText = String(data.options?.message ?? "").trim();
|
|
4802
|
-
if (!btwText) {
|
|
4803
|
-
await data.respond(t("btw.usage"));
|
|
4804
|
-
return;
|
|
4805
|
-
}
|
|
4806
|
-
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
4807
|
-
chatId: "", messageId: "", username: data.username ?? "user",
|
|
4808
|
-
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
4809
|
-
});
|
|
4810
|
-
await data.respond(result);
|
|
4811
|
-
}
|
|
4812
|
-
else if (data.command === "login") {
|
|
4813
|
-
await this.handleLoginSlash(data, adapterId, adapter);
|
|
4814
|
-
}
|
|
4618
|
+
await this.dispatchSlash(data, adapterId, adapter);
|
|
4815
4619
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
4816
4620
|
adapter.on("started", safeHandler((username, userId) => {
|
|
4817
4621
|
if (!isCurrentAdapter())
|
|
@@ -9025,10 +8829,17 @@ export class FleetManager {
|
|
|
9025
8829
|
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
9026
8830
|
*/
|
|
9027
8831
|
async handleSlashSave(data, adapterId) {
|
|
9028
|
-
|
|
8832
|
+
// The admin of the channel's own kind (a fleet admin in a fleet channel). It used to ask for a ClassicBot
|
|
8833
|
+
// admin everywhere, so a fleet admin was refused in their own channel and a ClassicBot admin could paste
|
|
8834
|
+
// into a fleet instance.
|
|
8835
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
9029
8836
|
await data.respond(t("admin.required"));
|
|
9030
8837
|
return;
|
|
9031
8838
|
}
|
|
8839
|
+
if (!this.classicChannels) {
|
|
8840
|
+
await data.respond(t("classic.no_agent_start"));
|
|
8841
|
+
return;
|
|
8842
|
+
}
|
|
9032
8843
|
// Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
|
|
9033
8844
|
const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
|
|
9034
8845
|
const target = classicName
|
|
@@ -14153,7 +13964,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14153
13964
|
const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
|
|
14154
13965
|
const latest = await npmVersion("@songsid/agend");
|
|
14155
13966
|
let target = latest;
|
|
14156
|
-
|
|
13967
|
+
// The same channel rule as `agend update` (any SemVer prerelease is on @beta): rc and alpha too.
|
|
13968
|
+
if (isPrereleaseVersion(currentVersion)) {
|
|
14157
13969
|
// Beta users track the @beta channel (never fall back to @latest, which is
|
|
14158
13970
|
// older), but should also hear when a newer STABLE ships — pick whichever
|
|
14159
13971
|
// of beta/latest is the newest.
|
|
@@ -14173,7 +13985,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14173
13985
|
// only that same-core stable-vs-my-beta case; a higher stable core (2.0.12)
|
|
14174
13986
|
// or a newer beta (2.0.11-beta.50) still notifies via semverGt below.
|
|
14175
13987
|
const core = (v) => v.replace(/^v/, "").split("-")[0];
|
|
14176
|
-
const betaSupersedesStable = currentVersion
|
|
13988
|
+
const betaSupersedesStable = isPrereleaseVersion(currentVersion) && !isPrereleaseVersion(target) && core(target) === core(currentVersion);
|
|
14177
13989
|
// Only notify when target is genuinely newer (semver), so a beta user on
|
|
14178
13990
|
// 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
|
|
14179
13991
|
if (target && !betaSupersedesStable && this.semverGt(target, currentVersion)) {
|
|
@@ -14181,7 +13993,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
|
|
|
14181
13993
|
if (generalId) {
|
|
14182
13994
|
// No release URL — Discord's SuppressEmbeds proved unreliable and the
|
|
14183
13995
|
// link preview looked bad. Version + /update instruction is enough.
|
|
14184
|
-
this.notifyInstanceTopic(generalId, t(
|
|
13996
|
+
this.notifyInstanceTopic(generalId, t(updateNoticeKey(currentVersion, target), `v${target}`, `v${currentVersion}`));
|
|
14185
13997
|
}
|
|
14186
13998
|
}
|
|
14187
13999
|
}
|