@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.
Files changed (70) hide show
  1. package/dist/backend/kiro-engine-ledger.d.ts +36 -0
  2. package/dist/backend/kiro-engine-ledger.js +113 -0
  3. package/dist/backend/kiro-engine-ledger.js.map +1 -0
  4. package/dist/backend/kiro-kas-store.d.ts +11 -0
  5. package/dist/backend/kiro-kas-store.js +67 -0
  6. package/dist/backend/kiro-kas-store.js.map +1 -0
  7. package/dist/backend/kiro-v3-identity.d.ts +28 -0
  8. package/dist/backend/kiro-v3-identity.js +213 -0
  9. package/dist/backend/kiro-v3-identity.js.map +1 -0
  10. package/dist/backend/kiro.d.ts +7 -0
  11. package/dist/backend/kiro.js +34 -1
  12. package/dist/backend/kiro.js.map +1 -1
  13. package/dist/backend/opencode-permission.d.ts +49 -0
  14. package/dist/backend/opencode-permission.js +181 -0
  15. package/dist/backend/opencode-permission.js.map +1 -0
  16. package/dist/backend/opencode.d.ts +6 -0
  17. package/dist/backend/opencode.js +46 -2
  18. package/dist/backend/opencode.js.map +1 -1
  19. package/dist/backend/types.d.ts +15 -0
  20. package/dist/backend/types.js.map +1 -1
  21. package/dist/channel/adapters/discord.js +26 -25
  22. package/dist/channel/adapters/discord.js.map +1 -1
  23. package/dist/channel/ipc-bridge.d.ts +1 -0
  24. package/dist/channel/ipc-bridge.js +1 -1
  25. package/dist/channel/ipc-bridge.js.map +1 -1
  26. package/dist/channel/mcp-tools.js +2 -0
  27. package/dist/channel/mcp-tools.js.map +1 -1
  28. package/dist/command-table.d.ts +99 -0
  29. package/dist/command-table.js +125 -0
  30. package/dist/command-table.js.map +1 -0
  31. package/dist/daemon.d.ts +36 -0
  32. package/dist/daemon.js +165 -2
  33. package/dist/daemon.js.map +1 -1
  34. package/dist/delivery-outbox.d.ts +16 -0
  35. package/dist/delivery-outbox.js +55 -6
  36. package/dist/delivery-outbox.js.map +1 -1
  37. package/dist/e2e-isolation.d.ts +122 -0
  38. package/dist/e2e-isolation.js +386 -0
  39. package/dist/e2e-isolation.js.map +1 -0
  40. package/dist/fleet-manager.d.ts +24 -0
  41. package/dist/fleet-manager.js +292 -484
  42. package/dist/fleet-manager.js.map +1 -1
  43. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +56 -0
  44. package/dist/instance-lifecycle.d.ts +2 -0
  45. package/dist/instance-lifecycle.js +14 -0
  46. package/dist/instance-lifecycle.js.map +1 -1
  47. package/dist/kiro-engine-status.d.ts +71 -0
  48. package/dist/kiro-engine-status.js +68 -0
  49. package/dist/kiro-engine-status.js.map +1 -0
  50. package/dist/locale.js +10 -0
  51. package/dist/locale.js.map +1 -1
  52. package/dist/login-flows.js +10 -0
  53. package/dist/login-flows.js.map +1 -1
  54. package/dist/outbound-handlers.js +16 -1
  55. package/dist/outbound-handlers.js.map +1 -1
  56. package/dist/outbound-schemas.d.ts +3 -0
  57. package/dist/outbound-schemas.js +3 -0
  58. package/dist/outbound-schemas.js.map +1 -1
  59. package/dist/paths.d.ts +19 -0
  60. package/dist/paths.js +18 -5
  61. package/dist/paths.js.map +1 -1
  62. package/dist/tool-permissions.js +4 -2
  63. package/dist/tool-permissions.js.map +1 -1
  64. package/dist/ui/web-terminal/terminal-input.js +69 -0
  65. package/dist/ui/web-terminal/terminal.css +6 -0
  66. package/dist/ui/web-terminal/terminal.html +9 -1
  67. package/dist/ui/web-terminal/terminal.js +31 -2
  68. package/dist/web-terminal-http.js +1 -0
  69. package/dist/web-terminal-http.js.map +1 -1
  70. package/package.json +1 -1
@@ -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.classicChannels?.isAdmin(data.userId)) {
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 true;
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 false;
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
- if (!(await this.authorizeSlash(data, adapterId)))
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
- if (!(await this.authorizeSlash(data, adapterId)))
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
- if (!this.classicChannels?.isAdmin(data.userId)) {
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