openclaw/src/discord/monitor/native-command.ts

1709 lines
53 KiB
TypeScript
Raw Normal View History

2026-01-15 01:13:36 -06:00
import {
Button,
ChannelType,
Command,
Container,
2026-01-15 01:13:36 -06:00
Row,
StringSelectMenu,
TextDisplay,
2026-01-15 01:13:36 -06:00
type AutocompleteInteraction,
type ButtonInteraction,
type CommandInteraction,
type CommandOptions,
type ComponentData,
type StringSelectMenuInteraction,
2026-01-15 01:13:36 -06:00
} from "@buape/carbon";
import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10";
import { resolveHumanDelayConfig } from "../../agents/identity.js";
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import type {
ChatCommandDefinition,
CommandArgDefinition,
CommandArgValues,
CommandArgs,
NativeCommandSpec,
} from "../../auto-reply/commands-registry.js";
2026-01-15 01:13:36 -06:00
import {
buildCommandTextFromArgs,
findCommandByNativeName,
listChatCommands,
parseCommandArgs,
resolveCommandArgChoices,
resolveCommandArgMenu,
serializeCommandArgs,
} from "../../auto-reply/commands-registry.js";
2026-01-17 05:04:29 +00:00
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
import { resolveStoredModelOverride } from "../../auto-reply/reply/model-selection.js";
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js";
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
import type { OpenClawConfig, loadConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
2026-01-14 01:08:15 +00:00
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "../../pairing/pairing-store.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
import { chunkItems } from "../../utils/chunk-items.js";
import { withTimeout } from "../../utils/with-timeout.js";
2026-01-14 01:08:15 +00:00
import { loadWebMedia } from "../../web/media.js";
2026-01-25 04:05:14 +00:00
import { chunkDiscordTextWithMode } from "../chunk.js";
2026-01-14 01:08:15 +00:00
import {
allowListMatches,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
2026-01-17 15:26:59 -06:00
resolveDiscordChannelConfigWithFallback,
2026-01-14 01:08:15 +00:00
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAllowFrom,
2026-01-14 01:08:15 +00:00
} from "./allow-list.js";
2026-01-17 15:26:59 -06:00
import { resolveDiscordChannelInfo } from "./message-utils.js";
import {
readDiscordModelPickerRecentModels,
recordDiscordModelPickerRecentModel,
type DiscordModelPickerPreferenceScope,
} from "./model-picker-preferences.js";
import {
DISCORD_MODEL_PICKER_CUSTOM_ID_KEY,
loadDiscordModelPickerData,
parseDiscordModelPickerData,
renderDiscordModelPickerModelsView,
renderDiscordModelPickerProvidersView,
renderDiscordModelPickerRecentsView,
toDiscordModelPickerMessagePayload,
type DiscordModelPickerCommandContext,
} from "./model-picker.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
import type { ThreadBindingManager } from "./thread-bindings.js";
2026-01-17 15:26:59 -06:00
import { resolveDiscordThreadParentInfo } from "./threading.js";
2026-01-14 01:08:15 +00:00
2026-01-30 03:15:10 +01:00
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
const log = createSubsystemLogger("discord/native-command");
2026-01-14 01:08:15 +00:00
2026-01-15 01:13:36 -06:00
function buildDiscordCommandOptions(params: {
command: ChatCommandDefinition;
cfg: ReturnType<typeof loadConfig>;
}): CommandOptions | undefined {
const { command, cfg } = params;
const args = command.args;
if (!args || args.length === 0) {
return undefined;
}
2026-01-15 01:13:36 -06:00
return args.map((arg) => {
const required = arg.required ?? false;
if (arg.type === "number") {
return {
name: arg.name,
description: arg.description,
type: ApplicationCommandOptionType.Number,
required,
};
}
if (arg.type === "boolean") {
return {
name: arg.name,
description: arg.description,
type: ApplicationCommandOptionType.Boolean,
required,
};
}
const resolvedChoices = resolveCommandArgChoices({ command, arg, cfg });
const shouldAutocomplete =
resolvedChoices.length > 0 &&
(typeof arg.choices === "function" || resolvedChoices.length > 25);
const autocomplete = shouldAutocomplete
? async (interaction: AutocompleteInteraction) => {
const focused = interaction.options.getFocused();
const focusValue =
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const choices = resolveCommandArgChoices({ command, arg, cfg });
const filtered = focusValue
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
2026-01-15 01:13:36 -06:00
: choices;
await interaction.respond(
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
2026-01-15 01:13:36 -06:00
);
}
: undefined;
const choices =
resolvedChoices.length > 0 && !autocomplete
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
? resolvedChoices
.slice(0, 25)
.map((choice) => ({ name: choice.label, value: choice.value }))
2026-01-15 01:13:36 -06:00
: undefined;
return {
name: arg.name,
description: arg.description,
type: ApplicationCommandOptionType.String,
required,
choices,
autocomplete,
};
}) satisfies CommandOptions;
}
function readDiscordCommandArgs(
interaction: CommandInteraction,
definitions?: CommandArgDefinition[],
): CommandArgs | undefined {
if (!definitions || definitions.length === 0) {
return undefined;
}
2026-01-15 01:13:36 -06:00
const values: CommandArgValues = {};
for (const definition of definitions) {
2026-01-15 18:37:02 +00:00
let value: string | number | boolean | null | undefined;
2026-01-15 01:13:36 -06:00
if (definition.type === "number") {
value = interaction.options.getNumber(definition.name) ?? null;
2026-01-15 01:13:36 -06:00
} else if (definition.type === "boolean") {
value = interaction.options.getBoolean(definition.name) ?? null;
2026-01-15 01:13:36 -06:00
} else {
value = interaction.options.getString(definition.name) ?? null;
2026-01-15 01:13:36 -06:00
}
if (value != null) {
values[definition.name] = value;
}
}
return Object.keys(values).length > 0 ? { values } : undefined;
}
const DISCORD_COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg";
function createCommandArgsWithValue(params: { argName: string; value: string }): CommandArgs {
const values: CommandArgValues = { [params.argName]: params.value };
return { values };
}
function encodeDiscordCommandArgValue(value: string): string {
return encodeURIComponent(value);
}
function decodeDiscordCommandArgValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isDiscordUnknownInteraction(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
const err = error as {
discordCode?: number;
status?: number;
message?: string;
rawBody?: { code?: number; message?: string };
};
if (err.discordCode === 10062 || err.rawBody?.code === 10062) {
return true;
}
if (err.status === 404 && /Unknown interaction/i.test(err.message ?? "")) {
return true;
}
if (/Unknown interaction/i.test(err.rawBody?.message ?? "")) {
return true;
}
return false;
}
async function safeDiscordInteractionCall<T>(
label: string,
fn: () => Promise<T>,
): Promise<T | null> {
try {
return await fn();
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
logVerbose(`discord: ${label} skipped (interaction expired)`);
return null;
}
throw error;
}
}
2026-01-15 01:13:36 -06:00
function buildDiscordCommandArgCustomId(params: {
command: string;
arg: string;
value: string;
userId: string;
}): string {
return [
`${DISCORD_COMMAND_ARG_CUSTOM_ID_KEY}:command=${encodeDiscordCommandArgValue(params.command)}`,
`arg=${encodeDiscordCommandArgValue(params.arg)}`,
`value=${encodeDiscordCommandArgValue(params.value)}`,
`user=${encodeDiscordCommandArgValue(params.userId)}`,
].join(";");
}
function parseDiscordCommandArgData(
data: ComponentData,
): { command: string; arg: string; value: string; userId: string } | null {
if (!data || typeof data !== "object") {
return null;
}
2026-01-15 01:13:36 -06:00
const coerce = (value: unknown) =>
typeof value === "string" || typeof value === "number" ? String(value) : "";
const rawCommand = coerce(data.command);
const rawArg = coerce(data.arg);
const rawValue = coerce(data.value);
const rawUser = coerce(data.user);
if (!rawCommand || !rawArg || !rawValue || !rawUser) {
return null;
}
2026-01-15 01:13:36 -06:00
return {
command: decodeDiscordCommandArgValue(rawCommand),
arg: decodeDiscordCommandArgValue(rawArg),
value: decodeDiscordCommandArgValue(rawValue),
userId: decodeDiscordCommandArgValue(rawUser),
2026-01-14 01:08:15 +00:00
};
2026-01-15 01:13:36 -06:00
}
type DiscordCommandArgContext = {
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ThreadBindingManager;
};
type DiscordModelPickerContext = DiscordCommandArgContext;
function resolveDiscordModelPickerCommandContext(
command: ChatCommandDefinition,
): DiscordModelPickerCommandContext | null {
const normalized = (command.nativeName ?? command.key).trim().toLowerCase();
if (normalized === "model" || normalized === "models") {
return normalized;
}
return null;
}
function resolveCommandArgStringValue(args: CommandArgs | undefined, key: string): string {
const value = args?.values?.[key];
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function shouldOpenDiscordModelPickerFromCommand(params: {
command: ChatCommandDefinition;
commandArgs?: CommandArgs;
}): DiscordModelPickerCommandContext | null {
const context = resolveDiscordModelPickerCommandContext(params.command);
if (!context) {
return null;
}
const serializedArgs = serializeCommandArgs(params.command, params.commandArgs)?.trim() ?? "";
if (context === "model") {
const modelValue = resolveCommandArgStringValue(params.commandArgs, "model");
return !modelValue && !serializedArgs ? context : null;
}
return serializedArgs ? null : context;
}
function buildDiscordModelPickerCurrentModel(
defaultProvider: string,
defaultModel: string,
): string {
return `${defaultProvider}/${defaultModel}`;
}
function buildDiscordModelPickerAllowedModelRefs(
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
): Set<string> {
const out = new Set<string>();
for (const provider of data.providers) {
const models = data.byProvider.get(provider);
if (!models) {
continue;
}
for (const model of models) {
out.add(`${provider}/${model}`);
}
}
return out;
}
function resolveDiscordModelPickerPreferenceScope(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
accountId: string;
userId: string;
}): DiscordModelPickerPreferenceScope {
return {
accountId: params.accountId,
guildId: params.interaction.guild?.id ?? undefined,
userId: params.userId,
};
}
function buildDiscordModelPickerNoticePayload(message: string): { components: Container[] } {
return {
components: [new Container([new TextDisplay(message)])],
};
}
async function resolveDiscordModelPickerRoute(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
cfg: ReturnType<typeof loadConfig>;
accountId: string;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ThreadBindingManager;
}) {
const { interaction, cfg, accountId } = params;
const channel = interaction.channel;
const channelType = channel?.type;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
const isThreadChannel =
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
const rawChannelId = channel?.id ?? "unknown";
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
let threadParentId: string | undefined;
if (interaction.guild && channel && isThreadChannel && rawChannelId) {
const channelInfo = await resolveDiscordChannelInfo(interaction.client, rawChannelId);
const parentInfo = await resolveDiscordThreadParentInfo({
client: interaction.client,
threadChannel: {
id: rawChannelId,
name: "name" in channel ? (channel.name as string | undefined) : undefined,
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
parent: undefined,
},
channelInfo,
});
threadParentId = parentInfo.id;
}
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId,
guildId: interaction.guild?.id ?? undefined,
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? (interaction.user?.id ?? rawChannelId) : rawChannelId,
},
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
});
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
const threadBinding = isThreadChannel
? params.threadBindings.getByThreadId(rawChannelId)
: undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
return boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
}
function resolveDiscordModelPickerCurrentModel(params: {
cfg: ReturnType<typeof loadConfig>;
route: ReturnType<typeof resolveAgentRoute>;
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
}): string {
const fallback = buildDiscordModelPickerCurrentModel(
params.data.resolvedDefault.provider,
params.data.resolvedDefault.model,
);
try {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const sessionStore = loadSessionStore(storePath, { skipCache: true });
const sessionEntry = sessionStore[params.route.sessionKey];
const override = resolveStoredModelOverride({
sessionEntry,
sessionStore,
sessionKey: params.route.sessionKey,
});
if (!override?.model) {
return fallback;
}
const provider = (override.provider || params.data.resolvedDefault.provider).trim();
if (!provider) {
return fallback;
}
return `${provider}/${override.model}`;
} catch {
return fallback;
}
}
async function replyWithDiscordModelPickerProviders(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
cfg: ReturnType<typeof loadConfig>;
command: DiscordModelPickerCommandContext;
userId: string;
accountId: string;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ThreadBindingManager;
preferFollowUp: boolean;
}) {
const data = await loadDiscordModelPickerData(params.cfg);
const route = await resolveDiscordModelPickerRoute({
interaction: params.interaction,
cfg: params.cfg,
accountId: params.accountId,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: params.threadBindings,
});
const currentModel = resolveDiscordModelPickerCurrentModel({
cfg: params.cfg,
route,
data,
});
const quickModels = await readDiscordModelPickerRecentModels({
scope: resolveDiscordModelPickerPreferenceScope({
interaction: params.interaction,
accountId: params.accountId,
userId: params.userId,
}),
allowedModelRefs: buildDiscordModelPickerAllowedModelRefs(data),
limit: 5,
});
const rendered = renderDiscordModelPickerModelsView({
command: params.command,
userId: params.userId,
data,
provider: splitDiscordModelRef(currentModel ?? "")?.provider ?? data.resolvedDefault.provider,
page: 1,
providerPage: 1,
currentModel,
quickModels,
});
const payload = {
...toDiscordModelPickerMessagePayload(rendered),
ephemeral: true,
};
await safeDiscordInteractionCall("model picker reply", async () => {
if (params.preferFollowUp) {
await params.interaction.followUp(payload);
return;
}
await params.interaction.reply(payload);
});
}
function resolveModelPickerSelectionValue(
interaction: ButtonInteraction | StringSelectMenuInteraction,
): string | null {
const rawValues = (interaction as { values?: string[] }).values;
if (!Array.isArray(rawValues) || rawValues.length === 0) {
return null;
}
const first = rawValues[0];
if (typeof first !== "string") {
return null;
}
const trimmed = first.trim();
return trimmed || null;
}
function buildDiscordModelPickerSelectionCommand(params: {
modelRef: string;
}): { command: ChatCommandDefinition; args: CommandArgs; prompt: string } | null {
const commandDefinition =
findCommandByNativeName("model", "discord") ??
listChatCommands().find((entry) => entry.key === "model");
if (!commandDefinition) {
return null;
}
const commandArgs: CommandArgs = {
values: {
model: params.modelRef,
},
raw: params.modelRef,
};
return {
command: commandDefinition,
args: commandArgs,
prompt: buildCommandTextFromArgs(commandDefinition, commandArgs),
};
}
function listDiscordModelPickerProviderModels(
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>,
provider: string,
): string[] {
const modelSet = data.byProvider.get(provider);
if (!modelSet) {
return [];
}
return [...modelSet].toSorted();
}
function resolveDiscordModelPickerModelIndex(params: {
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
provider: string;
model: string;
}): number | null {
const models = listDiscordModelPickerProviderModels(params.data, params.provider);
if (!models.length) {
return null;
}
const index = models.indexOf(params.model);
if (index < 0) {
return null;
}
return index + 1;
}
function resolveDiscordModelPickerModelByIndex(params: {
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
provider: string;
modelIndex?: number;
}): string | null {
if (!params.modelIndex || params.modelIndex < 1) {
return null;
}
const models = listDiscordModelPickerProviderModels(params.data, params.provider);
if (!models.length) {
return null;
}
return models[params.modelIndex - 1] ?? null;
}
function splitDiscordModelRef(modelRef: string): { provider: string; model: string } | null {
const trimmed = modelRef.trim();
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
return null;
}
const provider = trimmed.slice(0, slashIndex).trim();
const model = trimmed.slice(slashIndex + 1).trim();
if (!provider || !model) {
return null;
}
return { provider, model };
}
async function handleDiscordModelPickerInteraction(
interaction: ButtonInteraction | StringSelectMenuInteraction,
data: ComponentData,
ctx: DiscordModelPickerContext,
) {
const parsed = parseDiscordModelPickerData(data);
if (!parsed) {
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(
buildDiscordModelPickerNoticePayload(
"Sorry, that model picker interaction is no longer available.",
),
),
);
return;
}
if (interaction.user?.id && interaction.user.id !== parsed.userId) {
await safeDiscordInteractionCall("model picker ack", () => interaction.acknowledge());
return;
}
const pickerData = await loadDiscordModelPickerData(ctx.cfg);
const route = await resolveDiscordModelPickerRoute({
interaction,
cfg: ctx.cfg,
accountId: ctx.accountId,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ctx.threadBindings,
});
const currentModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
route,
data: pickerData,
});
const allowedModelRefs = buildDiscordModelPickerAllowedModelRefs(pickerData);
const preferenceScope = resolveDiscordModelPickerPreferenceScope({
interaction,
accountId: ctx.accountId,
userId: parsed.userId,
});
const quickModels = await readDiscordModelPickerRecentModels({
scope: preferenceScope,
allowedModelRefs,
limit: 5,
});
if (parsed.action === "recents") {
const rendered = renderDiscordModelPickerRecentsView({
command: parsed.command,
userId: parsed.userId,
data: pickerData,
quickModels,
currentModel: currentModelRef,
provider: parsed.provider,
page: parsed.page,
providerPage: parsed.providerPage,
});
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
);
return;
}
if (parsed.action === "back" && parsed.view === "providers") {
const rendered = renderDiscordModelPickerProvidersView({
command: parsed.command,
userId: parsed.userId,
data: pickerData,
page: parsed.page,
currentModel: currentModelRef,
});
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
);
return;
}
if (parsed.action === "back" && parsed.view === "models") {
const provider =
parsed.provider ??
splitDiscordModelRef(currentModelRef ?? "")?.provider ??
pickerData.resolvedDefault.provider;
const rendered = renderDiscordModelPickerModelsView({
command: parsed.command,
userId: parsed.userId,
data: pickerData,
provider,
page: parsed.page ?? 1,
providerPage: parsed.providerPage ?? 1,
currentModel: currentModelRef,
quickModels,
});
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
);
return;
}
if (parsed.action === "provider") {
const selectedProvider = resolveModelPickerSelectionValue(interaction) ?? parsed.provider;
if (!selectedProvider || !pickerData.byProvider.has(selectedProvider)) {
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(
buildDiscordModelPickerNoticePayload("Sorry, that provider isn't available anymore."),
),
);
return;
}
const rendered = renderDiscordModelPickerModelsView({
command: parsed.command,
userId: parsed.userId,
data: pickerData,
provider: selectedProvider,
page: 1,
providerPage: parsed.providerPage ?? parsed.page,
currentModel: currentModelRef,
quickModels,
});
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
);
return;
}
if (parsed.action === "model") {
const selectedModel = resolveModelPickerSelectionValue(interaction);
const provider = parsed.provider;
if (!provider || !selectedModel) {
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(
buildDiscordModelPickerNoticePayload("Sorry, I couldn't read that model selection."),
),
);
return;
}
const modelIndex = resolveDiscordModelPickerModelIndex({
data: pickerData,
provider,
model: selectedModel,
});
if (!modelIndex) {
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(
buildDiscordModelPickerNoticePayload("Sorry, that model isn't available anymore."),
),
);
return;
}
const modelRef = `${provider}/${selectedModel}`;
const rendered = renderDiscordModelPickerModelsView({
command: parsed.command,
userId: parsed.userId,
data: pickerData,
provider,
page: parsed.page,
providerPage: parsed.providerPage ?? 1,
currentModel: currentModelRef,
pendingModel: modelRef,
pendingModelIndex: modelIndex,
quickModels,
});
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(toDiscordModelPickerMessagePayload(rendered)),
);
return;
}
if (parsed.action === "submit" || parsed.action === "reset" || parsed.action === "quick") {
let modelRef: string | null = null;
if (parsed.action === "reset") {
modelRef = `${pickerData.resolvedDefault.provider}/${pickerData.resolvedDefault.model}`;
} else if (parsed.action === "quick") {
const slot = parsed.recentSlot ?? 0;
modelRef = slot >= 1 ? (quickModels[slot - 1] ?? null) : null;
} else if (parsed.view === "recents") {
const defaultModelRef = `${pickerData.resolvedDefault.provider}/${pickerData.resolvedDefault.model}`;
const dedupedRecents = quickModels.filter((ref) => ref !== defaultModelRef);
const slot = parsed.recentSlot ?? 0;
if (slot === 1) {
modelRef = defaultModelRef;
} else if (slot >= 2) {
modelRef = dedupedRecents[slot - 2] ?? null;
}
} else {
const provider = parsed.provider;
const selectedModel = resolveDiscordModelPickerModelByIndex({
data: pickerData,
provider: provider ?? "",
modelIndex: parsed.modelIndex,
});
modelRef = provider && selectedModel ? `${provider}/${selectedModel}` : null;
}
const parsedModelRef = modelRef ? splitDiscordModelRef(modelRef) : null;
if (
!parsedModelRef ||
!pickerData.byProvider.get(parsedModelRef.provider)?.has(parsedModelRef.model)
) {
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(
buildDiscordModelPickerNoticePayload(
"That selection expired. Please choose a model again.",
),
),
);
return;
}
const resolvedModelRef = `${parsedModelRef.provider}/${parsedModelRef.model}`;
const selectionCommand = buildDiscordModelPickerSelectionCommand({
modelRef: resolvedModelRef,
});
if (!selectionCommand) {
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(
buildDiscordModelPickerNoticePayload("Sorry, /model is unavailable right now."),
),
);
return;
}
const updateResult = await safeDiscordInteractionCall("model picker update", () =>
interaction.update(
buildDiscordModelPickerNoticePayload(`Applying model change to ${resolvedModelRef}...`),
),
);
if (updateResult === null) {
return;
}
try {
await withTimeout(
dispatchDiscordCommandInteraction({
interaction,
prompt: selectionCommand.prompt,
command: selectionCommand.command,
commandArgs: selectionCommand.args,
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
preferFollowUp: true,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ctx.threadBindings,
suppressReplies: true,
}),
12000,
);
} catch (error) {
if (error instanceof Error && error.message === "timeout") {
await safeDiscordInteractionCall("model picker follow-up", () =>
interaction.followUp({
...buildDiscordModelPickerNoticePayload(
`⏳ Model change to ${resolvedModelRef} is still processing. Check /status in a few seconds.`,
),
ephemeral: true,
}),
);
return;
}
await safeDiscordInteractionCall("model picker follow-up", () =>
interaction.followUp({
...buildDiscordModelPickerNoticePayload(
`❌ Failed to apply ${resolvedModelRef}. Try /model ${resolvedModelRef} directly.`,
),
ephemeral: true,
}),
);
return;
}
const effectiveModelRef = resolveDiscordModelPickerCurrentModel({
cfg: ctx.cfg,
route,
data: pickerData,
});
const persisted = effectiveModelRef === resolvedModelRef;
if (!persisted) {
logVerbose(
`discord: model picker override mismatch — expected ${resolvedModelRef} but read ${effectiveModelRef} from session key ${route.sessionKey}`,
);
}
if (persisted) {
await recordDiscordModelPickerRecentModel({
scope: preferenceScope,
modelRef: resolvedModelRef,
limit: 5,
}).catch(() => undefined);
}
await safeDiscordInteractionCall("model picker follow-up", () =>
interaction.followUp({
...buildDiscordModelPickerNoticePayload(
persisted
? `✅ Model set to ${resolvedModelRef}.`
: `⚠️ Tried to set ${resolvedModelRef}, but current model is ${effectiveModelRef}.`,
),
ephemeral: true,
}),
);
return;
}
if (parsed.action === "cancel") {
const displayModel = currentModelRef ?? "default";
await safeDiscordInteractionCall("model picker update", () =>
interaction.update(buildDiscordModelPickerNoticePayload(` Model kept as ${displayModel}.`)),
);
return;
}
}
async function handleDiscordCommandArgInteraction(
interaction: ButtonInteraction,
data: ComponentData,
ctx: DiscordCommandArgContext,
) {
const parsed = parseDiscordCommandArgData(data);
if (!parsed) {
await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: "Sorry, that selection is no longer available.",
components: [],
}),
);
return;
}
if (interaction.user?.id && interaction.user.id !== parsed.userId) {
await safeDiscordInteractionCall("command arg ack", () => interaction.acknowledge());
return;
}
const commandDefinition =
2026-01-24 09:58:06 +00:00
findCommandByNativeName(parsed.command, "discord") ??
listChatCommands().find((entry) => entry.key === parsed.command);
if (!commandDefinition) {
await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: "Sorry, that command is no longer available.",
components: [],
}),
);
return;
}
const argUpdateResult = await safeDiscordInteractionCall("command arg update", () =>
interaction.update({
content: `✅ Selected ${parsed.value}.`,
components: [],
}),
);
if (argUpdateResult === null) {
return;
}
const commandArgs = createCommandArgsWithValue({
argName: parsed.arg,
value: parsed.value,
});
const commandArgsWithRaw: CommandArgs = {
...commandArgs,
raw: serializeCommandArgs(commandDefinition, commandArgs),
};
const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw);
await dispatchDiscordCommandInteraction({
interaction,
prompt,
command: commandDefinition,
commandArgs: commandArgsWithRaw,
cfg: ctx.cfg,
discordConfig: ctx.discordConfig,
accountId: ctx.accountId,
sessionPrefix: ctx.sessionPrefix,
preferFollowUp: true,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ctx.threadBindings,
});
}
2026-01-15 01:13:36 -06:00
class DiscordCommandArgButton extends Button {
label: string;
customId: string;
style = ButtonStyle.Secondary;
private cfg: ReturnType<typeof loadConfig>;
private discordConfig: DiscordConfig;
private accountId: string;
private sessionPrefix: string;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
private threadBindings: ThreadBindingManager;
2026-01-15 01:13:36 -06:00
constructor(params: {
label: string;
customId: string;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ThreadBindingManager;
2026-01-15 01:13:36 -06:00
}) {
super();
this.label = params.label;
this.customId = params.customId;
this.cfg = params.cfg;
this.discordConfig = params.discordConfig;
this.accountId = params.accountId;
this.sessionPrefix = params.sessionPrefix;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
this.threadBindings = params.threadBindings;
2026-01-15 01:13:36 -06:00
}
async run(interaction: ButtonInteraction, data: ComponentData) {
await handleDiscordCommandArgInteraction(interaction, data, {
2026-01-15 01:13:36 -06:00
cfg: this.cfg,
discordConfig: this.discordConfig,
accountId: this.accountId,
sessionPrefix: this.sessionPrefix,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: this.threadBindings,
2026-01-15 01:13:36 -06:00
});
}
}
class DiscordCommandArgFallbackButton extends Button {
label = "cmdarg";
customId = "cmdarg:seed=1";
private ctx: DiscordCommandArgContext;
constructor(ctx: DiscordCommandArgContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData) {
await handleDiscordCommandArgInteraction(interaction, data, this.ctx);
}
}
export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgContext): Button {
return new DiscordCommandArgFallbackButton(params);
}
class DiscordModelPickerFallbackButton extends Button {
label = DISCORD_MODEL_PICKER_CUSTOM_ID_KEY;
customId = `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:seed=btn`;
private ctx: DiscordModelPickerContext;
constructor(ctx: DiscordModelPickerContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData) {
await handleDiscordModelPickerInteraction(interaction, data, this.ctx);
}
}
class DiscordModelPickerFallbackSelect extends StringSelectMenu {
customId = `${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:seed=sel`;
options = [];
private ctx: DiscordModelPickerContext;
constructor(ctx: DiscordModelPickerContext) {
super();
this.ctx = ctx;
}
async run(interaction: StringSelectMenuInteraction, data: ComponentData) {
await handleDiscordModelPickerInteraction(interaction, data, this.ctx);
}
}
export function createDiscordModelPickerFallbackButton(params: DiscordModelPickerContext): Button {
return new DiscordModelPickerFallbackButton(params);
}
export function createDiscordModelPickerFallbackSelect(
params: DiscordModelPickerContext,
): StringSelectMenu {
return new DiscordModelPickerFallbackSelect(params);
}
2026-01-15 01:13:36 -06:00
function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition;
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
menu: {
arg: CommandArgDefinition;
choices: Array<{ value: string; label: string }>;
title?: string;
};
2026-01-15 01:13:36 -06:00
interaction: CommandInteraction;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ThreadBindingManager;
2026-01-15 01:13:36 -06:00
}): { content: string; components: Row<Button>[] } {
const { command, menu, interaction } = params;
const commandLabel = command.nativeName ?? command.key;
const userId = interaction.user?.id ?? "";
const rows = chunkItems(menu.choices, 4).map((choices) => {
const buttons = choices.map(
(choice) =>
new DiscordCommandArgButton({
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
label: choice.label,
2026-01-15 01:13:36 -06:00
customId: buildDiscordCommandArgCustomId({
command: commandLabel,
arg: menu.arg.name,
fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Shadow <hi@shadowing.dev>
2026-01-26 21:51:53 -04:00
value: choice.value,
2026-01-15 01:13:36 -06:00
userId,
}),
cfg: params.cfg,
discordConfig: params.discordConfig,
accountId: params.accountId,
sessionPrefix: params.sessionPrefix,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: params.threadBindings,
2026-01-15 01:13:36 -06:00
}),
);
return new Row(buttons);
});
const content =
menu.title ?? `Choose ${menu.arg.description || menu.arg.name} for /${commandLabel}.`;
return { content, components: rows };
}
export function createDiscordNativeCommand(params: {
command: NativeCommandSpec;
2026-01-14 01:08:15 +00:00
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
ephemeralDefault: boolean;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ThreadBindingManager;
}): Command {
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
const {
command,
cfg,
discordConfig,
accountId,
sessionPrefix,
ephemeralDefault,
threadBindings,
} = params;
2026-01-15 01:13:36 -06:00
const commandDefinition =
2026-01-24 09:58:06 +00:00
findCommandByNativeName(command.name, "discord") ??
2026-01-15 01:13:36 -06:00
({
key: command.name,
nativeName: command.name,
description: command.description,
textAliases: [],
acceptsArgs: command.acceptsArgs,
args: command.args,
argsParsing: "none",
scope: "native",
} satisfies ChatCommandDefinition);
const argDefinitions = commandDefinition.args ?? command.args;
const commandOptions = buildDiscordCommandOptions({
command: commandDefinition,
cfg,
});
const options = commandOptions
? (commandOptions satisfies CommandOptions)
: command.acceptsArgs
2026-01-14 01:08:15 +00:00
? ([
{
name: "input",
description: "Command input",
type: ApplicationCommandOptionType.String,
required: false,
},
] satisfies CommandOptions)
: undefined;
2026-01-15 01:13:36 -06:00
return new (class extends Command {
name = command.name;
description = command.description;
defer = true;
ephemeral = ephemeralDefault;
options = options;
2026-01-14 01:08:15 +00:00
async run(interaction: CommandInteraction) {
2026-01-15 01:13:36 -06:00
const commandArgs = argDefinitions?.length
? readDiscordCommandArgs(interaction, argDefinitions)
: command.acceptsArgs
? parseCommandArgs(commandDefinition, interaction.options.getString("input") ?? "")
: undefined;
const commandArgsWithRaw = commandArgs
? ({
...commandArgs,
raw: serializeCommandArgs(commandDefinition, commandArgs) ?? commandArgs.raw,
} satisfies CommandArgs)
: undefined;
const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw);
await dispatchDiscordCommandInteraction({
interaction,
prompt,
command: commandDefinition,
commandArgs: commandArgsWithRaw,
cfg,
discordConfig,
accountId,
sessionPrefix,
preferFollowUp: false,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings,
2026-01-14 01:08:15 +00:00
});
2026-01-15 01:13:36 -06:00
}
})();
}
async function dispatchDiscordCommandInteraction(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
2026-01-15 01:13:36 -06:00
prompt: string;
command: ChatCommandDefinition;
commandArgs?: CommandArgs;
cfg: ReturnType<typeof loadConfig>;
discordConfig: DiscordConfig;
accountId: string;
sessionPrefix: string;
preferFollowUp: boolean;
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings: ThreadBindingManager;
suppressReplies?: boolean;
2026-01-15 01:13:36 -06:00
}) {
const {
interaction,
prompt,
command,
commandArgs,
cfg,
discordConfig,
accountId,
sessionPrefix,
preferFollowUp,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings,
suppressReplies,
2026-01-15 01:13:36 -06:00
} = params;
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
const payload = {
content,
...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}),
};
await safeDiscordInteractionCall("interaction reply", async () => {
if (preferFollowUp) {
await interaction.followUp(payload);
return;
}
await interaction.reply(payload);
});
2026-01-15 01:13:36 -06:00
};
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const user = interaction.user;
if (!user) {
return;
}
const sender = resolveDiscordSenderIdentity({ author: user, pluralkitInfo: null });
2026-01-15 01:13:36 -06:00
const channel = interaction.channel;
const channelType = channel?.type;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
2026-01-17 15:26:59 -06:00
const isThreadChannel =
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
2026-01-15 01:13:36 -06:00
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
2026-01-17 15:26:59 -06:00
const rawChannelId = channel?.id ?? "";
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
: [];
const ownerAllowList = normalizeDiscordAllowList(
discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? [],
["discord:", "user:", "pk:"],
);
const ownerOk =
ownerAllowList && user
? allowListMatches(ownerAllowList, {
id: sender.id,
name: sender.name,
tag: sender.tag,
})
: false;
2026-01-15 01:13:36 -06:00
const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined,
guildEntries: discordConfig?.guilds,
});
2026-01-17 15:26:59 -06:00
let threadParentId: string | undefined;
let threadParentName: string | undefined;
let threadParentSlug = "";
if (interaction.guild && channel && isThreadChannel && rawChannelId) {
// Threads inherit parent channel config unless explicitly overridden.
const channelInfo = await resolveDiscordChannelInfo(interaction.client, rawChannelId);
const parentInfo = await resolveDiscordThreadParentInfo({
client: interaction.client,
threadChannel: {
id: rawChannelId,
name: channelName,
parentId: "parentId" in channel ? (channel.parentId ?? undefined) : undefined,
2026-01-17 15:26:59 -06:00
parent: undefined,
},
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
}
2026-01-15 01:13:36 -06:00
const channelConfig = interaction.guild
2026-01-17 15:26:59 -06:00
? resolveDiscordChannelConfigWithFallback({
2026-01-15 01:13:36 -06:00
guildInfo,
2026-01-17 15:26:59 -06:00
channelId: rawChannelId,
2026-01-15 01:13:36 -06:00
channelName,
channelSlug,
2026-01-17 15:26:59 -06:00
parentId: threadParentId,
parentName: threadParentName,
parentSlug: threadParentSlug,
scope: isThreadChannel ? "thread" : "channel",
2026-01-15 01:13:36 -06:00
})
: null;
if (channelConfig?.enabled === false) {
await respond("This channel is disabled.");
return;
}
if (interaction.guild && channelConfig?.allowed === false) {
await respond("This channel is not allowed.");
return;
}
if (useAccessGroups && interaction.guild) {
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
const allowByPolicy = isDiscordGroupAllowedByPolicy({
groupPolicy: discordConfig?.groupPolicy ?? "open",
guildAllowlisted: Boolean(guildInfo),
2026-01-15 01:13:36 -06:00
channelAllowlistConfigured,
channelAllowed,
});
if (!allowByPolicy) {
await respond("This channel is not allowed.");
return;
}
}
const dmEnabled = discordConfig?.dm?.enabled ?? true;
const dmPolicy = discordConfig?.dmPolicy ?? discordConfig?.dm?.policy ?? "pairing";
2026-01-15 01:13:36 -06:00
let commandAuthorized = true;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
await respond("Discord DMs are disabled.");
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom =
dmPolicy === "allowlist" ? [] : await readChannelAllowFromStore("discord").catch(() => []);
const effectiveAllowFrom = [
...(discordConfig?.allowFrom ?? discordConfig?.dm?.allowFrom ?? []),
...storeAllowFrom,
];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
2026-01-15 01:13:36 -06:00
const permitted = allowList
? allowListMatches(allowList, {
id: sender.id,
name: sender.name,
tag: sender.tag,
2026-01-14 01:08:15 +00:00
})
2026-01-15 01:13:36 -06:00
: false;
if (!permitted) {
commandAuthorized = false;
if (dmPolicy === "pairing") {
const { code, created } = await upsertChannelPairingRequest({
channel: "discord",
id: user.id,
meta: {
tag: sender.tag,
name: sender.name,
2026-01-15 01:13:36 -06:00
},
2026-01-14 01:08:15 +00:00
});
2026-01-15 01:13:36 -06:00
if (created) {
await respond(
buildPairingReply({
2026-01-14 01:08:15 +00:00
channel: "discord",
2026-01-15 01:13:36 -06:00
idLine: `Your Discord user id: ${user.id}`,
code,
}),
{ ephemeral: true },
);
2026-01-14 01:08:15 +00:00
}
2026-01-15 01:13:36 -06:00
} else {
await respond("You are not authorized to use this command.", { ephemeral: true });
2026-01-14 01:08:15 +00:00
}
return;
}
2026-01-15 01:13:36 -06:00
commandAuthorized = true;
}
}
if (!isDirectMessage) {
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender,
});
const authorizers = useAccessGroups
? [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
]
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers,
modeWhenAccessGroupsOff: "configured",
});
if (!commandAuthorized) {
await respond("You are not authorized to use this command.", { ephemeral: true });
return;
2026-01-15 01:13:36 -06:00
}
}
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
await respond("Discord group DMs are disabled.");
return;
}
2026-01-14 01:08:15 +00:00
2026-01-15 01:13:36 -06:00
const menu = resolveCommandArgMenu({
command,
args: commandArgs,
cfg,
});
if (menu) {
const menuPayload = buildDiscordCommandArgMenu({
command,
menu,
interaction: interaction as CommandInteraction,
cfg,
discordConfig,
accountId,
sessionPrefix,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings,
2026-01-15 01:13:36 -06:00
});
if (preferFollowUp) {
await safeDiscordInteractionCall("interaction follow-up", () =>
interaction.followUp({
content: menuPayload.content,
components: menuPayload.components,
ephemeral: true,
}),
);
return;
}
await safeDiscordInteractionCall("interaction reply", () =>
interaction.reply({
2026-01-15 01:13:36 -06:00
content: menuPayload.content,
components: menuPayload.components,
ephemeral: true,
}),
);
2026-01-15 01:13:36 -06:00
return;
}
const pickerCommandContext = shouldOpenDiscordModelPickerFromCommand({
command,
commandArgs,
});
if (pickerCommandContext) {
await replyWithDiscordModelPickerProviders({
interaction,
cfg,
command: pickerCommandContext,
userId: user.id,
accountId,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
threadBindings,
preferFollowUp,
});
return;
}
2026-01-15 01:13:36 -06:00
const isGuild = Boolean(interaction.guild);
2026-01-17 15:26:59 -06:00
const channelId = rawChannelId || "unknown";
2026-01-15 01:13:36 -06:00
const interactionId = interaction.rawData.id;
const route = resolveAgentRoute({
cfg,
channel: "discord",
accountId,
guildId: interaction.guild?.id ?? undefined,
memberRoleIds,
2026-01-15 01:13:36 -06:00
peer: {
refactor: unify peer kind to ChatType, rename dm to direct (#11881) * fix: use .js extension for ESM imports of RoutePeerKind The imports incorrectly used .ts extension which doesn't resolve with moduleResolution: NodeNext. Changed to .js and added 'type' import modifier. * fix tsconfig * refactor: unify peer kind to ChatType, rename dm to direct - Replace RoutePeerKind with ChatType throughout codebase - Change 'dm' literal values to 'direct' in routing/session keys - Keep backward compat: normalizeChatType accepts 'dm' -> 'direct' - Add ChatType export to plugin-sdk, deprecate RoutePeerKind - Update session key parsing to accept both 'dm' and 'direct' markers - Update all channel monitors and extensions to use ChatType BREAKING CHANGE: Session keys now use 'direct' instead of 'dm'. Existing 'dm' keys still work via backward compat layer. * fix tests * test: update session key expectations for dmdirect migration - Fix test expectations to expect :direct: in generated output - Add explicit backward compat test for normalizeChatType('dm') - Keep input test data with :dm: keys to verify backward compat * fix: accept legacy 'dm' in session key parsing for backward compat getDmHistoryLimitFromSessionKey now accepts both :dm: and :direct: to ensure old session keys continue to work correctly. * test: add explicit backward compat tests for dmdirect migration - session-key.test.ts: verify both :dm: and :direct: keys are valid - getDmHistoryLimitFromSessionKey: verify both formats work * feat: backward compat for resetByType.dm config key * test: skip unix-path Nix tests on Windows
2026-02-08 16:20:52 -08:00
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
2026-01-15 01:13:36 -06:00
id: isDirectMessage ? user.id : channelId,
},
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
2026-01-15 01:13:36 -06:00
});
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
const effectiveRoute = boundSessionKey
? {
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
2026-01-17 04:04:05 +00:00
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
channelConfig,
guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag },
});
2026-01-17 05:04:29 +00:00
const ctxPayload = finalizeInboundContext({
2026-01-15 01:13:36 -06:00
Body: prompt,
BodyForAgent: prompt,
2026-01-17 05:04:29 +00:00
RawBody: prompt,
2026-01-15 01:13:36 -06:00
CommandBody: prompt,
CommandArgs: commandArgs,
2026-01-17 08:46:19 +00:00
From: isDirectMessage
? `discord:${user.id}`
: isGroupDm
? `discord:group:${channelId}`
: `discord:channel:${channelId}`,
2026-01-15 01:13:36 -06:00
To: `slash:${user.id}`,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
SessionKey: boundSessionKey ?? `agent:${effectiveRoute.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: boundSessionKey ?? effectiveRoute.sessionKey,
AccountId: effectiveRoute.accountId,
2026-01-17 04:04:05 +00:00
ChatType: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
ConversationLabel: conversationLabel,
2026-01-15 01:13:36 -06:00
GroupSubject: isGuild ? interaction.guild?.name : undefined,
GroupSystemPrompt: isGuild
? (() => {
const systemPromptParts = [channelConfig?.systemPrompt?.trim() || null].filter(
(entry): entry is string => Boolean(entry),
);
return systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
})()
: undefined,
UntrustedContext: isGuild
2026-01-15 01:13:36 -06:00
? (() => {
const channelTopic =
channel && "topic" in channel ? (channel.topic ?? undefined) : undefined;
const untrustedChannelMetadata = buildUntrustedChannelMetadata({
source: "discord",
label: "Discord channel topic",
entries: [channelTopic],
});
return untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined;
2026-01-15 01:13:36 -06:00
})()
: undefined,
OwnerAllowFrom: ownerAllowFrom,
2026-01-15 01:13:36 -06:00
SenderName: user.globalName ?? user.username,
SenderId: user.id,
SenderUsername: user.username,
SenderTag: sender.tag,
2026-01-15 01:13:36 -06:00
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: true,
MessageSid: interactionId,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
MessageThreadId: isThreadChannel ? channelId : undefined,
2026-01-15 01:13:36 -06:00
Timestamp: Date.now(),
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
// Native slash contexts use To=slash:<user> for interaction routing.
// For follow-up delivery (for example subagent completion announces),
// preserve the real Discord target separately.
OriginatingChannel: "discord" as const,
OriginatingTo: isDirectMessage ? `user:${user.id}` : `channel:${channelId}`,
2026-01-17 05:04:29 +00:00
});
2026-01-15 01:13:36 -06:00
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
agentId: effectiveRoute.agentId,
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
channel: "discord",
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
accountId: effectiveRoute.accountId,
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
});
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
2026-01-15 01:13:36 -06:00
let didReply = false;
await dispatchReplyWithDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
...prefixOptions,
feat: thread-bound subagents on Discord (#21805) * docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
2026-02-21 16:14:55 +01:00
humanDelay: resolveHumanDelayConfig(cfg, effectiveRoute.agentId),
2026-01-15 01:13:36 -06:00
deliver: async (payload) => {
if (suppressReplies) {
return;
}
try {
await deliverDiscordInteractionReply({
interaction,
payload,
mediaLocalRoots,
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
fallbackLimit: 2000,
}),
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
preferFollowUp: preferFollowUp || didReply,
2026-01-25 04:05:14 +00:00
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
} catch (error) {
if (isDiscordUnknownInteraction(error)) {
logVerbose("discord: interaction reply skipped (interaction expired)");
return;
}
throw error;
}
2026-01-15 01:13:36 -06:00
didReply = true;
},
onError: (err, info) => {
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
log.error(`discord slash ${info.kind} reply failed: ${message}`);
2026-01-15 01:13:36 -06:00
},
},
replyOptions: {
skillFilter: channelConfig?.skills,
disableBlockStreaming:
typeof discordConfig?.blockStreaming === "boolean"
? !discordConfig.blockStreaming
: undefined,
feat: per-channel responsePrefix override (#9001) * feat: per-channel responsePrefix override Add responsePrefix field to all channel config types and Zod schemas, enabling per-channel and per-account outbound response prefix overrides. Resolution cascade (most specific wins): L1: channels.<ch>.accounts.<id>.responsePrefix L2: channels.<ch>.responsePrefix L3: (reserved for channels.defaults) L4: messages.responsePrefix (existing global) Semantics: - undefined -> inherit from parent level - empty string -> explicitly no prefix (stops cascade) - "auto" -> derive [identity.name] from routed agent Changes: - Core logic: resolveResponsePrefix() in identity.ts accepts optional channel/accountId and walks the cascade - resolveEffectiveMessagesConfig() passes channel context through - Types: responsePrefix added to WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Google Chat, MS Teams, Feishu, BlueBubbles configs - Zod schemas: responsePrefix added for config validation - All channel handlers wired: telegram, discord, slack, signal, imessage, line, heartbeat runner, route-reply, native commands - 23 new tests covering backward compat, channel/account levels, full cascade, auto keyword, empty string stops, unknown fallthrough Fully backward compatible - no existing config is affected. Fixes #8857 * fix: address CI lint + review feedback - Replace Record<string, any> with proper typed helpers (no-explicit-any) - Add curly braces to single-line if returns (eslint curly) - Fix JSDoc: 'Per-channel' → 'channel/account' on shared config types - Extract getChannelConfig() helper for type-safe dynamic key access * fix: finish responsePrefix overrides (#9001) (thanks @mudrii) * fix: normalize prefix wiring and types (#9001) (thanks @mudrii) --------- Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-05 05:16:34 +08:00
onModelSelected,
2026-01-15 01:13:36 -06:00
},
});
2026-01-14 01:08:15 +00:00
}
async function deliverDiscordInteractionReply(params: {
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
2026-01-14 01:08:15 +00:00
payload: ReplyPayload;
mediaLocalRoots?: readonly string[];
2026-01-14 01:08:15 +00:00
textLimit: number;
maxLinesPerMessage?: number;
preferFollowUp: boolean;
2026-01-25 04:05:14 +00:00
chunkMode: "length" | "newline";
2026-01-14 01:08:15 +00:00
}) {
2026-01-25 04:05:14 +00:00
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
2026-01-14 01:08:15 +00:00
const text = payload.text ?? "";
let hasReplied = false;
const sendMessage = async (content: string, files?: { name: string; data: Buffer }[]) => {
2026-01-14 01:08:15 +00:00
const payload =
files && files.length > 0
? {
content,
files: files.map((file) => {
if (file.data instanceof Blob) {
return { name: file.name, data: file.data };
}
const arrayBuffer = Uint8Array.from(file.data).buffer;
return { name: file.name, data: new Blob([arrayBuffer]) };
}),
}
: { content };
await safeDiscordInteractionCall("interaction send", async () => {
if (!preferFollowUp && !hasReplied) {
await interaction.reply(payload);
hasReplied = true;
return;
}
await interaction.followUp(payload);
2026-01-14 01:08:15 +00:00
hasReplied = true;
});
2026-01-14 01:08:15 +00:00
};
if (mediaList.length > 0) {
const media = await Promise.all(
mediaList.map(async (url) => {
const loaded = await loadWebMedia(url, {
localRoots: params.mediaLocalRoots,
});
2026-01-14 01:08:15 +00:00
return {
name: loaded.fileName ?? "upload",
data: loaded.buffer,
};
}),
);
2026-01-25 04:05:14 +00:00
const chunks = chunkDiscordTextWithMode(text, {
2026-01-14 01:08:15 +00:00
maxChars: textLimit,
maxLines: maxLinesPerMessage,
2026-01-25 04:05:14 +00:00
chunkMode,
2026-01-14 01:08:15 +00:00
});
if (!chunks.length && text) {
chunks.push(text);
}
2026-01-14 01:08:15 +00:00
const caption = chunks[0] ?? "";
await sendMessage(caption, media);
for (const chunk of chunks.slice(1)) {
if (!chunk.trim()) {
continue;
}
2026-01-14 01:08:15 +00:00
await interaction.followUp({ content: chunk });
}
return;
}
if (!text.trim()) {
return;
}
2026-01-25 04:05:14 +00:00
const chunks = chunkDiscordTextWithMode(text, {
2026-01-14 01:08:15 +00:00
maxChars: textLimit,
maxLines: maxLinesPerMessage,
2026-01-25 04:05:14 +00:00
chunkMode,
2026-01-14 01:08:15 +00:00
});
if (!chunks.length && text) {
chunks.push(text);
}
2026-01-14 01:08:15 +00:00
for (const chunk of chunks) {
if (!chunk.trim()) {
continue;
}
2026-01-14 01:08:15 +00:00
await sendMessage(chunk);
}
}