@nordbyte/nordrelay 0.6.0 → 0.8.0

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 (62) hide show
  1. package/.env.example +52 -0
  2. package/README.md +171 -50
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/adapter-conformance.js +61 -0
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot.js +95 -37
  8. package/dist/channel-adapter.js +44 -11
  9. package/dist/channel-command-catalog.js +94 -0
  10. package/dist/channel-command-core.js +60 -0
  11. package/dist/channel-command-service.js +230 -1
  12. package/dist/channel-mirror-registry.js +84 -0
  13. package/dist/channel-peer-prompt.js +95 -0
  14. package/dist/channel-prompt-engine.js +177 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-lifecycle.js +73 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +82 -8
  19. package/dist/config.js +79 -7
  20. package/dist/context-key.js +42 -0
  21. package/dist/discord-bot.js +173 -342
  22. package/dist/discord-command-surface.js +11 -73
  23. package/dist/index.js +29 -0
  24. package/dist/metrics.js +48 -0
  25. package/dist/peer-auth.js +85 -0
  26. package/dist/peer-client.js +288 -0
  27. package/dist/peer-context.js +21 -0
  28. package/dist/peer-identity.js +127 -0
  29. package/dist/peer-readiness.js +77 -0
  30. package/dist/peer-runtime-service.js +658 -0
  31. package/dist/peer-server.js +220 -0
  32. package/dist/peer-store.js +307 -0
  33. package/dist/peer-types.js +52 -0
  34. package/dist/relay-runtime-helpers.js +210 -0
  35. package/dist/relay-runtime.js +79 -274
  36. package/dist/remote-prompt.js +98 -0
  37. package/dist/settings-wizard-test.js +216 -0
  38. package/dist/slack-artifacts.js +165 -0
  39. package/dist/slack-bot.js +1461 -0
  40. package/dist/slack-channel-runtime.js +147 -0
  41. package/dist/slack-command-surface.js +46 -0
  42. package/dist/slack-diagnostics.js +116 -0
  43. package/dist/slack-rate-limit.js +139 -0
  44. package/dist/telegram-command-menu.js +3 -53
  45. package/dist/telegram-general-commands.js +14 -0
  46. package/dist/telegram-preference-commands.js +23 -127
  47. package/dist/user-management-crypto.js +38 -0
  48. package/dist/user-management-normalize.js +188 -0
  49. package/dist/user-management-types.js +1 -0
  50. package/dist/user-management.js +193 -196
  51. package/dist/web-api-contract.js +16 -0
  52. package/dist/web-dashboard-access-routes.js +62 -0
  53. package/dist/web-dashboard-assets.js +1 -0
  54. package/dist/web-dashboard-pages.js +26 -4
  55. package/dist/web-dashboard-peer-routes.js +225 -0
  56. package/dist/web-dashboard-ui.js +1 -0
  57. package/dist/web-dashboard.js +46 -0
  58. package/dist/web-state.js +2 -2
  59. package/dist/webui-assets/dashboard.css +193 -0
  60. package/dist/webui-assets/dashboard.js +870 -57
  61. package/package.json +5 -2
  62. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
@@ -9,10 +9,14 @@ import { enabledAgents } from "./agent-factory.js";
9
9
  import { collectRecentWorkspaceArtifacts, ensureOutDir, formatArtifactSummary, persistWorkspaceArtifactReport } from "./artifacts.js";
10
10
  import { buildFileInstructions, outboxPath, stageFile } from "./attachments.js";
11
11
  import { AuditLogStore } from "./audit-log.js";
12
- import { BotPreferencesStore, parseMirrorMode, parseNotifyMode, parseVoiceBackendPreference } from "./bot-preferences.js";
12
+ import { BotPreferencesStore } from "./bot-preferences.js";
13
13
  import { capabilitiesOf, filterActivityEvents, formatLocalDateTime, parseActivityOptions, renderExternalMirrorEvent, renderExternalMirrorStatus, renderPromptFailure, trimLine } from "./bot-rendering.js";
14
14
  import { renderAgentUpdateJobAction, renderAgentUpdateJobsAction, renderAgentUpdateLogAction, renderAgentUpdatePickerAction, renderQueueListAction } from "./channel-actions.js";
15
+ import { createSharedChannelCommandDispatcher } from "./channel-command-core.js";
15
16
  import { ChannelCommandService } from "./channel-command-service.js";
