@songsid/agend 2.1.11-beta.1 → 2.1.11-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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 +292 -484
- 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 +10 -0
- 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/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/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,7 @@ 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";
|
|
65
66
|
import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
|
|
66
67
|
import { runBeforeDeadline } from "./deadline.js";
|
|
67
68
|
import { LoginWindowLock } from "./login-window-lock.js";
|
|
@@ -457,6 +458,7 @@ export class FleetManager {
|
|
|
457
458
|
startupRetryNotices = new Map();
|
|
458
459
|
/** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
|
|
459
460
|
unsupportedCliNotices = new Map();
|
|
461
|
+
kiroIncompatNotices = new Map();
|
|
460
462
|
/** Backoff between automatic startup retries; the last step repeats while the backend is down. */
|
|
461
463
|
static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
|
|
462
464
|
/** Hard cap on automatic startup retries (3 backoff steps + up to 3 more during a backend outage). */
|
|
@@ -1426,7 +1428,7 @@ export class FleetManager {
|
|
|
1426
1428
|
const action = data.command;
|
|
1427
1429
|
const classicName = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
1428
1430
|
if (classicName) {
|
|
1429
|
-
if (!this.
|
|
1431
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
1430
1432
|
await data.respond(t("permission.denied"));
|
|
1431
1433
|
return;
|
|
1432
1434
|
}
|
|
@@ -2220,6 +2222,230 @@ export class FleetManager {
|
|
|
2220
2222
|
return "disabled";
|
|
2221
2223
|
return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
|
|
2222
2224
|
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Every Discord slash command, for every adapter, in one place (it used to be two copies that could drift).
|
|
2227
|
+
*
|
|
2228
|
+
* Two checks before any command's own code runs, and the order matters: the DOOR (src/slash-authz.ts — may
|
|
2229
|
+
* this caller speak here at all) and then the COMMAND TABLE (src/command-table.ts — does this command apply
|
|
2230
|
+
* in this kind of channel, and which kind of admin does it need). The table can only narrow what the door
|
|
2231
|
+
* let through. A command nobody registered is answered, not left to time out.
|
|
2232
|
+
*/
|
|
2233
|
+
async dispatchSlash(data, adapterId, adapter) {
|
|
2234
|
+
const scope = await this.authorizeSlash(data, adapterId);
|
|
2235
|
+
if (!scope)
|
|
2236
|
+
return;
|
|
2237
|
+
const spec = commandSpec(data.command);
|
|
2238
|
+
if (!spec) {
|
|
2239
|
+
this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
|
|
2240
|
+
await data.respond(t("slash.unknown_command")).catch(() => { });
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
const decision = decideCommand(spec, scope, {
|
|
2244
|
+
fleetAdmin: () => this.fleetAdminGate(data.userId, adapterId),
|
|
2245
|
+
channelAdmin: () => this.isModelAdmin(data.userId, data.channelId, adapterId),
|
|
2246
|
+
classicAdmin: () => !!this.classicChannels?.isAdmin(data.userId),
|
|
2247
|
+
});
|
|
2248
|
+
if (!decision.allow) {
|
|
2249
|
+
this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
|
|
2250
|
+
const [key, ...args] = decision.reply;
|
|
2251
|
+
await data.respond(t(key, ...args)).catch(() => { });
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
if (data.command === "start") {
|
|
2255
|
+
await this.handleClassicStartSlash(data, adapterId);
|
|
2256
|
+
}
|
|
2257
|
+
else if (data.command === "stop") {
|
|
2258
|
+
const reply = await this.handleClassicStop(data.channelId, adapterId);
|
|
2259
|
+
await data.respond(reply);
|
|
2260
|
+
}
|
|
2261
|
+
else if (data.command === "pause" || data.command === "wake") {
|
|
2262
|
+
await this.handlePauseWakeSlash(data, adapterId);
|
|
2263
|
+
}
|
|
2264
|
+
else if (data.command === "chat") {
|
|
2265
|
+
const text = data.text ?? "";
|
|
2266
|
+
if (!text) {
|
|
2267
|
+
await data.respond(t("chat.usage"));
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
2271
|
+
if (!name) {
|
|
2272
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
const status = this.resolveStatusEmojisFor(name, adapterId, adapter);
|
|
2276
|
+
const replyMsgId = await data.respond(textForm(status.platform, status.received));
|
|
2277
|
+
const username = data.username ?? data.userId;
|
|
2278
|
+
ClassicChannelManager.logMessage(name, username, `/chat ${text}`, new Date());
|
|
2279
|
+
await this.forwardToClassicInstance(name, text, {
|
|
2280
|
+
chatId: data.channelId,
|
|
2281
|
+
threadId: data.channelId,
|
|
2282
|
+
messageId: replyMsgId ?? "",
|
|
2283
|
+
userId: data.userId,
|
|
2284
|
+
username,
|
|
2285
|
+
source: "discord",
|
|
2286
|
+
timestamp: new Date(),
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
else if (data.command === "save") {
|
|
2290
|
+
await this.handleSlashSave(data, adapterId);
|
|
2291
|
+
}
|
|
2292
|
+
else if (data.command === "load") {
|
|
2293
|
+
// load is kiro-cli/classic only — no claude-code equivalent. (Classic admins only: the command table.)
|
|
2294
|
+
const name = this.classicChannels?.getInstanceByChannel(data.channelId, adapterId);
|
|
2295
|
+
if (!name) {
|
|
2296
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2297
|
+
return;
|
|
2298
|
+
}
|
|
2299
|
+
const filename = data.options?.filename;
|
|
2300
|
+
if (!SAVE_FILENAME_RE.test(filename ?? "")) {
|
|
2301
|
+
await data.respond(t("filename.invalid"));
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
this.pasteRawToClassicInstance(name, `/chat load ${filename}`);
|
|
2305
|
+
await data.respond(t("save.sent", `/chat load ${filename}`, name));
|
|
2306
|
+
}
|
|
2307
|
+
else if (data.command === "model") {
|
|
2308
|
+
await this.handleModelSlash(data, adapterId);
|
|
2309
|
+
}
|
|
2310
|
+
else if (data.command === "clear") {
|
|
2311
|
+
await this.handleClearSlash(data, adapterId);
|
|
2312
|
+
}
|
|
2313
|
+
else if (data.command === "effort") {
|
|
2314
|
+
await this.handleEffortSlash(data, adapterId);
|
|
2315
|
+
}
|
|
2316
|
+
else if (data.command === "cancel") {
|
|
2317
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2318
|
+
if (!name) {
|
|
2319
|
+
await data.respond(t("classic.no_agent"));
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
const ok = this.cancelInstance(name);
|
|
2323
|
+
await data.respond(ok ? t("cancel.sent", name) : t("cancel.not_running", name));
|
|
2324
|
+
}
|
|
2325
|
+
else if (data.command === "ctx") {
|
|
2326
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2327
|
+
if (!name) {
|
|
2328
|
+
await data.respond(t("classic.no_agent"));
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
// Single source of truth (statusline.json + robust tmux pane fallback).
|
|
2332
|
+
await data.respond(await this.topicCommands.getCtxText(name));
|
|
2333
|
+
}
|
|
2334
|
+
else if (data.command === "collab") {
|
|
2335
|
+
// The scope the door and the table authorized is the scope this acts in. A channel can be in both the
|
|
2336
|
+
// ClassicBot registry and the routing table (two configurations that each validate); the door and
|
|
2337
|
+
// `isModelAdmin` read the registry first, so a ClassicBot admin was judged for the ClassicBot channel
|
|
2338
|
+
// here and must not be handed the fleet instance's collab switch by a second, routing-first lookup.
|
|
2339
|
+
const collabTarget2 = scope === "classic" ? undefined : this.routing.resolve(data.channelId);
|
|
2340
|
+
if (collabTarget2) {
|
|
2341
|
+
const isCollab = this.toggleFleetCollab(collabTarget2.name);
|
|
2342
|
+
await data.respond(isCollab ? t("collab.on") : t("collab.off"));
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (!this.classicChannels?.isClassicChannel(data.channelId, adapterId)) {
|
|
2346
|
+
await data.respond(t("classic.no_agent_start"));
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
const newState = this.classicChannels.toggleCollab(data.channelId, adapterId);
|
|
2350
|
+
await data.respond(newState
|
|
2351
|
+
? t("collab.on.classic")
|
|
2352
|
+
: t("collab.off.classic"));
|
|
2353
|
+
}
|
|
2354
|
+
else if (data.command === "update") {
|
|
2355
|
+
await this.handleUpdateSlash(data, adapterId);
|
|
2356
|
+
}
|
|
2357
|
+
else if (data.command === "doctor") {
|
|
2358
|
+
await data.respond(await this.runBackendDoctor());
|
|
2359
|
+
}
|
|
2360
|
+
else if (data.command === "usage") {
|
|
2361
|
+
// Same permission level as /ctx (none). The reply is still ephemeral —
|
|
2362
|
+
// the adapter defers non-chat commands that way — so it never spams the
|
|
2363
|
+
// channel either way.
|
|
2364
|
+
try {
|
|
2365
|
+
const { getUsageSnapshot } = await import("./usage/usage-api.js");
|
|
2366
|
+
const { renderUsageMarkdown } = await import("./usage/format-rich.js");
|
|
2367
|
+
// slash_command is Discord-only; editReply renders Markdown natively.
|
|
2368
|
+
await data.respond(renderUsageMarkdown(await getUsageSnapshot(false, this.getActiveUsageProviderIds())));
|
|
2369
|
+
}
|
|
2370
|
+
catch (err) {
|
|
2371
|
+
await data.respond(t("usage.failed", err.message));
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
else if (data.command === "tips") {
|
|
2375
|
+
await this.handleTipsSlash(data, adapterId);
|
|
2376
|
+
}
|
|
2377
|
+
else if (data.command === "status") {
|
|
2378
|
+
// Admin-gated (like the topic path): the merged table shows every
|
|
2379
|
+
// instance's cost and IPC health.
|
|
2380
|
+
if (!this.isFleetAdmin(data.userId, adapterId)) {
|
|
2381
|
+
await data.respond(t("cmd.admin_required", "/status"));
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
const text = await this.topicCommands.getStatusText();
|
|
2385
|
+
await data.respond(text);
|
|
2386
|
+
}
|
|
2387
|
+
else if (data.command === "sysinfo") {
|
|
2388
|
+
// Slash commands are Discord-only; use plain lines (no markdown table)
|
|
2389
|
+
await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
|
|
2390
|
+
}
|
|
2391
|
+
else if (data.command === "dashboard") {
|
|
2392
|
+
// Reply is ephemeral (adapter defers non-chat commands ephemerally), so
|
|
2393
|
+
// the web-token-bearing URLs are only visible to the caller.
|
|
2394
|
+
await data.respond(this.topicCommands.getDashboardText());
|
|
2395
|
+
}
|
|
2396
|
+
else if (data.command === "restart") {
|
|
2397
|
+
await this.handleRestartSlash(data, adapterId);
|
|
2398
|
+
}
|
|
2399
|
+
else if (data.command === "compact") {
|
|
2400
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2401
|
+
if (!name) {
|
|
2402
|
+
await data.respond(t("classic.no_agent"));
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
const result = await this.topicCommands.sendCompact(name, String(data.options?.instructions ?? ""));
|
|
2406
|
+
await data.respond(result);
|
|
2407
|
+
}
|
|
2408
|
+
else if (data.command === "steer") {
|
|
2409
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2410
|
+
if (!name) {
|
|
2411
|
+
await data.respond(t("classic.no_agent"));
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
const steerText = String(data.options?.message ?? "").trim();
|
|
2415
|
+
if (!steerText) {
|
|
2416
|
+
await data.respond(t("steer.usage"));
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
// chat_id/message_id stay empty: a slash interaction has no channel
|
|
2420
|
+
// message to react to, and an empty chat_id keeps updateLastChat from
|
|
2421
|
+
// rerouting the instance's replies to the slash context.
|
|
2422
|
+
const result = this.topicCommands.sendSteer(name, steerText, {
|
|
2423
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2424
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2425
|
+
});
|
|
2426
|
+
await data.respond(result);
|
|
2427
|
+
}
|
|
2428
|
+
else if (data.command === "btw") {
|
|
2429
|
+
const name = this.resolveSlashTarget(data.channelId, adapterId);
|
|
2430
|
+
if (!name) {
|
|
2431
|
+
await data.respond(t("classic.no_agent"));
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
const btwText = String(data.options?.message ?? "").trim();
|
|
2435
|
+
if (!btwText) {
|
|
2436
|
+
await data.respond(t("btw.usage"));
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
const result = this.topicCommands.sendBtw(name, btwText, {
|
|
2440
|
+
chatId: "", messageId: "", username: data.username ?? "user",
|
|
2441
|
+
userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
|
|
2442
|
+
});
|
|
2443
|
+
await data.respond(result);
|
|
2444
|
+
}
|
|
2445
|
+
else if (data.command === "login") {
|
|
2446
|
+
await this.handleLoginSlash(data, adapterId, adapter);
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2223
2449
|
/**
|
|
2224
2450
|
* The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning).
|
|
2225
2451
|
* Answers the caller itself when it refuses, so a refused command costs one reply and does nothing else.
|
|
@@ -2229,6 +2455,8 @@ export class FleetManager {
|
|
|
2229
2455
|
const classic = !!this.classicChannels?.isClassicChannel(channelId, adapterId);
|
|
2230
2456
|
const fleetTarget = classic ? undefined : this.routing.resolve(channelId);
|
|
2231
2457
|
const scope = classic ? "classic" : fleetTarget ? "fleet" : "none";
|
|
2458
|
+
// The table tells the General dispatcher from an instance's own channel; the door does not need to.
|
|
2459
|
+
const commandScope = classic ? "classic" : fleetTarget ? (fleetTarget.kind === "general" ? "general" : "fleet") : "none";
|
|
2232
2460
|
let speaker = "denied";
|
|
2233
2461
|
if (scope !== "classic") {
|
|
2234
2462
|
// The same world the typed-message path would consult: the adapter that owns the channel's instance,
|
|
@@ -2253,12 +2481,12 @@ export class FleetManager {
|
|
|
2253
2481
|
};
|
|
2254
2482
|
const decision = decideSlash(facts);
|
|
2255
2483
|
if (decision.allow)
|
|
2256
|
-
return
|
|
2484
|
+
return commandScope;
|
|
2257
2485
|
this.logger.info({ command: data.command, reason: decision.reason, adapterId, guildId: data.guildId ?? null, channelId, scope }, "Slash command refused");
|
|
2258
2486
|
await data.respond(t(decision.reason === "dm" ? "slash.dm_unsupported"
|
|
2259
2487
|
: decision.reason === "wrong-guild" ? "slash.wrong_server"
|
|
2260
2488
|
: "not_authorized")).catch(() => { });
|
|
2261
|
-
return
|
|
2489
|
+
return null;
|
|
2262
2490
|
}
|
|
2263
2491
|
/** Phase 2: delivery_worker for a target (instance override → fleet default → wake_only). */
|
|
2264
2492
|
deliveryWorkerMode(target) {
|
|
@@ -2868,8 +3096,58 @@ export class FleetManager {
|
|
|
2868
3096
|
return false;
|
|
2869
3097
|
this.logger.error({ name, reason: err.message }, `Not starting ${what}: the installed CLI cannot run it as configured`);
|
|
2870
3098
|
this.queueUnsupportedCliNotice(name, err.message);
|
|
3099
|
+
this.queueKiroIncompatNotice(name, err.message);
|
|
2871
3100
|
return true;
|
|
2872
3101
|
}
|
|
3102
|
+
/**
|
|
3103
|
+
* LifecycleContext: a kiro instance the installed kiro-cli cannot run as
|
|
3104
|
+
* configured (#1109) — at start, or at a respawn after kiro-cli replaced
|
|
3105
|
+
* itself. The operator already gets the plain notice; this tells every
|
|
3106
|
+
* General, as an agent, so it can explain the kiro engine move when asked.
|
|
3107
|
+
* One notice per refusal reason, like the operator's.
|
|
3108
|
+
*/
|
|
3109
|
+
queueKiroIncompatNotice(name, reason) {
|
|
3110
|
+
// The effective backend, ClassicBot channels included: they are not in fleetConfig.instances.
|
|
3111
|
+
if (this.backendNameForInstance(name) !== "kiro-cli")
|
|
3112
|
+
return;
|
|
3113
|
+
const pending = this.kiroIncompatNotices.get(reason);
|
|
3114
|
+
if (pending) {
|
|
3115
|
+
if (!pending.names.includes(name))
|
|
3116
|
+
pending.names.push(name);
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
const timer = setTimeout(() => {
|
|
3120
|
+
const entry = this.kiroIncompatNotices.get(reason);
|
|
3121
|
+
this.kiroIncompatNotices.delete(reason);
|
|
3122
|
+
if (entry)
|
|
3123
|
+
this.sendKiroIncompatToGenerals(entry.names, reason);
|
|
3124
|
+
}, FleetManager.STARTUP_RETRY_NOTICE_AGGREGATE_MS);
|
|
3125
|
+
timer.unref?.();
|
|
3126
|
+
this.kiroIncompatNotices.set(reason, { names: [name], timer });
|
|
3127
|
+
}
|
|
3128
|
+
/**
|
|
3129
|
+
* Held in the delivery outbox until each General takes it: the same kiro-cli
|
|
3130
|
+
* may have stopped the General too, and it should still hear once it is back.
|
|
3131
|
+
* Admitted once per day per refusal (reason + names) and General.
|
|
3132
|
+
*/
|
|
3133
|
+
sendKiroIncompatToGenerals(names, reason, now = new Date()) {
|
|
3134
|
+
const generals = Object.entries(this.fleetConfig?.instances ?? {})
|
|
3135
|
+
.filter(([, config]) => config.general_topic === true).map(([general]) => general);
|
|
3136
|
+
if (generals.length === 0)
|
|
3137
|
+
return;
|
|
3138
|
+
try {
|
|
3139
|
+
this.ensureDeliveryOutbox();
|
|
3140
|
+
const sorted = [...names].sort();
|
|
3141
|
+
const digest = createHash("sha256").update(`${reason}\u0000${sorted.join(",")}`).digest("hex").slice(0, 16);
|
|
3142
|
+
const content = t("fleet.kiro_incompat_general", sorted.join(", "), reason);
|
|
3143
|
+
for (const general of generals) {
|
|
3144
|
+
this.deliveryOutbox.admitSystemNotice(general, `kiro-incompat:${now.toISOString().slice(0, 10)}:${digest}:${general}`, content, now);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
catch (err) {
|
|
3148
|
+
this.logger.warn({ err, names }, "Could not queue the kiro incompatibility notice for General");
|
|
3149
|
+
}
|
|
3150
|
+
}
|
|
2873
3151
|
/**
|
|
2874
3152
|
* One notice per refusal reason: a kiro-cli that dropped the legacy UI
|
|
2875
3153
|
* refuses every kiro instance at once, and that is one message, not ten.
|
|
@@ -4230,270 +4508,7 @@ export class FleetManager {
|
|
|
4230
4508
|
this.adapter.on("slash_command", safeHandler(async (data) => {
|
|
4231
4509
|
if (!isCurrentAdapter())
|
|
4232
4510
|
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
|
-
}
|
|
4511
|
+
await this.dispatchSlash(data, adapterId, adapter);
|
|
4497
4512
|
}, this.logger, "adapter.slash_command"));
|
|
4498
4513
|
await this.topicCommands.registerBotCommands().catch(e => this.logger.warn({ err: e }, "registerBotCommands failed (non-fatal)"));
|
|
4499
4514
|
// Background-probe each backend's CLI env (version/models) → cli-env cache.
|
|
@@ -4597,221 +4612,7 @@ export class FleetManager {
|
|
|
4597
4612
|
adapter.on("slash_command", safeHandler(async (data) => {
|
|
4598
4613
|
if (!isCurrentAdapter())
|
|
4599
4614
|
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
|
-
}
|
|
4615
|
+
await this.dispatchSlash(data, adapterId, adapter);
|
|
4815
4616
|
}, this.logger, `adapter[${adapterId}].slash_command`));
|
|
4816
4617
|
adapter.on("started", safeHandler((username, userId) => {
|
|
4817
4618
|
if (!isCurrentAdapter())
|
|
@@ -9025,10 +8826,17 @@ export class FleetManager {
|
|
|
9025
8826
|
* unsupported backends get a clear error. Routes via classic paste or fleet IPC.
|
|
9026
8827
|
*/
|
|
9027
8828
|
async handleSlashSave(data, adapterId) {
|
|
9028
|
-
|
|
8829
|
+
// The admin of the channel's own kind (a fleet admin in a fleet channel). It used to ask for a ClassicBot
|
|
8830
|
+
// admin everywhere, so a fleet admin was refused in their own channel and a ClassicBot admin could paste
|
|
8831
|
+
// into a fleet instance.
|
|
8832
|
+
if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
|
|
9029
8833
|
await data.respond(t("admin.required"));
|
|
9030
8834
|
return;
|
|
9031
8835
|
}
|
|
8836
|
+
if (!this.classicChannels) {
|
|
8837
|
+
await data.respond(t("classic.no_agent_start"));
|
|
8838
|
+
return;
|
|
8839
|
+
}
|
|
9032
8840
|
// Classic resolves per-bot (same-channel multi-bot); otherwise a fleet topic.
|
|
9033
8841
|
const classicName = this.classicChannels.getInstanceByChannel(data.channelId, adapterId);
|
|
9034
8842
|
const target = classicName
|