@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.
Files changed (77) 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/cli.js +27 -6
  29. package/dist/cli.js.map +1 -1
  30. package/dist/command-table.d.ts +99 -0
  31. package/dist/command-table.js +125 -0
  32. package/dist/command-table.js.map +1 -0
  33. package/dist/daemon.d.ts +36 -0
  34. package/dist/daemon.js +165 -2
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/delivery-outbox.d.ts +16 -0
  37. package/dist/delivery-outbox.js +55 -6
  38. package/dist/delivery-outbox.js.map +1 -1
  39. package/dist/e2e-isolation.d.ts +122 -0
  40. package/dist/e2e-isolation.js +386 -0
  41. package/dist/e2e-isolation.js.map +1 -0
  42. package/dist/fleet-manager.d.ts +24 -0
  43. package/dist/fleet-manager.js +301 -489
  44. package/dist/fleet-manager.js.map +1 -1
  45. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +56 -0
  46. package/dist/instance-lifecycle.d.ts +2 -0
  47. package/dist/instance-lifecycle.js +14 -0
  48. package/dist/instance-lifecycle.js.map +1 -1
  49. package/dist/kiro-engine-status.d.ts +71 -0
  50. package/dist/kiro-engine-status.js +68 -0
  51. package/dist/kiro-engine-status.js.map +1 -0
  52. package/dist/locale.js +16 -4
  53. package/dist/locale.js.map +1 -1
  54. package/dist/login-flows.js +10 -0
  55. package/dist/login-flows.js.map +1 -1
  56. package/dist/outbound-handlers.js +16 -1
  57. package/dist/outbound-handlers.js.map +1 -1
  58. package/dist/outbound-schemas.d.ts +3 -0
  59. package/dist/outbound-schemas.js +3 -0
  60. package/dist/outbound-schemas.js.map +1 -1
  61. package/dist/paths.d.ts +19 -0
  62. package/dist/paths.js +18 -5
  63. package/dist/paths.js.map +1 -1
  64. package/dist/tool-permissions.js +4 -2
  65. package/dist/tool-permissions.js.map +1 -1
  66. package/dist/topic-commands.js +3 -2
  67. package/dist/topic-commands.js.map +1 -1
  68. package/dist/ui/web-terminal/terminal-input.js +69 -0
  69. package/dist/ui/web-terminal/terminal.css +6 -0
  70. package/dist/ui/web-terminal/terminal.html +9 -1
  71. package/dist/ui/web-terminal/terminal.js +31 -2
  72. package/dist/update-check.d.ts +40 -2
  73. package/dist/update-check.js +101 -3
  74. package/dist/update-check.js.map +1 -1
  75. package/dist/web-terminal-http.js +1 -0
  76. package/dist/web-terminal-http.js.map +1 -1
  77. 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,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.classicChannels?.isAdmin(data.userId)) {
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
- const currentVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8")).version ?? "";
1494
- const command = currentVersion.includes("beta") ? "agend update --beta" : "agend update";
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 true;
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 false;
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
- 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
- }
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
- 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
- }
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
- if (!this.classicChannels?.isAdmin(data.userId)) {
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
- if (currentVersion.includes("-beta")) {
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.includes("-") && !target.includes("-") && core(target) === core(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("update.available_current", `v${target}`, `v${currentVersion}`));
13996
+ this.notifyInstanceTopic(generalId, t(updateNoticeKey(currentVersion, target), `v${target}`, `v${currentVersion}`));
14185
13997
  }
14186
13998
  }
14187
13999
  }