17
+ import { discordHelpCommandList } from "./channel-command-catalog.js";
18
+ import { createChannelPromptEngine } from "./channel-prompt-engine.js";
19
+ import { runChannelPeerPrompt } from "./channel-peer-prompt.js";
16
20
  import { deliverChannelAction } from "./channel-runtime.js";
17
21
  import { checkAuthStatus, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
18
22
  import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
@@ -25,6 +29,7 @@ import { friendlyErrorText } from "./error-messages.js";
25
29
  import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
26
30
  import { spawnConnectorRestart, spawnSelfUpdate } from "./operations.js";
27
31
  import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
32
+ import { RemoteRelayClient } from "./peer-client.js";
28
33
  import { checkPiAuthStatus } from "./pi-auth.js";
29
34
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
30
35
  import { RelayArtifactService } from "./relay-artifact-service.js";
@@ -46,7 +51,8 @@ export function createDiscordBridge(config, registry) {
46
51
  return null;
47
52
  }
48
53
  if (!config.discordBotToken) {
49
- throw new Error("DISCORD_ENABLED=true requires DISCORD_BOT_TOKEN.");
54
+ console.warn("Discord adapter disabled: DISCORD_ENABLED=true requires DISCORD_BOT_TOKEN.");
55
+ return null;
50
56
  }
51
57
  configureRedaction(config.telegramRedactPatterns);
52
58
  const intents = [
@@ -522,10 +528,44 @@ export function createDiscordBridge(config, registry) {
522
528
  await reply(request, `Session is locked by ${lock?.ownerLabel || lock?.ownerUserId || "another user"}.`);
523
529
  return true;
524
530
  };
531
+ const remoteClient = new RemoteRelayClient();
532
+ const handleRemotePrompt = async (request, envelope) => {
533
+ const targetPeerId = preferencesStore.get(request.contextKey).targetPeerId ?? undefined;
534
+ return runChannelPeerPrompt({
535
+ targetPeerId,
536
+ contextKey: request.contextKey,
537
+ prompt: envelope,
538
+ remoteClient,
539
+ editMinIntervalMs: EDIT_DEBOUNCE_MS,
540
+ typingIntervalMs: TYPING_INTERVAL_MS,
541
+ sendTyping: () => runtime.sendTyping(request.context),
542
+ sendResponse: async (text) => {
543
+ const rendered = trimDiscordMessage(text);
544
+ const sent = await runtime.sendMessage(request.context, { text: rendered, fallbackText: rendered });
545
+ return sent.messageId;
546
+ },
547
+ editResponse: async (messageId, text) => {
548
+ const rendered = trimDiscordMessage(text);
549
+ await runtime.editMessage(request.context, messageId, { text: rendered, fallbackText: rendered });
550
+ },
551
+ sendTurnStart: (remotePrompt) => reply(request, `Remote peer working on:\n${remotePrompt}`),
552
+ sendToolStart: (toolName) => reply(request, `Remote tool: ${toolName}`),
553
+ sendQueued: async (queueId) => {
554
+ await reply(request, `Remote prompt queued${queueId ? `: ${queueId}` : ""}.`, queueId ? {
555
+ buttons: [[{ label: "Cancel queued message", action: `discord_peer_queue_cancel:${targetPeerId}:${queueId}` }]],
556
+ } : undefined);
557
+ },
558
+ sendCompleted: () => reply(request, "Remote turn completed."),
559
+ sendFailure: (message) => reply(request, `Remote peer failed: ${message}`),
560
+ });
561
+ };
525
562
  const handlePrompt = async (request, input, artifactOutDir, options = {}) => {
526
563
  const session = await getSession(request);
527
564
  const envelope = toPromptEnvelope(input, artifactOutDir);
528
565
  envelope.activityActor = actorFor(request);
566
+ if (!options.fromQueue && await handleRemotePrompt(request, envelope)) {
567
+ return;
568
+ }
529
569
  if (!options.fromQueue && await denyIfLocked(request)) {
530
570
  return;
531
571
  }
@@ -557,157 +597,41 @@ export function createDiscordBridge(config, registry) {
557
597
  }
558
598
  const busyState = getBusyState(request.contextKey);
559
599
  busyState.processing = true;
560
- const typing = setInterval(() => {
561
- void runtime.sendTyping(request.context).catch(() => { });
562
- }, TYPING_INTERVAL_MS);
563
- void runtime.sendTyping(request.context).catch(() => { });
564
- let accumulatedText = "";
565
- let responseMessageId;
566
- let planMessageId;
567
- let flushTimer;
568
- let lastEditAt = 0;
569
- let running = true;
570
- let finalized = false;
571
- const toolCounts = new Map();
572
- const toolVerbosity = config.toolVerbosity;
573
- const startedAt = Date.now();
574
- const turnId = randomUUID().slice(0, 12);
575
- const progress = {
576
- status: "running",
600
+ const engine = createChannelPromptEngine({
601
+ runtime,
602
+ context: request.context,
603
+ contextKey: request.contextKey,
577
604
  promptDescription: envelope.description,
578
- startedAt,
579
- updatedAt: startedAt,
580
- toolCounts,
581
- textCharacters: 0,
582
- };
605
+ abortAction: `discord_abort:${request.contextKey}`,
606
+ trimMessage: trimDiscordMessage,
607
+ splitMessage: splitDiscordMessage,
608
+ editDebounceMs: EDIT_DEBOUNCE_MS,
609
+ typingIntervalMs: TYPING_INTERVAL_MS,
610
+ toolVerbosity: config.toolVerbosity,
611
+ logPrefix: "Discord",
612
+ onResponseMessage: (messageId) => responseOwners.set(messageId, request.contextKey),
613
+ onToolStart: (toolName) => appendActivity(request, {
614
+ status: "running",
615
+ type: "tool_started",
616
+ prompt: envelope.description,
617
+ detail: toolName,
618
+ threadId: session.getInfo().threadId,
619
+ workspace: session.getInfo().workspace,
620
+ agentId: session.getInfo().agentId,
621
+ }),
622
+ onToolEnd: (isError) => appendActivity(request, {
623
+ status: isError ? "failed" : "completed",
624
+ type: isError ? "tool_failed" : "tool_completed",
625
+ prompt: envelope.description,
626
+ detail: "tool",
627
+ threadId: session.getInfo().threadId,
628
+ workspace: session.getInfo().workspace,
629
+ agentId: session.getInfo().agentId,
630
+ }),
631
+ });
632
+ const progress = engine.progress;
583
633
  turnProgress.set(request.contextKey, progress);
584
- const scheduleFlush = () => {
585
- if (flushTimer || !running) {
586
- return;
587
- }
588
- const delay = Math.max(0, EDIT_DEBOUNCE_MS - (Date.now() - lastEditAt));
589
- flushTimer = setTimeout(() => {
590
- flushTimer = undefined;
591
- void flushResponse().catch((error) => console.error("Failed to edit Discord response:", error));
592
- }, delay);
593
- };
594
- const ensureResponse = async () => {
595
- if (responseMessageId)
596
- return;
597
- const preview = trimDiscordMessage(accumulatedText || "Working...");
598
- const sent = await runtime.sendMessage(request.context, {
599
- text: preview,
600
- fallbackText: preview,
601
- buttons: [[{ label: "Abort", action: `discord_abort:${request.contextKey}` }]],
602
- });
603
- responseMessageId = sent.messageId;
604
- responseOwners.set(responseMessageId, request.contextKey);
605
- lastEditAt = Date.now();
606
- };
607
- const flushResponse = async (force = false) => {
608
- if (!accumulatedText.trim())
609
- return;
610
- await ensureResponse();
611
- if (!responseMessageId)
612
- return;
613
- const now = Date.now();
614
- if (!force && now - lastEditAt < EDIT_DEBOUNCE_MS)
615
- return;
616
- await runtime.editMessage(request.context, responseMessageId, {
617
- text: trimDiscordMessage(accumulatedText),
618
- fallbackText: trimDiscordMessage(accumulatedText),
619
- buttons: [[{ label: "Abort", action: `discord_abort:${request.contextKey}` }]],
620
- });
621
- lastEditAt = Date.now();
622
- };
623
- const finalize = async () => {
624
- if (finalized) {
625
- return;
626
- }
627
- finalized = true;
628
- running = false;
629
- clearInterval(typing);
630
- if (flushTimer) {
631
- clearTimeout(flushTimer);
632
- flushTimer = undefined;
633
- }
634
- const finalText = accumulatedText.trim() || "Done.";
635
- const chunks = splitDiscordMessage(finalText);
636
- if (responseMessageId) {
637
- const [first, ...rest] = chunks;
638
- await runtime.editMessage(request.context, responseMessageId, { text: first ?? "Done.", fallbackText: first ?? "Done." });
639
- for (const chunk of rest) {
640
- await runtime.sendMessage(request.context, { text: chunk, fallbackText: chunk });
641
- }
642
- }
643
- else {
644
- for (const chunk of chunks) {
645
- await runtime.sendMessage(request.context, { text: chunk, fallbackText: chunk });
646
- }
647
- }
648
- };
649
- const callbacks = {
650
- onTextDelta: (delta) => {
651
- accumulatedText += delta;
652
- progress.textCharacters = accumulatedText.length;
653
- progress.updatedAt = Date.now();
654
- void ensureResponse().then(() => scheduleFlush()).catch((error) => console.error("Failed to send Discord response:", error));
655
- },
656
- onToolStart: (toolName) => {
657
- toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1);
658
- progress.currentTool = toolName;
659
- progress.lastTool = toolName;
660
- progress.updatedAt = Date.now();
661
- appendActivity(request, {
662
- status: "running",
663
- type: "tool_started",
664
- prompt: envelope.description,
665
- detail: toolName,
666
- threadId: session.getInfo().threadId,
667
- workspace: session.getInfo().workspace,
668
- agentId: session.getInfo().agentId,
669
- });
670
- if (toolVerbosity === "all") {
671
- void runtime.sendMessage(request.context, { text: `Tool started: ${toolName}`, fallbackText: `Tool started: ${toolName}` }).catch(() => { });
672
- }
673
- },
674
- onToolUpdate: () => { },
675
- onToolEnd: (_toolCallId, isError) => {
676
- progress.currentTool = undefined;
677
- progress.updatedAt = Date.now();
678
- appendActivity(request, {
679
- status: isError ? "failed" : "completed",
680
- type: isError ? "tool_failed" : "tool_completed",
681
- prompt: envelope.description,
682
- detail: "tool",
683
- threadId: session.getInfo().threadId,
684
- workspace: session.getInfo().workspace,
685
- agentId: session.getInfo().agentId,
686
- });
687
- },
688
- onTodoUpdate: (items) => {
689
- progress.updatedAt = Date.now();
690
- const text = [
691
- "Plan:",
692
- ...items.map((item) => `${item.completed ? "[x]" : "[ ]"} ${item.text}`),
693
- ].join("\n");
694
- if (!planMessageId) {
695
- void runtime.sendMessage(request.context, { text, fallbackText: text }).then((result) => {
696
- planMessageId = result.messageId;
697
- }).catch(() => { });
698
- }
699
- else {
700
- void runtime.editMessage(request.context, planMessageId, { text, fallbackText: text }).catch(() => { });
701
- }
702
- },
703
- onTurnComplete: () => { },
704
- onAgentEnd: () => {
705
- progress.status = "completed";
706
- progress.completedAt = Date.now();
707
- progress.updatedAt = progress.completedAt;
708
- void finalize().catch((error) => console.error("Failed to finalize Discord response:", error));
709
- },
710
- };
634
+ engine.start();
711
635
  try {
712
636
  const info = session.getInfo();
713
637
  if ((info.capabilities ?? capabilitiesOf(info)).auth) {
@@ -739,15 +663,15 @@ export function createDiscordBridge(config, registry) {
739
663
  workspace: currentInfo.workspace,
740
664
  description: envelope.description,
741
665
  });
742
- await session.prompt(envelope.input, callbacks);
666
+ await session.prompt(envelope.input, engine.callbacks);
743
667
  updateSession(request, session);
744
668
  progress.status = "completed";
745
669
  progress.completedAt = Date.now();
746
670
  progress.updatedAt = progress.completedAt;
747
- await finalize();
748
- await artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, turnId, new Date(startedAt));
671
+ await engine.finalize();
672
+ await artifactService.persistWorkspaceArtifactsForTurn(session.getInfo().workspace, engine.turnId, new Date(engine.startedAt));
749
673
  if (config.discordAutoSendArtifacts) {
750
- await sendRecentDiscordArtifacts(artifactDeps, request, session, new Date(startedAt), turnId);
674
+ await sendRecentDiscordArtifacts(artifactDeps, request, session, new Date(engine.startedAt), engine.turnId);
751
675
  }
752
676
  appendActivity(request, {
753
677
  status: "completed",
@@ -756,7 +680,7 @@ export function createDiscordBridge(config, registry) {
756
680
  threadId: session.getInfo().threadId,
757
681
  workspace: session.getInfo().workspace,
758
682
  agentId: session.getInfo().agentId,
759
- durationMs: Date.now() - startedAt,
683
+ durationMs: Date.now() - engine.startedAt,
760
684
  });
761
685
  audit(request, {
762
686
  action: "prompt_completed",
@@ -772,13 +696,8 @@ export function createDiscordBridge(config, registry) {
772
696
  progress.completedAt = Date.now();
773
697
  progress.updatedAt = progress.completedAt;
774
698
  progress.error = friendlyErrorText(error);
775
- const errorText = renderPromptFailure(accumulatedText, error);
776
- if (responseMessageId) {
777
- await runtime.editMessage(request.context, responseMessageId, { text: trimDiscordMessage(errorText), fallbackText: trimDiscordMessage(errorText) }).catch(() => { });
778
- }
779
- else {
780
- await reply(request, errorText).catch(() => { });
781
- }
699
+ const errorText = renderPromptFailure(engine.accumulatedText(), error);
700
+ await engine.fail(errorText);
782
701
  appendActivity(request, {
783
702
  status: "failed",
784
703
  type: "prompt_failed",
@@ -787,7 +706,7 @@ export function createDiscordBridge(config, registry) {
787
706
  threadId: session.getInfo().threadId,
788
707
  workspace: session.getInfo().workspace,
789
708
  agentId: session.getInfo().agentId,
790
- durationMs: Date.now() - startedAt,
709
+ durationMs: Date.now() - engine.startedAt,
791
710
  });
792
711
  audit(request, {
793
712
  action: "prompt_failed",
@@ -800,8 +719,7 @@ export function createDiscordBridge(config, registry) {
800
719
  });
801
720
  }
802
721
  finally {
803
- running = false;
804
- clearInterval(typing);
722
+ engine.stop();
805
723
  busyState.processing = false;
806
724
  await drainQueue(request).catch((error) => console.error("Failed to drain Discord queue:", error));
807
725
  }
@@ -878,6 +796,58 @@ export function createDiscordBridge(config, registry) {
878
796
  if (state)
879
797
  state.artifactsDeliveredForTurnId = turnId;
880
798
  };
799
+ const commandDispatcher = createSharedChannelCommandDispatcher({
800
+ transport: "discord",
801
+ bindings: [
802
+ { names: ["start", "help"], handler: (request) => commandHelp(request) },
803
+ { names: ["channels"], handler: (request) => deliverChannelAction(runtime, request.context, commandService.renderChannels()).then(() => { }) },
804
+ { names: ["peers"], handler: (request) => deliverChannelAction(runtime, request.context, commandService.renderPeers()).then(() => { }) },
805
+ { names: ["target"], handler: (request, argument) => deliverChannelAction(runtime, request.context, commandService.renderTargetPreference({ source: "discord", contextKey: request.contextKey, argument, preferencesStore })).then(() => { }) },
806
+ { names: ["agents"], handler: (request) => deliverChannelAction(runtime, request.context, commandService.renderAgents()).then(() => { }) },
807
+ { names: ["agent"], handler: (request, argument) => commandAgent(request, argument) },
808
+ { names: ["auth"], handler: (request) => commandAuth(request) },
809
+ { names: ["login"], handler: (request) => commandLogin(request) },
810
+ { names: ["logout"], handler: (request) => commandLogout(request) },
811
+ { names: ["session"], handler: (request) => commandSession(request) },
812
+ { names: ["sessions"], handler: (request, argument) => commandSessions(request, argument) },
813
+ { names: ["new"], handler: (request, argument) => commandNew(request, argument) },
814
+ { names: ["switch", "attach"], handler: (request, argument) => commandSwitch(request, argument) },
815
+ { names: ["model"], handler: (request, argument) => commandModel(request, argument) },
816
+ { names: ["reasoning", "effort"], handler: (request, argument) => commandReasoning(request, argument) },
817
+ { names: ["fast"], handler: (request, argument) => commandFast(request, argument) },
818
+ { names: ["launch", "launch_profiles", "launch-profiles"], handler: (request, argument) => commandLaunch(request, argument) },
819
+ { names: ["queue"], handler: (request, argument) => commandQueue(request, argument) },
820
+ { names: ["clearqueue"], handler: (request) => { promptStore.clear(request.contextKey); return reply(request, "Queue cleared."); } },
821
+ { names: ["cancel"], handler: (request, argument) => commandQueue(request, `cancel ${argument}`) },
822
+ { names: ["abort", "stop"], handler: (request) => commandAbort(request) },
823
+ { names: ["retry"], handler: (request) => commandRetry(request) },
824
+ { names: ["sync"], handler: (request) => commandSync(request) },
825
+ { names: ["tasks", "progress"], handler: (request) => commandProgress(request) },
826
+ { names: ["activity"], handler: (request, argument) => commandActivity(request, argument) },
827
+ { names: ["audit"], handler: (request, argument) => commandAudit(request, argument) },
828
+ { names: ["artifacts"], handler: (request, argument) => commandArtifacts(request, argument) },
829
+ { names: ["logs"], handler: (request, argument) => commandLogs(request, argument) },
830
+ { names: ["version", "health", "status"], handler: (request) => commandVersion(request) },
831
+ { names: ["diagnostics", "support"], handler: (request) => commandDiagnostics(request) },
832
+ { names: ["restart"], handler: (request) => commandRestart(request) },
833
+ { names: ["update"], handler: (request, argument) => commandUpdate(request, argument) },
834
+ { names: ["lock"], handler: (request) => commandLock(request) },
835
+ { names: ["unlock"], handler: (request) => { lockStore.clear(request.contextKey); return reply(request, "Session unlocked."); } },
836
+ { names: ["locks"], handler: (request) => reply(request, lockStore.list().map((lock) => `${lock.contextKey}: ${lock.ownerLabel || lock.ownerUserId}`).join("\n") || "No active locks.") },
837
+ { names: ["mirror"], handler: (request, argument) => commandMirror(request, argument) },
838
+ { names: ["notify"], handler: (request, argument) => commandNotify(request, argument) },
839
+ { names: ["voice"], handler: (request, argument) => commandVoice(request, argument) },
840
+ { names: ["workspaces"], handler: (request) => commandWorkspaces(request) },
841
+ { names: ["pin"], handler: (request, argument) => commandPin(request, argument) },
842
+ { names: ["unpin"], handler: (request, argument) => commandUnpin(request, argument) },
843
+ { names: ["pinned"], handler: (request) => commandPinned(request) },
844
+ { names: ["handback"], handler: (request) => commandHandback(request) },
845
+ { names: ["register_channel"], handler: (request) => commandRegisterChannel(request) },
846
+ { names: ["link"], handler: (request, argument) => commandLink(request, argument) },
847
+ { names: ["whoami"], handler: (request) => reply(request, request.authUser ? `${request.authUser.user.displayName} <${request.authUser.user.email}>\nGroups: ${request.authUser.groups.map((group) => group.name).join(", ")}` : "Not linked.") },
848
+ { names: ["prompt"], handler: (request, argument) => handlePrompt(request, argument) },
849
+ ],
850
+ });
881
851
  const handleCommand = async (request, command, argument) => {
882
852
  const normalized = command.toLowerCase();
883
853
  const permission = requiredPermissionForDiscordCommand(normalized, argument);
@@ -885,158 +855,9 @@ export function createDiscordBridge(config, registry) {
885
855
  return;
886
856
  }
887
857
  audit(request, { action: "command", status: "ok", description: `/${normalized} ${argument}`.trim() });
888
- switch (normalized) {
889
- case "start":
890
- case "help":
891
- await commandHelp(request);
892
- return;
893
- case "channels":
894
- await deliverChannelAction(runtime, request.context, commandService.renderChannels());
895
- return;
896
- case "agents":
897
- await deliverChannelAction(runtime, request.context, commandService.renderAgents());
898
- return;
899
- case "agent":
900
- await commandAgent(request, argument);
901
- return;
902
- case "auth":
903
- await commandAuth(request);
904
- return;
905
- case "login":
906
- await commandLogin(request);
907
- return;
908
- case "logout":
909
- await commandLogout(request);
910
- return;
911
- case "session":
912
- await commandSession(request);
913
- return;
914
- case "sessions":
915
- await commandSessions(request, argument);
916
- return;
917
- case "new":
918
- await commandNew(request, argument);
919
- return;
920
- case "switch":
921
- case "attach":
922
- await commandSwitch(request, argument);
923
- return;
924
- case "model":
925
- await commandModel(request, argument);
926
- return;
927
- case "reasoning":
928
- case "effort":
929
- await commandReasoning(request, argument);
930
- return;
931
- case "fast":
932
- await commandFast(request, argument);
933
- return;
934
- case "launch":
935
- case "launch_profiles":
936
- case "launch-profiles":
937
- await commandLaunch(request, argument);
938
- return;
939
- case "queue":
940
- await commandQueue(request, argument);
941
- return;
942
- case "clearqueue":
943
- promptStore.clear(request.contextKey);
944
- await reply(request, "Queue cleared.");
945
- return;
946
- case "cancel":
947
- await commandQueue(request, `cancel ${argument}`);
948
- return;
949
- case "abort":
950
- case "stop":
951
- await commandAbort(request);
952
- return;
953
- case "retry":
954
- await commandRetry(request);
955
- return;
956
- case "sync":
957
- await commandSync(request);
958
- return;
959
- case "tasks":
960
- case "progress":
961
- await commandProgress(request);
962
- return;
963
- case "activity":
964
- await commandActivity(request, argument);
965
- return;
966
- case "audit":
967
- await commandAudit(request, argument);
968
- return;
969
- case "artifacts":
970
- await commandArtifacts(request, argument);
971
- return;
972
- case "logs":
973
- await commandLogs(request, argument);
974
- return;
975
- case "version":
976
- case "health":
977
- case "status":
978
- await commandVersion(request);
979
- return;
980
- case "diagnostics":
981
- await commandDiagnostics(request);
982
- return;
983
- case "support":
984
- await commandDiagnostics(request);
985
- return;
986
- case "restart":
987
- await commandRestart(request);
988
- return;
989
- case "update":
990
- await commandUpdate(request, argument);
991
- return;
992
- case "lock":
993
- await commandLock(request);
994
- return;
995
- case "unlock":
996
- lockStore.clear(request.contextKey);
997
- await reply(request, "Session unlocked.");
998
- return;
999
- case "locks":
1000
- await reply(request, lockStore.list().map((lock) => `${lock.contextKey}: ${lock.ownerLabel || lock.ownerUserId}`).join("\n") || "No active locks.");
1001
- return;
1002
- case "mirror":
1003
- await commandMirror(request, argument);
1004
- return;
1005
- case "notify":
1006
- await commandNotify(request, argument);
1007
- return;
1008
- case "voice":
1009
- await commandVoice(request, argument);
1010
- return;
1011
- case "workspaces":
1012
- await commandWorkspaces(request);
1013
- return;
1014
- case "pin":
1015
- await commandPin(request, argument);
1016
- return;
1017
- case "unpin":
1018
- await commandUnpin(request, argument);
1019
- return;
1020
- case "pinned":
1021
- await commandPinned(request);
1022
- return;
1023
- case "handback":
1024
- await commandHandback(request);
1025
- return;
1026
- case "register_channel":
1027
- await commandRegisterChannel(request);
1028
- return;
1029
- case "link":
1030
- await commandLink(request, argument);
1031
- return;
1032
- case "whoami":
1033
- await reply(request, request.authUser ? `${request.authUser.user.displayName} <${request.authUser.user.email}>\nGroups: ${request.authUser.groups.map((group) => group.name).join(", ")}` : "Not linked.");
1034
- return;
1035
- case "prompt":
1036
- await handlePrompt(request, argument);
1037
- return;
1038
- default:
1039
- await reply(request, `Unknown command: /${normalized}`);
858
+ const result = await commandDispatcher.dispatch(request, normalized, argument);
859
+ if (!result.matched) {
860
+ await reply(request, `Unknown command: /${normalized}`);
1040
861
  }
1041
862
  };
1042
863
  const commandHelp = async (request) => {
@@ -1046,7 +867,7 @@ export function createDiscordBridge(config, registry) {
1046
867
  "",
1047
868
  "Send a message to prompt the selected agent, or use slash commands.",
1048
869
  "",
1049
- "Core commands: `/agent`, `/agents`, `/auth`, `/login`, `/logout`, `/session`, `/sessions`, `/new`, `/switch`, `/attach`, `/handback`, `/workspaces`, `/pin`, `/unpin`, `/pinned`, `/model`, `/reasoning`, `/fast`, `/launch`, `/launch_profiles`, `/queue`, `/clearqueue`, `/cancel`, `/stop`, `/retry`, `/sync`, `/progress`, `/activity`, `/audit`, `/artifacts`, `/logs`, `/version`, `/diagnostics`, `/support`, `/restart`, `/update`, `/lock`, `/unlock`, `/locks`, `/mirror`, `/notify`, `/voice`, `/channels`, `/whoami`, `/link`, `/register_channel`.",
870
+ `Core commands: ${discordHelpCommandList()}.`,
1050
871
  "",
1051
872
  renderSessionInfoPlain(session.getInfo()),
1052
873
  ].join("\n"));
@@ -1595,33 +1416,32 @@ export function createDiscordBridge(config, registry) {
1595
1416
  await deliverChannelAction(runtime, request.context, commandService.renderHandback(result));
1596
1417
  };
1597
1418
  const commandMirror = async (request, argument) => {
1598
- const mode = parseMirrorMode(argument, preferencesStore.get(request.contextKey).mirrorMode ?? config.discordMirrorMode);
1599
- preferencesStore.update(request.contextKey, { mirrorMode: mode });
1600
- await reply(request, `CLI mirror mode: ${mode}`);
1419
+ const session = await getSession(request, { deferThreadStart: true });
1420
+ const info = session.getInfo();
1421
+ await deliverChannelAction(runtime, request.context, commandService.renderMirrorPreference({
1422
+ source: "discord",
1423
+ contextKey: request.contextKey,
1424
+ argument,
1425
+ preferencesStore,
1426
+ cliMirrorSupported: capabilitiesOf(info).cliMirror,
1427
+ agentLabel: info.agentLabel,
1428
+ }));
1601
1429
  };
1602
1430
  const commandNotify = async (request, argument) => {
1603
- const mode = parseNotifyMode(argument, preferencesStore.get(request.contextKey).notifyMode ?? config.discordNotifyMode);
1604
- preferencesStore.update(request.contextKey, { notifyMode: mode });
1605
- await reply(request, `Notify mode: ${mode}`);
1431
+ await deliverChannelAction(runtime, request.context, commandService.renderNotifyPreference({
1432
+ source: "discord",
1433
+ contextKey: request.contextKey,
1434
+ argument,
1435
+ preferencesStore,
1436
+ }));
1606
1437
  };
1607
1438
  const commandVoice = async (request, argument) => {
1608
- const normalized = argument.trim().toLowerCase();
1609
- const parts = normalized.split(/\s+/).filter(Boolean);
1610
- if (parts[0] === "backend" && parts[1]) {
1611
- preferencesStore.update(request.contextKey, { voiceBackend: parseVoiceBackendPreference(parts[1]) });
1612
- }
1613
- else if (parts[0] === "language" && parts[1]) {
1614
- preferencesStore.update(request.contextKey, { voiceLanguage: parts[1] === "auto" ? null : parts[1] });
1615
- }
1616
- else if ((parts[0] === "transcribe-only" || parts[0] === "transcribe_only") && parts[1]) {
1617
- preferencesStore.update(request.contextKey, { voiceTranscribeOnly: ["on", "true", "yes", "1"].includes(parts[1]) });
1618
- }
1619
- else if (argument.trim()) {
1620
- await reply(request, "Usage: `/voice`, `/voice backend auto|parakeet|faster-whisper|openai`, `/voice language auto|<code>`, or `/voice transcribe_only on|off`.");
1621
- return;
1622
- }
1623
- const prefs = preferencesStore.get(request.contextKey);
1624
- await reply(request, `Voice backend: ${prefs.voiceBackend ?? config.voicePreferredBackend}\nLanguage: ${prefs.voiceLanguage ?? config.voiceDefaultLanguage ?? "auto"}\nTranscribe only: ${prefs.voiceTranscribeOnly ?? config.voiceTranscribeOnly}`);
1439
+ await deliverChannelAction(runtime, request.context, await commandService.renderVoicePreference({
1440
+ source: "discord",
1441
+ contextKey: request.contextKey,
1442
+ argument,
1443
+ preferencesStore,
1444
+ }));
1625
1445
  };
1626
1446
  const commandRegisterChannel = async (request) => {
1627
1447
  const channel = userStore.registerDiscordChannel({
@@ -1781,6 +1601,17 @@ export function createDiscordBridge(config, registry) {
1781
1601
  await commandQueue(request, `${queueMatch[1]} ${queueMatch[3]}`);
1782
1602
  return;
1783
1603
  }
1604
+ const peerQueueMatch = action.match(/^discord_peer_queue_cancel:([^:]+):([^:]+)$/);
1605
+ if (peerQueueMatch?.[1] && peerQueueMatch[2]) {
1606
+ await remoteClient.webProxy(peerQueueMatch[1], {
1607
+ method: "POST",
1608
+ path: "/api/queue",
1609
+ body: { action: "cancel", id: peerQueueMatch[2] },
1610
+ contextKey: request.contextKey,
1611
+ }, actorFor(request), request.contextKey);
1612
+ await reply(request, `Cancelled remote queued prompt ${peerQueueMatch[2]}.`, { ephemeral: true });
1613
+ return;
1614
+ }
1784
1615
  const artifactMatch = action.match(/^discord_artifact_(send|zip|delete):(.+):([^:]+)$/);
1785
1616
  if (artifactMatch?.[1] && artifactMatch[2] === request.contextKey) {
1786
1617
  await commandArtifacts(request, `${artifactMatch[1]} ${artifactMatch[3]}`);