import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, InputEvent, ToolCallEvent, ToolResultEvent, } from "@earendil-works/pi-coding-agent"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { TextContent } from "@earendil-works/pi-ai"; import { Client, Events, GatewayIntentBits, REST, Routes, SlashCommandBuilder, ThreadAutoArchiveDuration, type ChatInputCommandInteraction, type Message as DiscordMessage, type MessageCreateOptions, type MessageEditOptions, type RESTPostAPIChatInputApplicationCommandsJSONBody, } from "discord.js"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { createServer, type IncomingMessage, type Server as HttpServer, } from "node:http"; import { spawn } from "node:child_process"; // MessageStart/Update/End event types are declared in pi-coding-agent's // internal `core/extensions/types` module but not re-exported from the root // `@earendil-works/pi-coding-agent` package entry, so we mirror the minimal // shape we use. Source of truth: // node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/types.d.ts // (interfaces MessageStartEvent / MessageUpdateEvent / MessageEndEvent). interface MessageEventLike { message: AgentMessage; } interface Config { enabled: boolean; allowLegacyEnv: boolean; botToken: string; channelIds: Set; userIds: Set; primaryChannelId: string; activeChannelId: string; createThreadPerSession: boolean; threadNamePrefix: string; threadAutoArchiveDuration: ThreadAutoArchiveDuration; editIntervalMs: number; createIntervalMs: number; maxChars: number; renderUserInput: boolean; renderToolCalls: boolean; renderToolResults: boolean; renderReasoning: boolean; toolArgsMaxChars: number; toolResultMaxChars: number; prefix: string; steerLabel: string; registerSlashCommands: boolean; slashCommandGuildIds: Set; launcherEnabled: boolean; launcherPort: number; launcherSecret: string; spawnCommand: string; spawnArgs: string[]; } const RELAYED_TYPE = "discord-remote/relayed"; const THREAD_BINDING_TYPE = "discord-remote/thread-binding"; function unquoteEnvValue(value: string): string { const trimmed = value.trim(); if (trimmed.length >= 2) { const quote = trimmed[0]; if ( (quote === '"' || quote === "'") && trimmed[trimmed.length - 1] === quote ) { return trimmed.slice(1, -1).replace(/\\n/g, "\n").replace(/\\r/g, "\r"); } } return trimmed; } function parseDotEnvLine(line: string): [string, string] | undefined { const withoutExport = line.trim().replace(/^export\s+/, ""); if (!withoutExport || withoutExport.startsWith("#")) return undefined; const eq = withoutExport.indexOf("="); if (eq <= 0) return undefined; const key = withoutExport.slice(0, eq).trim(); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return undefined; let rawValue = withoutExport.slice(eq + 1).trim(); if (!rawValue.startsWith('"') && !rawValue.startsWith("'")) { const comment = rawValue.indexOf(" #"); if (comment >= 0) rawValue = rawValue.slice(0, comment).trim(); } return [key, unquoteEnvValue(rawValue)]; } function loadProjectDotEnv(): void { const envFile = process.env.PI_DISCORD_ENV_FILE?.trim() || join(process.cwd(), ".env"); if (!existsSync(envFile)) return; for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { const parsed = parseDotEnvLine(line); if (!parsed) continue; const [key, value] = parsed; if (process.env[key] === undefined) process.env[key] = value; } } function envFlag(name: string, defaultValue: boolean): boolean { const value = process.env[name]; if (value === undefined || value.trim() === "") return defaultValue; return !/^(0|false|no|off)$/i.test(value.trim()); } function envNumber(name: string, defaultValue: number, min = 0): number { const raw = process.env[name]; if (!raw) return defaultValue; const parsed = Number(raw); if (!Number.isFinite(parsed)) return defaultValue; return Math.max(min, parsed); } function envThreadArchiveDuration( name: string, defaultValue: ThreadAutoArchiveDuration, ): ThreadAutoArchiveDuration { const allowed = new Set([60, 1440, 4320, 10080]); const value = envNumber(name, defaultValue, 60); return ( allowed.has(value) ? value : defaultValue ) as ThreadAutoArchiveDuration; } function envString(names: string | readonly string[]): string { const list = Array.isArray(names) ? names : [names]; for (const name of list) { const value = process.env[name]?.trim(); if (value) return value; } return ""; } function envSet(names: string | readonly string[]): Set { const raw = envString(names); return new Set( raw .split(",") .map((s) => s.trim()) .filter(Boolean), ); } function loadConfig(): Config { const allowLegacyEnv = envFlag("PI_DISCORD_ALLOW_LEGACY_ENV", false); const channelIds = envSet( allowLegacyEnv ? [ "PI_DISCORD_CHANNEL_IDS", "PI_DISCORD_BOT_CHANNEL_ID", "DISCORD_BOT_CHANNEL_ID", ] : ["PI_DISCORD_CHANNEL_IDS", "PI_DISCORD_BOT_CHANNEL_ID"], ); const primary = envString("PI_DISCORD_PRIMARY_CHANNEL_ID") || [...channelIds][0] || ""; return { enabled: envFlag("PI_DISCORD_ENABLED", false), allowLegacyEnv, botToken: envString( allowLegacyEnv ? ["PI_DISCORD_BOT_TOKEN", "DISCORD_BOT_TOKEN"] : "PI_DISCORD_BOT_TOKEN", ), channelIds, userIds: envSet( allowLegacyEnv ? ["PI_DISCORD_USER_IDS", "PI_DISCORD_USER_ID", "DISCORD_USER_ID"] : ["PI_DISCORD_USER_IDS", "PI_DISCORD_USER_ID"], ), primaryChannelId: primary, activeChannelId: primary, createThreadPerSession: envFlag( "PI_DISCORD_CREATE_THREAD_PER_SESSION", true, ), threadNamePrefix: process.env.PI_DISCORD_THREAD_NAME_PREFIX?.trim() || "pi", threadAutoArchiveDuration: envThreadArchiveDuration( "PI_DISCORD_THREAD_AUTO_ARCHIVE_MINUTES", ThreadAutoArchiveDuration.OneWeek, ), editIntervalMs: envNumber("PI_DISCORD_EDIT_INTERVAL_MS", 1100, 500), createIntervalMs: envNumber("PI_DISCORD_CREATE_INTERVAL_MS", 600, 300), maxChars: Math.min( 1990, Math.max(500, envNumber("PI_DISCORD_MAX_CHARS", 1900, 500)), ), renderUserInput: envFlag("PI_DISCORD_RENDER_USER_INPUT", true), renderToolCalls: envFlag("PI_DISCORD_RENDER_TOOL_CALLS", true), renderToolResults: envFlag("PI_DISCORD_RENDER_TOOL_RESULTS", true), renderReasoning: envFlag("PI_DISCORD_RENDER_REASONING", false), toolArgsMaxChars: envNumber("PI_DISCORD_TOOL_ARGS_MAX_CHARS", 800, 100), toolResultMaxChars: envNumber( "PI_DISCORD_TOOL_RESULT_MAX_CHARS", 1000, 100, ), prefix: process.env.PI_DISCORD_PREFIX ?? "", steerLabel: process.env.PI_DISCORD_STEER_LABEL?.trim() || "[discord]", registerSlashCommands: envFlag("PI_DISCORD_REGISTER_SLASH_COMMANDS", false), slashCommandGuildIds: envSet("PI_DISCORD_SLASH_COMMAND_GUILD_IDS"), launcherEnabled: envFlag("PI_DISCORD_LAUNCHER_ENABLED", true), launcherPort: envNumber("PI_DISCORD_LAUNCHER_PORT", 8765, 1), launcherSecret: process.env.PI_DISCORD_LAUNCHER_SECRET?.trim() ?? "", spawnCommand: process.env.PI_DISCORD_SPAWN_COMMAND?.trim() || "pi", spawnArgs: parseSpawnArgs(process.env.PI_DISCORD_SPAWN_ARGS?.trim() || ""), }; } // Shell-style arg splitter that handles single- and double-quoted tokens. // Needed so PI_DISCORD_SPAWN_ARGS can include paths with spaces. function parseSpawnArgs(raw: string): string[] { const args: string[] = []; let current = ""; let inQuote: '"' | "'" | null = null; for (const ch of raw) { if (inQuote) { if (ch === inQuote) inQuote = null; else current += ch; } else if (ch === '"' || ch === "'") { inQuote = ch; } else if (ch === " " || ch === "\t") { if (current) { args.push(current); current = ""; } } else { current += ch; } } if (current) args.push(current); return args; } function stripAnsi(s: string): string { return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); } function clip(s: string, n: number): string { if (s.length <= n) return s; return s.slice(0, Math.max(0, n - 1)) + "…"; } function extractAssistantText( message: AgentMessage, includeThinking: boolean, ): string { if (message.role !== "assistant") return ""; const parts: string[] = []; for (const block of message.content) { if (block.type === "text") parts.push((block as TextContent).text); else if (block.type === "thinking" && includeThinking) { // Render reasoning as a blockquote so Discord doesn't try to apply // any of its inline markdown (`*_~|>` + backticks) to the contents. parts.push( block.thinking .split("\n") .map((line) => "> " + line) .join("\n"), ); } } return parts.join("\n"); } function extractTextContent( blocks: ReadonlyArray<{ type: string; text?: string }>, ): string { return blocks .filter((b) => b.type === "text" && typeof b.text === "string") .map((b) => b.text as string) .join("\n"); } function messageTimestamp(message: AgentMessage): number | undefined { return "timestamp" in message && typeof message.timestamp === "number" ? message.timestamp : undefined; } type DiscordSendPayload = string | MessageCreateOptions; type DiscordChannel = { id: string; send: (content: DiscordSendPayload) => Promise; }; type DiscordThreadCreatableChannel = DiscordChannel & { threads: { create: (options: { name: string; autoArchiveDuration?: ThreadAutoArchiveDuration; reason?: string; }) => Promise; }; }; type ToolMessageState = { toolName: string; input: unknown; msg: DiscordMessage | undefined; startedAt: number; }; type TypingCapableChannel = DiscordChannel & { sendTyping: () => Promise; }; const NO_MENTIONS = { parse: [] as [] }; const EMBED_FIELD_MAX_CHARS = 1000; const COLOR_TOOL_RUNNING = 0xf1c40f; const COLOR_TOOL_SUCCESS = 0x2ecc71; const COLOR_TOOL_ERROR = 0xe74c3c; const DISCORD_SLASH_COMMAND_LIMIT = 100; const DISCORD_SLASH_NAME_MAX_CHARS = 32; const DISCORD_SLASH_DESCRIPTION_MAX_CHARS = 100; const BRIDGE_NATIVE_COMMANDS: ReadonlyArray<{ name: string; description: string; acceptsArgs?: boolean; }> = [ { name: "commands", description: "List Discord and Pi slash commands" }, { name: "help", description: "List Discord and Pi slash commands" }, { name: "session", description: "Show active Pi session and Discord thread" }, { name: "compact", description: "Trigger context compaction" }, { name: "abort", description: "Abort the current Pi turn" }, { name: "mute", description: "Suppress Pi output to Discord" }, { name: "unmute", description: "Resume Pi output to Discord" }, { name: "new", description: "Start a new Pi session (arms automatically; spawns if needed)", acceptsArgs: true, }, { name: "discord-arm", description: "Explain how to arm Discord session creation", }, { name: "discord-status", description: "Show Discord remote-control status" }, { name: "discord-launcher-status", description: "Show launcher server status and spawn config", }, { name: "discord-test", description: "Post a test message to Discord", acceptsArgs: true, }, { name: "discord-reconnect", description: "Reconnect the Discord client" }, { name: "discord-mute", description: "Suppress Pi output to Discord" }, { name: "discord-unmute", description: "Resume Pi output to Discord" }, ]; function textPayload(content: string): MessageCreateOptions { return { content: redactString(content), allowedMentions: NO_MENTIONS }; } type NativeDiscordCommand = { discordName: string; piName: string; description: string; kind: "bridge"; acceptsArgs: boolean; }; let nativeDiscordCommands = new Map(); let nativeDiscordCommandSignature = ""; let nativeDiscordCommandSyncInFlight: Promise | undefined; let nativeDiscordRest: REST | undefined; function getDiscordRest(cfg: Config): REST { // Token is captured on first construction; callers must invoke // resetDiscordRest() whenever cfg.botToken may have changed (currently // covered by session_shutdown and both /discord-reconnect paths). if (!nativeDiscordRest) { nativeDiscordRest = new REST({ version: "10" }).setToken(cfg.botToken); } return nativeDiscordRest; } function resetDiscordRest(): void { nativeDiscordRest = undefined; nativeDiscordCommandSignature = ""; } function discordInteractionPayload(content: string, ephemeral = true) { return { content: redactString(content), allowedMentions: NO_MENTIONS, ephemeral, }; } function truncateDiscordDescription( value: string | undefined, fallback: string, ): string { const text = (value?.trim() || fallback).replace(/\s+/g, " "); return clip(text, DISCORD_SLASH_DESCRIPTION_MAX_CHARS); } function sanitizeDiscordSlashCommandName(name: string): string { const sanitized = name .trim() .toLowerCase() .replace(/:/g, "-") .replace(/[^a-z0-9_-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, DISCORD_SLASH_NAME_MAX_CHARS); return sanitized || "pi"; } function uniqueDiscordSlashCommandName( name: string, usedNames: Set, ): string { const base = sanitizeDiscordSlashCommandName(name); if (!usedNames.has(base)) { usedNames.add(base); return base; } for (let index = 2; index < 100; index += 1) { const suffix = `-${index}`; const candidate = `${base.slice(0, DISCORD_SLASH_NAME_MAX_CHARS - suffix.length)}${suffix}`; if (!usedNames.has(candidate)) { usedNames.add(candidate); return candidate; } } return base; } function collectNativeDiscordCommands(): NativeDiscordCommand[] { const entries: NativeDiscordCommand[] = []; const usedNames = new Set(); const usedPiNames = new Set(); const add = (entry: Omit) => { if (entries.length >= DISCORD_SLASH_COMMAND_LIMIT) return; const discordName = uniqueDiscordSlashCommandName(entry.piName, usedNames); entries.push({ ...entry, discordName }); usedPiNames.add(entry.piName); }; for (const command of BRIDGE_NATIVE_COMMANDS) { add({ piName: command.name, description: command.description, kind: "bridge", acceptsArgs: command.acceptsArgs ?? (command.name !== "commands" && command.name !== "help"), }); } return entries; } function buildNativeDiscordCommandList(pi: ExtensionAPI): string { const bridgeEntries = collectNativeDiscordCommands(); const bridgeLines = bridgeEntries.map((entry) => { const mapped = entry.discordName === entry.piName ? "" : ` → Pi /${entry.piName}`; return `/${entry.discordName}${entry.acceptsArgs ? " [args]" : ""}${mapped} — ${entry.description}`; }); const piLines = pi .getCommands() .map( (cmd) => `/${cmd.name}${cmd.description ? ` — ${cmd.description}` : ""}`, ) .sort(); const sections = [`**Discord controls**\n${bridgeLines.join("\n")}`]; if (piLines.length > 0) { sections.push( `**Pi commands visible in this session**\n${piLines.join("\n")}\n\nThese are listed for visibility only; the current public Pi extension API does not expose a safe background command dispatcher for arbitrary command handlers.`, ); } return sections.join("\n\n"); } function buildSlashCommand( command: NativeDiscordCommand, ): RESTPostAPIChatInputApplicationCommandsJSONBody { const builder = new SlashCommandBuilder() .setName(command.discordName) .setDescription( truncateDiscordDescription( command.description, `Run Pi /${command.piName}`, ), ); if (command.acceptsArgs) { builder.addStringOption((option) => option .setName("args") .setDescription("Arguments to pass after the slash command") .setRequired(false), ); } return builder.toJSON(); } function nativeDiscordCommandPayloads( commands: NativeDiscordCommand[], ): RESTPostAPIChatInputApplicationCommandsJSONBody[] { return commands.map(buildSlashCommand); } async function syncNativeDiscordSlashCommands( _pi: ExtensionAPI, cfg: Config, c: Client, ): Promise { if (!cfg.registerSlashCommands) return; const applicationId = c.application?.id ?? c.user?.id; if (!applicationId) { console.warn( "[discord-remote] slash command sync skipped: Discord client has no application id yet.", ); return; } const guildIds = [...cfg.slashCommandGuildIds].sort(); if (guildIds.length === 0) { console.warn( "[discord-remote] slash command registration requested but PI_DISCORD_SLASH_COMMAND_GUILD_IDS is empty; refusing to register global application commands.", ); return; } const commands = collectNativeDiscordCommands(); nativeDiscordCommands = new Map( commands.map((command) => [command.discordName, command]), ); const payloads = nativeDiscordCommandPayloads(commands); const signature = JSON.stringify({ applicationId, guildIds, payloads }); if (nativeDiscordCommandSignature === signature) return; // Use @discordjs/rest (re-exported by discord.js) and the explicit // `applicationGuildCommands` route so registration is scoped to the // private guild(s) and never escapes to a global command. const rest = getDiscordRest(cfg); await Promise.all( guildIds.map((guildId) => rest.put(Routes.applicationGuildCommands(applicationId, guildId), { body: payloads, }), ), ); nativeDiscordCommandSignature = signature; } function scheduleNativeDiscordSlashCommandSync( pi: ExtensionAPI, cfg: Config, c: Client | undefined, ): void { if (!c || !cfg.registerSlashCommands) return; if (nativeDiscordCommandSyncInFlight) return; nativeDiscordCommandSyncInFlight = syncNativeDiscordSlashCommands(pi, cfg, c) .catch((err) => { console.error( "[discord-remote] slash command sync failed:", (err as Error).message, ); }) .finally(() => { nativeDiscordCommandSyncInFlight = undefined; }); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function safeJson(value: unknown): string { try { const json = JSON.stringify(value, null, 2); return json === undefined ? String(value) : json; } catch { return String(value); } } function parseJsonMaybe(text: string): unknown | undefined { const trimmed = text.trim(); if (!trimmed || !/^[{[]/.test(trimmed)) return undefined; try { return JSON.parse(trimmed); } catch { return undefined; } } function isSensitiveKey(key: string): boolean { return /(token|secret|password|passwd|api[_-]?key|authorization|auth|webhook|cookie|private[_-]?key|dsn|database[_-]?url)/i.test( key, ); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function sensitiveEnvValues(): string[] { const values = new Set(); for (const [key, value] of Object.entries(process.env)) { const trimmed = value?.trim(); if (!trimmed || trimmed.length < 8) continue; if (isSensitiveKey(key) || looksLikeSecret(trimmed)) values.add(trimmed); } return [...values].sort((a, b) => b.length - a.length); } function looksLikeSecret(value: string): boolean { return ( /[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,}/.test(value) || /\b(?:sk|pk|gh[pousr]|xox[baprs])[-_][A-Za-z0-9_-]{16,}\b/i.test(value) || /\bAKIA[0-9A-Z]{16}\b/.test(value) || /discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/i.test(value) ); } function redactString(value: string): string { let redacted = value; for (const secret of sensitiveEnvValues()) { redacted = redacted.replace( new RegExp(escapeRegExp(secret), "g"), "", ); } return redacted .replace( /[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,}/g, "", ) .replace( /\b(?:sk|pk|gh[pousr]|xox[baprs])[-_][A-Za-z0-9_-]{16,}\b/gi, "", ) .replace(/\bAKIA[0-9A-Z]{16}\b/g, "") .replace( /discord(?:app)?\.com\/api\/webhooks\/\d+\/[A-Za-z0-9_-]+/gi, "discord.com/api/webhooks/", ); } function formatValueForDiscord(key: string, value: unknown): string { if ( isSensitiveKey(key) && value !== undefined && value !== null && String(value) !== "" ) return ""; if (value === undefined || value === null || value === "") return "(empty)"; if (typeof value === "string") { const redacted = redactString(value); return redacted.includes("\n") ? clip(redacted, 500) : `"${clip(redacted, 220)}"`; } if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) { const simple = value.every((item) => ["string", "number", "boolean"].includes(typeof item), ); if (simple) return ( value.map((item) => formatValueForDiscord(key, item)).join(", ") || "[]" ); return `[${value.length} item${value.length === 1 ? "" : "s"}]`; } if (isRecord(value)) return `{${Object.keys(value).slice(0, 6).join(", ")}${Object.keys(value).length > 6 ? ", …" : ""}}`; return clip(String(value), 220); } function summarizeToolInput( _toolName: string, input: unknown, maxChars: number, ): string { if (!isRecord(input)) return clip(redactString(safeJson(input ?? {})), maxChars); const preferred = [ "query", "queries", "scope", "repo", "branch", "path", "filePath", "url", "urls", "command", "pattern", "lang", "limit", "timeout", "agent", "task", "message", ]; const keys = [ ...preferred.filter((key) => key in input), ...Object.keys(input).filter((key) => !preferred.includes(key)), ].slice(0, 10); if (keys.length === 0) return "No arguments."; const lines = keys.map( (key) => `• **${key}**: ${formatValueForDiscord(key, input[key])}`, ); const summary = lines.join("\n"); const suffix = Object.keys(input).length > keys.length ? "\n• …additional arguments omitted" : ""; return clip(summary + suffix, maxChars); } function toolAction(toolName: string): string { const name = toolName.toLowerCase(); if (name === "bash" || name.endsWith(".bash")) return "Running a shell command"; if (name === "read" || name.endsWith(".read")) return "Reading a file"; if (name === "edit" || name.endsWith(".edit")) return "Editing a file"; if (name === "write" || name.endsWith(".write")) return "Writing a file"; if (name.includes("web_search")) return "Searching the web"; if (name.includes("fetch_content")) return "Fetching web content"; if (name.includes("lsp")) return "Querying code intelligence"; if (name.includes("ast_grep")) return "Searching code structurally"; if (name.includes("subagent")) return "Delegating to a subagent"; return "Running tool"; } function summarizeMemoryHits( payload: Record, maxChars: number, ): string | undefined { const hits = Array.isArray(payload.hits) ? payload.hits : undefined; if (!hits) return undefined; if (hits.length === 0) return "No matching memories found."; const lines = hits.slice(0, 4).map((hit, index) => { if (!isRecord(hit)) return `• Hit ${index + 1}`; const name = typeof hit.entity_name === "string" ? hit.entity_name : `Hit ${index + 1}`; const type = typeof hit.entity_type === "string" ? ` (${hit.entity_type})` : ""; const content = typeof hit.content_display === "string" ? hit.content_display : typeof hit.content === "string" ? hit.content : ""; return `• **${clip(redactString(name), 80)}**${type}: ${clip(redactString(content).replace(/\s+/g, " "), 220)}`; }); const more = hits.length > 4 ? `\n• …${hits.length - 4} more hit${hits.length - 4 === 1 ? "" : "s"}` : ""; return clip( `Found ${hits.length} memory hit${hits.length === 1 ? "" : "s"}.\n${lines.join("\n")}${more}`, maxChars, ); } function summarizeJsonResult( value: unknown, maxChars: number, ): string | undefined { if (!isRecord(value)) return undefined; const memory = summarizeMemoryHits(value, maxChars); if (memory) return memory; const lines: string[] = []; for (const [key, item] of Object.entries(value)) { if (isSensitiveKey(key)) continue; if ( ["content", "text", "stdout", "stderr"].includes(key) && typeof item === "string" ) { const label = key === "stdout" ? "stdout" : key === "stderr" ? "stderr" : key; lines.push( `• **${label}**: ${clip(redactString(item).trim().replace(/\s+/g, " "), 500) || "(empty)"}`, ); } else if (Array.isArray(item)) { lines.push( `• **${key}**: ${item.length} item${item.length === 1 ? "" : "s"}`, ); } else if (isRecord(item)) { lines.push( `• **${key}**: object with ${Object.keys(item).length} key${Object.keys(item).length === 1 ? "" : "s"}`, ); } else { lines.push(`• **${key}**: ${formatValueForDiscord(key, item)}`); } if (lines.length >= 8) break; } return lines.length > 0 ? clip(lines.join("\n"), maxChars) : undefined; } function summarizeTextResult(text: string, maxChars: number): string { const lines = redactString(text) .split("\n") .map((line) => line.trim()) .filter(Boolean); if (lines.length === 0) return "(empty result)"; return clip(lines.slice(0, 10).join("\n"), maxChars); } function summarizeToolResult( _toolName: string, text: string, isError: boolean, maxChars: number, ): string { const clean = stripAnsi(text).trim(); if (!clean) return isError ? "Tool failed without an error message." : "Completed with no text output."; const parsed = parseJsonMaybe(clean); if (parsed !== undefined) { const jsonSummary = summarizeJsonResult(parsed, maxChars); if (jsonSummary) return jsonSummary; } return summarizeTextResult(clean, maxChars); } function formatDuration(ms: number): string { if (!Number.isFinite(ms) || ms < 0) return "—"; if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`; const minutes = Math.floor(ms / 60_000); const seconds = Math.round((ms % 60_000) / 1000); return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`; } function toolPayload( cfg: Config, toolName: string, input: unknown, status: "running" | "complete" | "error", resultText?: string, durationMs?: number, ): MessageCreateOptions { const isDone = status !== "running"; const color = status === "error" ? COLOR_TOOL_ERROR : status === "complete" ? COLOR_TOOL_SUCCESS : COLOR_TOOL_RUNNING; const icon = status === "error" ? "❌" : status === "complete" ? "✅" : "▶️"; const fields: { name: string; value: string; inline: boolean }[] = [ { name: "Action", value: toolAction(toolName), inline: false }, { name: "Inputs", value: clip( summarizeToolInput(toolName, input, cfg.toolArgsMaxChars), EMBED_FIELD_MAX_CHARS, ), inline: false, }, ]; if (isDone) { fields.push({ name: status === "error" ? "Error" : "Result", value: clip(resultText || "Completed.", EMBED_FIELD_MAX_CHARS), inline: false, }); if (durationMs !== undefined) { fields.push({ name: "Duration", value: formatDuration(durationMs), inline: true, }); } } return { content: cfg.prefix ? redactString(cfg.prefix) : undefined, allowedMentions: NO_MENTIONS, embeds: [ { title: clip( redactString( `${icon} ${status === "running" ? "Running" : status === "error" ? "Tool failed" : "Tool complete"}: ${toolName}`, ), 250, ), description: `Pi ${status === "running" ? "started" : "finished"} a tool call.`, color, fields, footer: { text: "Pi Discord remote" }, timestamp: new Date().toISOString(), }, ], }; } class TypingIndicator { private interval: ReturnType | undefined; private active = 0; constructor(private readonly relay: DiscordRelay) {} retain(): void { this.active += 1; if (this.interval) return; void this.tick(); this.interval = setInterval(() => void this.tick(), 8000); } release(): void { this.active = Math.max(0, this.active - 1); if (this.active === 0) this.stop(); } stop(): void { if (this.interval) clearInterval(this.interval); this.interval = undefined; this.active = 0; } private async tick(): Promise { if (this.relay.isMuted()) return; const id = this.relay.getTargetChannelId(); if (!id) return; const ch = await this.relay.getChannel(id); if ( !ch || typeof (ch as Partial).sendTyping !== "function" ) return; try { await (ch as TypingCapableChannel).sendTyping(); } catch { /* Discord ratelimit / transient — try again next tick. */ } } } class DiscordRelay { private sendQueue: Promise = Promise.resolve(); private streams = new Map(); private toolMessages = new Map(); private muted = false; readonly typing = new TypingIndicator(this); constructor( private readonly client: Client, private readonly cfg: Config, ) {} isMuted(): boolean { return this.muted; } setMuted(muted: boolean): boolean { const changed = this.muted !== muted; this.muted = muted; if (muted) { this.typing.stop(); // Drop pending stream-buffer flushes so an in-flight assistant stream // doesn't keep editing Discord after the user muted. for (const stream of this.streams.values()) stream.cancelPending(); } return changed; } getTargetChannelId(): string { return this.cfg.activeChannelId || this.cfg.primaryChannelId; } setTargetChannelId(channelId: string): void { this.cfg.activeChannelId = channelId; } async getChannel(id: string): Promise { let ch = this.client.channels.cache.get(id) ?? null; if (!ch) { ch = await this.client.channels.fetch(id).catch(() => null); } if (!ch || !("send" in ch)) return undefined; return ch as unknown as DiscordChannel; } async createSessionThread(sessionId: string): Promise { if (!this.cfg.createThreadPerSession) return undefined; const parent = await this.getChannel(this.cfg.primaryChannelId); if (!parent || !("threads" in parent)) return undefined; const threadParent = parent as DiscordThreadCreatableChannel; const safePrefix = this.cfg.threadNamePrefix.replace(/[^\w .-]+/g, "-").trim() || "pi"; const name = clip(`${safePrefix}-${sessionId}`, 100); try { const thread = await this.serial(() => threadParent.threads.create({ name, autoArchiveDuration: this.cfg.threadAutoArchiveDuration, reason: "Pi coding agent session started", }), ); this.setTargetChannelId(thread.id); await this.postRaw( thread.id, `🧵 Pi session thread created for session \`${sessionId}\`.`, ); return thread.id; } catch (err) { console.error( "[discord-remote] createSessionThread failed:", (err as Error).message, ); return undefined; } } serial(work: () => Promise): Promise { const result = this.sendQueue.then(work); this.sendQueue = result.then( () => new Promise((r) => setTimeout(r, this.cfg.createIntervalMs)), () => undefined, ); return result; } async postRaw( channelId: string, text: string, ): Promise { if (this.muted) return undefined; const ch = await this.getChannel(channelId); if (!ch) return undefined; const body = clip( (this.cfg.prefix ? this.cfg.prefix + " " : "") + text, this.cfg.maxChars, ); try { return await this.serial(() => ch.send(textPayload(body))); } catch (err) { console.error("[discord-remote] postRaw failed:", (err as Error).message); return undefined; } } async postPayload( channelId: string, payload: MessageCreateOptions, ): Promise { if (this.muted) return undefined; const ch = await this.getChannel(channelId); if (!ch) return undefined; try { return await this.serial(() => ch.send(payload)); } catch (err) { console.error( "[discord-remote] postPayload failed:", (err as Error).message, ); return undefined; } } onMessageStart(event: MessageEventLike) { if (event.message.role !== "assistant") return; const ts = messageTimestamp(event.message); if (ts === undefined) return; if (!this.streams.has(ts)) { this.streams.set(ts, new StreamBuffer(this, this.cfg)); this.typing.retain(); } } onMessageUpdate(event: MessageEventLike) { if (event.message.role !== "assistant") return; const ts = messageTimestamp(event.message); if (ts === undefined) return; const text = extractAssistantText(event.message, this.cfg.renderReasoning); if (!text) return; const buf = this.streams.get(ts) ?? new StreamBuffer(this, this.cfg); this.streams.set(ts, buf); buf.update(stripAnsi(text)); } async onMessageEnd(event: MessageEventLike): Promise { if (event.message.role !== "assistant") return false; const ts = messageTimestamp(event.message); if (ts === undefined) return false; const buf = this.streams.get(ts); if (!buf) { const text = stripAnsi( extractAssistantText(event.message, this.cfg.renderReasoning), ); if (!text) return false; return Boolean(await this.postRaw(this.getTargetChannelId(), text)); } const sent = await buf.finalize( stripAnsi(extractAssistantText(event.message, this.cfg.renderReasoning)), ); if (sent) this.streams.delete(ts); // Release whether or not we successfully posted — the turn is over either // way and a stuck typing indicator is worse than a missed final frame. this.typing.release(); return sent; } async onToolCall(event: ToolCallEvent) { if (!this.cfg.renderToolCalls) return; const startedAt = Date.now(); const payload = toolPayload( this.cfg, event.toolName, event.input ?? {}, "running", ); const msg = await this.postPayload(this.getTargetChannelId(), payload); this.toolMessages.set(event.toolCallId, { toolName: event.toolName, input: event.input ?? {}, msg, startedAt, }); } async onToolResult(event: ToolResultEvent) { // Always clear the entry — even when results aren't rendered — to avoid // unbounded growth of `toolMessages` when renderToolCalls=true and // renderToolResults=false. const entry = this.toolMessages.get(event.toolCallId); this.toolMessages.delete(event.toolCallId); if (!this.cfg.renderToolResults) return; const text = extractTextContent(event.content); const toolName = entry?.toolName ?? event.toolCallId; const input = entry?.input ?? {}; const durationMs = entry ? Date.now() - entry.startedAt : undefined; const result = summarizeToolResult( toolName, text, event.isError, this.cfg.toolResultMaxChars, ); const payload = toolPayload( this.cfg, toolName, input, event.isError ? "error" : "complete", result, durationMs, ); if (entry?.msg) { if (this.muted) return; try { await entry.msg.edit(payload as MessageEditOptions); } catch (err) { console.error( "[discord-remote] tool result edit failed:", (err as Error).message, ); } return; } await this.postPayload(this.getTargetChannelId(), payload); } flushAll(): Promise { return this.sendQueue.catch(() => undefined); } shutdown(): void { this.typing.stop(); } } class StreamBuffer { private pages: { msg?: DiscordMessage; body: string }[] = []; private currentText = ""; private lastEditAt = 0; private pending: ReturnType | undefined; private flushing = false; private finalized = false; constructor( private readonly relay: DiscordRelay, private readonly cfg: Config, ) {} update(text: string) { if (this.finalized) return; if (text === this.currentText) return; this.currentText = text; this.schedule(); } async finalize(text: string): Promise { if (text) this.currentText = text; this.cancelPending(); this.finalized = true; return this.flush(); } cancelPending(): void { if (this.pending) { clearTimeout(this.pending); this.pending = undefined; } } private schedule() { if (this.pending) return; const wait = Math.max( 0, this.lastEditAt + this.cfg.editIntervalMs - Date.now(), ); this.pending = setTimeout(() => { this.pending = undefined; this.flush().catch(() => undefined); }, wait); } private async flush(): Promise { // Mute must suppress streaming as well as raw/tool posts — otherwise an // in-flight assistant stream keeps sending and editing while the user // has asked the bridge to be quiet. if (this.relay.isMuted()) return false; if (this.flushing) { this.schedule(); return this.pages.length > 0; } this.flushing = true; try { const ch = await this.relay.getChannel(this.relay.getTargetChannelId()); if (!ch) return false; const pages: string[] = []; const text = this.currentText; if (text.length === 0) return false; for (let i = 0; i < text.length; i += this.cfg.maxChars) { pages.push(text.slice(i, i + this.cfg.maxChars)); } for (let i = 0; i < pages.length; i++) { // Re-check on every page so a mute that lands mid-flush stops // further edits/sends in the same loop. if (this.relay.isMuted()) return this.pages.length > 0; const body = (this.cfg.prefix && i === 0 ? this.cfg.prefix + " " : "") + pages[i]; const existing = this.pages[i]; if (existing?.msg) { if (existing.body !== body) { try { await existing.msg.edit(textPayload(body) as MessageEditOptions); existing.body = body; } catch (err) { console.error( "[discord-remote] edit failed:", (err as Error).message, ); } } } else { try { const msg = await this.relay.serial(() => ch.send(textPayload(body)), ); this.pages[i] = { msg, body }; } catch (err) { console.error( "[discord-remote] send failed:", (err as Error).message, ); return false; } } } this.lastEditAt = Date.now(); return this.pages.length > 0; } finally { this.flushing = false; } } } interface DiscordRemoteSharedState { commandCtx?: ExtensionCommandContext; armedSessionId?: string; } const sharedState = (( globalThis as typeof globalThis & { __piDiscordRemoteState?: DiscordRemoteSharedState; } ).__piDiscordRemoteState ??= {}); let client: Client | undefined; let relay: DiscordRelay | undefined; let lastCtx: ExtensionContext | undefined; let bootInFlight: Promise | undefined; let launcherServer: HttpServer | undefined; const relayedTimestamps = new Set(); const sessionThreadIds = new Map(); function safeContextSessionId( ctx: ExtensionContext | ExtensionCommandContext | undefined, ): string | undefined { if (!ctx) return undefined; try { return ctx.sessionManager.getSessionId(); } catch { return undefined; } } function armDiscordBridge(ctx: ExtensionCommandContext): string { const sessionId = ctx.sessionManager.getSessionId(); sharedState.commandCtx = ctx; sharedState.armedSessionId = sessionId; return sessionId; } function clearDiscordBridgeArm(): void { sharedState.commandCtx = undefined; sharedState.armedSessionId = undefined; } function getArmedCommandCtx(): ExtensionCommandContext | undefined { const ctx = sharedState.commandCtx; const ctxSessionId = safeContextSessionId(ctx); const activeSessionId = safeContextSessionId(lastCtx); if ( !ctx || !ctxSessionId || sharedState.armedSessionId !== ctxSessionId || (activeSessionId !== undefined && activeSessionId !== ctxSessionId) ) { clearDiscordBridgeArm(); return undefined; } return ctx; } function refreshDiscordBridgeArmForSession(ctx: ExtensionContext): void { const activeSessionId = safeContextSessionId(ctx); if (!activeSessionId || !sharedState.commandCtx) return; const armedSessionId = safeContextSessionId(sharedState.commandCtx); if (armedSessionId !== activeSessionId) clearDiscordBridgeArm(); } function waitForClientReady(c: Client, timeoutMs = 30_000): Promise { if (c.isReady()) return Promise.resolve(); return new Promise((resolve, reject) => { const cleanup = () => { clearTimeout(timeout); c.off(Events.ClientReady, onReady); c.off(Events.Error, onError); }; const onReady = () => { cleanup(); resolve(); }; const onError = (err: Error) => { cleanup(); reject(err); }; const timeout = setTimeout(() => { cleanup(); reject( new Error(`Discord client did not become ready within ${timeoutMs}ms`), ); }, timeoutMs); c.once(Events.ClientReady, onReady); c.once(Events.Error, onError); }); } function commandNameAndArgs( text: string, ): { name: string; args: string } | undefined { if (!text.startsWith("/") && !text.startsWith("!")) return undefined; const withoutPrefix = text.slice(1).trim(); if (!withoutPrefix) return undefined; const spaceIndex = withoutPrefix.indexOf(" "); if (spaceIndex === -1) return { name: withoutPrefix.toLowerCase(), args: "" }; return { name: withoutPrefix.slice(0, spaceIndex).toLowerCase(), args: withoutPrefix.slice(spaceIndex + 1).trim(), }; } async function handleDiscordCommand( pi: ExtensionAPI, cfg: Config, msg: DiscordMessage, text: string, ): Promise { const parsed = commandNameAndArgs(text); if (!parsed) return false; const { name, args } = parsed; if (name === "abort") { if (lastCtx && !lastCtx.isIdle()) lastCtx.abort(); await msg.react("🛑").catch(() => undefined); return true; } if (name === "commands" || name === "help") { const commands = pi .getCommands() .map( (cmd) => `/${cmd.name}${cmd.description ? ` — ${cmd.description}` : ""}`, ) .sort(); const builtins = [ "/new [message] — start a new Pi session (uses in-process replacement if /discord-arm was run, otherwise spawns a new Pi process)", "/session — show the active Pi session id and thread", "/compact — trigger context compaction", "/abort — abort the current turn (or react 🛑 on any bot message)", "/mute — suppress further Pi output to Discord without disconnecting", "/unmute — resume Pi output to Discord", "/commands — list available Pi slash commands", ]; await msg .reply( textPayload(clip([...builtins, ...commands].join("\n"), cfg.maxChars)), ) .catch(() => undefined); return true; } if (name === "mute" || name === "unmute") { const target = name === "mute"; const activeRelay = await waitForRelay(); if (!activeRelay) { await msg .reply( textPayload( "Discord relay is not running yet — try again in a moment.", ), ) .catch(() => undefined); return true; } const changed = activeRelay.setMuted(target); const emoji = target ? "🔇" : "🔈"; await msg.react(emoji).catch(() => undefined); if (!changed) { await msg .reply(textPayload(target ? "Already muted." : "Already unmuted.")) .catch(() => undefined); } return true; } if (name === "session") { const sessionId = lastCtx?.sessionManager.getSessionId() ?? "unknown"; const threadId = sessionThreadIds.get(sessionId) ?? cfg.activeChannelId; await msg .reply( textPayload(`session=${sessionId}\nthread=${threadId || "(none)"}`), ) .catch(() => undefined); return true; } if (name === "compact") { lastCtx?.compact(); await msg.react("🧹").catch(() => undefined); return true; } if (name === "new") { try { const result = await doNewSession(cfg, args); if (!result.ok) { await msg .reply( textPayload(`error: ${result.error ?? "session creation failed"}`), ) .catch(() => undefined); } else { await msg.react(result.cancelled ? "🚫" : "🆕").catch(() => undefined); if (result.method === "spawn") { await msg .reply( textPayload( "Spawning new Pi session — it will announce itself in Discord shortly.", ), ) .catch(() => undefined); } } } catch (err) { await msg .reply(textPayload(`error: ${(err as Error).message}`)) .catch(() => undefined); } return true; } return false; } async function replyNativeDiscordInteraction( interaction: ChatInputCommandInteraction, content: string, ephemeral = true, ): Promise { const payload = discordInteractionPayload(clip(content, 1900), ephemeral); if (interaction.deferred || interaction.replied) { await interaction.followUp(payload).catch(() => undefined); return; } await interaction.reply(payload).catch(() => undefined); } async function editNativeDiscordInteraction( interaction: ChatInputCommandInteraction, content: string, ): Promise { await interaction .editReply({ content: redactString(clip(content, 1900)), allowedMentions: NO_MENTIONS, }) .catch(() => undefined); } async function handleNativeDiscordInteraction( pi: ExtensionAPI, cfg: Config, interaction: ChatInputCommandInteraction, ): Promise { if (!isInboundOriginAllowed(cfg, interaction.channelId)) { await replyNativeDiscordInteraction( interaction, "This channel is not allowed to control Pi.", ); return; } if (!cfg.userIds.has(interaction.user.id)) { await replyNativeDiscordInteraction( interaction, "You are not authorized to use this Pi session.", ); return; } let command = nativeDiscordCommands.get(interaction.commandName); if (!command) { nativeDiscordCommands = new Map( collectNativeDiscordCommands().map((entry) => [entry.discordName, entry]), ); command = nativeDiscordCommands.get(interaction.commandName); } if (!command) { await replyNativeDiscordInteraction( interaction, "Unknown Pi command. Try `/commands`.", ); return; } const argsOption = interaction.options.data.find( (option) => option.name === "args", ); const args = command.acceptsArgs && typeof argsOption?.value === "string" ? argsOption.value.trim() : ""; switch (command.piName) { case "commands": case "help": { scheduleNativeDiscordSlashCommandSync(pi, cfg, client); await replyNativeDiscordInteraction( interaction, clip(buildNativeDiscordCommandList(pi), cfg.maxChars), ); return; } case "abort": { if (lastCtx && !lastCtx.isIdle()) lastCtx.abort(); await replyNativeDiscordInteraction(interaction, "Abort requested."); return; } case "mute": case "discord-mute": { const activeRelay = await waitForRelay(); if (!activeRelay) { await replyNativeDiscordInteraction( interaction, "Discord relay is not running yet — try again in a moment.", ); return; } const changed = activeRelay.setMuted(true); await replyNativeDiscordInteraction( interaction, changed ? "Discord output muted." : "Already muted.", ); return; } case "unmute": case "discord-unmute": { const activeRelay = await waitForRelay(); if (!activeRelay) { await replyNativeDiscordInteraction( interaction, "Discord relay is not running yet — try again in a moment.", ); return; } const changed = activeRelay.setMuted(false); await replyNativeDiscordInteraction( interaction, changed ? "Discord output resumed." : "Already unmuted.", ); return; } case "session": { const sessionId = lastCtx?.sessionManager.getSessionId() ?? "unknown"; const threadId = sessionThreadIds.get(sessionId) ?? cfg.activeChannelId; await replyNativeDiscordInteraction( interaction, `session=${sessionId}\nthread=${threadId || "(none)"}`, ); return; } case "compact": { lastCtx?.compact(); await replyNativeDiscordInteraction(interaction, "Compaction requested."); return; } case "new": { await interaction.deferReply({ ephemeral: true }).catch(() => undefined); try { const result = await doNewSession(cfg, args); if (result.method === "spawn") { await editNativeDiscordInteraction( interaction, result.ok ? "Spawning new Pi session — it will announce itself in Discord shortly." : `error: ${result.error ?? "spawn failed"}`, ); } else { await editNativeDiscordInteraction( interaction, result.cancelled ? "Session creation cancelled." : result.ok ? "New Pi session created." : `error: ${result.error ?? "session creation failed"}`, ); } } catch (err) { await editNativeDiscordInteraction( interaction, `error: ${(err as Error).message}`, ); } return; } case "discord-arm": { await replyNativeDiscordInteraction( interaction, "Run `/discord-arm` in the local Pi TUI to pre-arm in-process session replacement for `/new`. When not armed, `/new` falls back to spawning a new Pi process automatically (configure with PI_DISCORD_SPAWN_COMMAND/PI_DISCORD_SPAWN_ARGS).", ); return; } case "discord-status": { if (bootInFlight) await bootInFlight.catch(() => undefined); await replyNativeDiscordInteraction(interaction, statusText(cfg)); return; } case "discord-test": { const activeRelay = await waitForRelay(); const text = args || "pi-discord-remote test ping"; const msg = await activeRelay?.postRaw( activeRelay.getTargetChannelId(), text, ); await replyNativeDiscordInteraction( interaction, msg ? "Sent." : "Send failed (see stderr).", ); return; } case "discord-reconnect": { await interaction.deferReply({ ephemeral: true }).catch(() => undefined); if (bootInFlight) await bootInFlight.catch(() => undefined); if (relay) relay.shutdown(); if (client) await client.destroy().catch(() => undefined); client = undefined; relay = undefined; resetDiscordRest(); bootInFlight = bootClient(pi, cfg).finally(() => { bootInFlight = undefined; }); const newClient = await bootInFlight; await editNativeDiscordInteraction( interaction, `Reconnect complete. ready=${newClient?.isReady() ?? false}`, ); return; } case "discord-launcher-status": { const lines = [ `enabled=${cfg.launcherEnabled}`, `port=${cfg.launcherPort}`, `running=${launcherServer?.listening ?? false}`, `spawn_command=${cfg.spawnCommand || "(none)"}`, `spawn_args=${cfg.spawnArgs.join(" ") || "(none)"}`, `secret=${cfg.launcherSecret ? "set" : "not set"}`, ].join("\n"); await replyNativeDiscordInteraction(interaction, lines); return; } default: { await replyNativeDiscordInteraction( interaction, "Unsupported Discord bridge command.", ); } } } function restoreThreadBindings(ctx: ExtensionContext, cfg: Config): void { sessionThreadIds.clear(); for (const entry of ctx.sessionManager.getEntries()) { if (entry.type !== "custom" || entry.customType !== THREAD_BINDING_TYPE) continue; const data = entry.data as | { sessionId?: string; threadId?: string } | undefined; if ( typeof data?.sessionId === "string" && typeof data.threadId === "string" ) { sessionThreadIds.set(data.sessionId, data.threadId); } } const currentSessionId = ctx.sessionManager.getSessionId(); cfg.activeChannelId = sessionThreadIds.get(currentSessionId) ?? cfg.primaryChannelId; } function activeSessionThreadId(): string | undefined { const sessionId = safeContextSessionId(lastCtx); if (!sessionId) return undefined; return sessionThreadIds.get(sessionId); } function isInboundOriginAllowed(cfg: Config, channelId: string): boolean { if (cfg.channelIds.has(channelId)) return true; return channelId === activeSessionThreadId(); } async function bootClient( pi: ExtensionAPI, cfg: Config, ): Promise { if (client) return client; const c = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent, GatewayIntentBits.DirectMessages, GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.DirectMessageReactions, ], }); c.on(Events.Error, (err) => console.error("[discord-remote] client error:", err.message), ); c.on(Events.MessageReactionAdd, async (reaction, user) => { if (user.bot) return; if (reaction.emoji.name !== "🛑") return; if (!cfg.userIds.has(user.id)) return; let message = reaction.message; if (message.partial) { try { message = await message.fetch(); } catch { return; } } if (!message.author?.bot || message.author.id !== c.user?.id) return; // Only honor 🛑 from an allowlisted channel or the *current* session's // thread — never from a stale thread, which would otherwise abort the // currently-active session unexpectedly. if (!isInboundOriginAllowed(cfg, message.channelId)) return; if (lastCtx && !lastCtx.isIdle()) { lastCtx.abort(); await message.react("🛑").catch(() => undefined); } }); c.on(Events.InteractionCreate, async (interaction) => { if (!interaction.isChatInputCommand()) return; await handleNativeDiscordInteraction(pi, cfg, interaction).catch( async (err) => { console.error( "[discord-remote] interaction failed:", (err as Error).message, ); await replyNativeDiscordInteraction( interaction, `error: ${(err as Error).message}`, ); }, ); }); c.on(Events.MessageCreate, async (msg) => { if (msg.author.bot) return; // Same scoping rule as reactions: only the allowlisted channel(s) and // the active session's thread inject into Pi. Posts in stale session // threads are ignored so they can't cross-steer the live session. if (!isInboundOriginAllowed(cfg, msg.channelId)) return; if (!cfg.userIds.has(msg.author.id)) return; const text = msg.content.trim(); if (!text) return; if (text.startsWith("/") || text.startsWith("!")) { const handled = await handleDiscordCommand(pi, cfg, msg, text); if (handled) return; } if (text === "!abort") { if (lastCtx && !lastCtx.isIdle()) lastCtx.abort(); await msg.react("🛑").catch(() => undefined); return; } const idle = lastCtx ? lastCtx.isIdle() : true; try { // Idle: deliverAs omitted -> default delivery, triggers a fresh turn. // Streaming: must pass deliverAs ("steer" interrupts, "followUp" queues). if (idle) { await pi.sendUserMessage(`${cfg.steerLabel} ${text}`); await msg.react("✅").catch(() => undefined); } else { await pi.sendUserMessage(`${cfg.steerLabel} ${text}`, { deliverAs: "steer", }); await msg.react("📥").catch(() => undefined); } } catch (err) { await msg .reply(`error: ${(err as Error).message}`) .catch(() => undefined); } }); try { await c.login(cfg.botToken); await waitForClientReady(c); await syncNativeDiscordSlashCommands(pi, cfg, c).catch((err) => { console.error( "[discord-remote] slash command sync failed:", (err as Error).message, ); }); } catch (err) { console.error("[discord-remote] login failed:", (err as Error).message); try { await c.destroy(); } catch { /* ignore */ } return undefined; } // Only publish to module state once login is ready, so a shutdown that fires // mid-login doesn't tear down a half-built client and let a second one // race in. Callers (session_shutdown, /discord-reconnect) await // `bootInFlight` to ensure we don't observe the gap. client = c; relay = new DiscordRelay(c, cfg); return c; } function alreadyRelayed(message: AgentMessage): boolean { const ts = messageTimestamp(message); if (ts === undefined) return false; return relayedTimestamps.has(ts); } function markRelayed(pi: ExtensionAPI, message: AgentMessage): void { const ts = messageTimestamp(message); if (ts === undefined) return; if (relayedTimestamps.has(ts)) return; relayedTimestamps.add(ts); pi.appendEntry(RELAYED_TYPE, { timestamp: ts, role: message.role }); } function restoreRelayed(ctx: ExtensionContext): void { relayedTimestamps.clear(); for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === RELAYED_TYPE) { const data = entry.data as { timestamp?: number } | undefined; if (typeof data?.timestamp === "number") relayedTimestamps.add(data.timestamp); } } } async function waitForRelay(): Promise { if (bootInFlight) await bootInFlight.catch(() => undefined); return relay; } function statusText(cfg: Config): string { return [ `enabled=${cfg.enabled}`, `legacy_env=${cfg.allowLegacyEnv}`, `token=${cfg.botToken ? "set" : "missing"}`, `ready=${client?.isReady() ?? false}`, `user=${client?.user?.tag ?? "n/a"}`, `muted=${relay?.isMuted() ?? false}`, `primary_channel=${cfg.primaryChannelId || "(none)"}`, `active_channel=${cfg.activeChannelId || "(none)"}`, `discord_new_armed=${Boolean(getArmedCommandCtx())}`, `armed_session=${sharedState.armedSessionId || "(none)"}`, `create_thread_per_session=${cfg.createThreadPerSession}`, `thread_name_prefix=${cfg.threadNamePrefix}`, `slash_commands=${cfg.registerSlashCommands}`, `slash_command_guilds=${[...cfg.slashCommandGuildIds].join(",") || "(none)"}`, `slash_command_count=${nativeDiscordCommands.size}`, `channels=${[...cfg.channelIds].join(",") || "(none)"}`, `users=${[...cfg.userIds].join(",") || "(none)"}`, `render_user_input=${cfg.renderUserInput}`, `render_tool_calls=${cfg.renderToolCalls}`, `render_tool_results=${cfg.renderToolResults}`, `render_reasoning=${cfg.renderReasoning}`, `edit_interval_ms=${cfg.editIntervalMs}`, `create_interval_ms=${cfg.createIntervalMs}`, `max_chars=${cfg.maxChars}`, `launcher_enabled=${cfg.launcherEnabled}`, `launcher_port=${cfg.launcherPort}`, `launcher_running=${launcherServer?.listening ?? false}`, `spawn_command=${cfg.spawnCommand || "(none)"}`, `spawn_args=${cfg.spawnArgs.join(" ") || "(none)"}`, ].join("\n"); } function notifyOrLog( ctx: ExtensionContext | ExtensionCommandContext, text: string, kind: "info" | "warning" | "error" = "info", ) { if (ctx.hasUI) ctx.ui.notify(text, kind); else console.log(`[discord-remote] ${text}`); } // --- Session launcher: creates new Pi sessions on demand from Discord or HTTP --- function readBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; req.on("data", (chunk: Buffer) => chunks.push(chunk)); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); } async function doNewSession( cfg: Config, args: string, ): Promise<{ ok: boolean; method: string; cancelled?: boolean; pid?: number; error?: string; }> { const armedCtx = getArmedCommandCtx(); if (armedCtx) { const result = await armedCtx.newSession({ withSession: async (newCtx) => { lastCtx = newCtx; armDiscordBridge(newCtx); if (args) await newCtx.sendUserMessage(`${cfg.steerLabel} ${args}`); }, }); return { ok: !result.cancelled, method: "newSession", cancelled: result.cancelled, }; } // No armed context — fall back to spawning a fresh Pi process. // The spawned process inherits all PI_DISCORD_* env vars so it will connect // to Discord automatically and create its own session thread. if (!cfg.spawnCommand) { return { ok: false, method: "none", error: "not armed and PI_DISCORD_SPAWN_COMMAND is not set; run /discord-arm in the Pi TUI or configure a spawn command", }; } const spawnEnv: Record = {}; for (const [k, v] of Object.entries(process.env)) { if (v !== undefined) spawnEnv[k] = v; } // Disable the launcher server in child instances to avoid port conflicts. spawnEnv["PI_DISCORD_LAUNCHER_ENABLED"] = "false"; const proc = spawn(cfg.spawnCommand, cfg.spawnArgs, { env: spawnEnv, detached: true, stdio: "ignore", }); proc.unref(); return { ok: true, method: "spawn", pid: proc.pid }; } function startLauncherServer(cfg: Config): void { if (!cfg.launcherEnabled) return; const server = createServer(async (req, res) => { res.setHeader("Content-Type", "application/json"); if (req.method === "GET" && req.url === "/health") { res.writeHead(200); res.end( JSON.stringify({ ok: true, ready: client?.isReady() ?? false, armed: Boolean(getArmedCommandCtx()), }), ); return; } if (req.method === "POST" && req.url === "/new-session") { let data: { secret?: string; args?: string }; try { const raw = await readBody(req); data = JSON.parse(raw) as { secret?: string; args?: string }; } catch { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON body" })); return; } if (cfg.launcherSecret && data.secret !== cfg.launcherSecret) { res.writeHead(401); res.end(JSON.stringify({ error: "unauthorized" })); return; } try { const result = await doNewSession(cfg, (data.args ?? "").trim()); res.writeHead( result.ok ? (result.method === "spawn" ? 202 : 200) : 500, ); res.end(JSON.stringify(result)); } catch (err) { res.writeHead(500); res.end(JSON.stringify({ error: (err as Error).message })); } return; } res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); }); server.on("error", (err: Error & { code?: string }) => { if (err.code === "EADDRINUSE") { console.warn( `[discord-remote] launcher port ${cfg.launcherPort} already in use; this instance will not serve /new-session via HTTP.`, ); } else { console.error("[discord-remote] launcher server error:", err.message); } // Clear the reference only after an error; if the listen callback already // set it we still want to clear it so stopLauncherServer() won't call // close() on a broken server. launcherServer = undefined; }); server.listen(cfg.launcherPort, "127.0.0.1", () => { // Only mark the server as live once it is actually bound. launcherServer = server; if (!cfg.launcherSecret) { console.warn( "[discord-remote] ⚠️ launcher server is running WITHOUT a secret — " + "set PI_DISCORD_LAUNCHER_SECRET to restrict /new-session access.", ); } console.log( `[discord-remote] session launcher HTTP server listening on 127.0.0.1:${cfg.launcherPort}`, ); }); } function stopLauncherServer(): void { if (launcherServer) { launcherServer.close(); launcherServer = undefined; } } export default function discordRemote(pi: ExtensionAPI): void { loadProjectDotEnv(); const cfg = loadConfig(); if (!cfg.enabled) return; if ( !cfg.botToken || cfg.channelIds.size === 0 || cfg.userIds.size === 0 || !cfg.primaryChannelId ) { console.error( "[discord-remote] refusing to start: PI_DISCORD_BOT_TOKEN/PI_DISCORD_CHANNEL_IDS/PI_DISCORD_USER_IDS are required (or aliases PI_DISCORD_BOT_CHANNEL_ID/PI_DISCORD_USER_ID; legacy DISCORD_* aliases require PI_DISCORD_ALLOW_LEGACY_ENV=true).", ); return; } if (!cfg.channelIds.has(cfg.primaryChannelId)) { console.error( `[discord-remote] refusing to start: PI_DISCORD_PRIMARY_CHANNEL_ID=${cfg.primaryChannelId} is not in the channel allowlist (${[...cfg.channelIds].join(",")}).`, ); return; } pi.on("session_start", async (_event, ctx) => { lastCtx = ctx; refreshDiscordBridgeArmForSession(ctx); restoreRelayed(ctx); restoreThreadBindings(ctx, cfg); if (!launcherServer) startLauncherServer(cfg); if (!client && !bootInFlight) { bootInFlight = bootClient(pi, cfg).finally(() => { bootInFlight = undefined; }); } else { scheduleNativeDiscordSlashCommandSync(pi, cfg, client); } const sessionId = ctx.sessionManager.getSessionId(); if (!sessionThreadIds.has(sessionId)) { const activeRelay = await waitForRelay(); const threadId = await activeRelay?.createSessionThread(sessionId); if (threadId) { sessionThreadIds.set(sessionId, threadId); pi.appendEntry(THREAD_BINDING_TYPE, { sessionId, threadId }); } } }); pi.on("session_shutdown", async () => { // Drain in-flight boot first, so the client we tear down here is the // same one a concurrent login() would have published. if (bootInFlight) await bootInFlight.catch(() => undefined); stopLauncherServer(); if (relay) { await relay.flushAll().catch(() => undefined); relay.shutdown(); } clearDiscordBridgeArm(); if (client) { try { await client.destroy(); } catch (err) { console.error( "[discord-remote] destroy failed:", (err as Error).message, ); } } client = undefined; relay = undefined; resetDiscordRest(); cfg.activeChannelId = cfg.primaryChannelId; }); pi.on("input", async (event: InputEvent, ctx) => { lastCtx = ctx; if (!cfg.renderUserInput) return; if (event.source !== "interactive") return; const text = (event.text ?? "").trim(); if (!text) return; const activeRelay = await waitForRelay(); await activeRelay?.postRaw( activeRelay.getTargetChannelId(), `👤 **user (local):** ${clip(text, cfg.maxChars - 32)}`, ); }); pi.on("message_start", async (event, ctx) => { lastCtx = ctx; if (alreadyRelayed(event.message)) return; const activeRelay = await waitForRelay(); activeRelay?.onMessageStart(event); }); pi.on("message_update", async (event, ctx) => { lastCtx = ctx; if (alreadyRelayed(event.message)) return; const activeRelay = await waitForRelay(); activeRelay?.onMessageUpdate(event); }); pi.on("message_end", async (event, ctx) => { lastCtx = ctx; if (alreadyRelayed(event.message)) return; const activeRelay = await waitForRelay(); const sent = await activeRelay?.onMessageEnd(event); if (sent && event.message.role === "assistant") markRelayed(pi, event.message); }); pi.on("tool_call", async (event, ctx) => { lastCtx = ctx; const activeRelay = await waitForRelay(); await activeRelay?.onToolCall(event); }); pi.on("tool_result", async (event, ctx) => { lastCtx = ctx; const activeRelay = await waitForRelay(); await activeRelay?.onToolResult(event); }); pi.registerCommand("discord-arm", { description: "Arm in-process session replacement for Discord /new. Optional — without it, /new falls back to spawning a new Pi process.", handler: async (_args, ctx) => { const sessionId = armDiscordBridge(ctx); notifyOrLog( ctx, `Discord command bridge armed for session ${sessionId}. Discord /new will use in-process session replacement until the local session changes.`, ); }, }); pi.registerCommand("discord-status", { description: "Show Discord remote-control status (token, channels, users, connection).", handler: async (_args, ctx) => { if (bootInFlight) await bootInFlight.catch(() => undefined); notifyOrLog(ctx, statusText(cfg)); }, }); pi.registerCommand("discord-test", { description: "Post a test message to the primary Discord channel.", handler: async (args, ctx) => { const activeRelay = await waitForRelay(); const text = args?.trim() || "pi-discord-remote test ping"; const msg = await activeRelay?.postRaw( activeRelay.getTargetChannelId(), text, ); notifyOrLog( ctx, msg ? "Sent." : "Send failed (see stderr).", msg ? "info" : "warning", ); }, }); pi.registerCommand("discord-reconnect", { description: "Tear down and re-login the Discord client.", handler: async (_args, ctx) => { notifyOrLog(ctx, "Reconnecting..."); // Wait for any session_start-triggered boot before tearing down, // otherwise we race two concurrent bootClient calls. if (bootInFlight) await bootInFlight.catch(() => undefined); if (relay) relay.shutdown(); if (client) { try { await client.destroy(); } catch { /* ignore */ } } client = undefined; relay = undefined; resetDiscordRest(); bootInFlight = bootClient(pi, cfg).finally(() => { bootInFlight = undefined; }); const newClient = (await bootInFlight) as Client | undefined; notifyOrLog( ctx, `Reconnect complete. ready=${newClient?.isReady() ?? false}`, ); }, }); pi.registerCommand("discord-mute", { description: "Suppress Discord output for this Pi session without disconnecting.", handler: async (_args, ctx) => { const activeRelay = await waitForRelay(); if (!activeRelay) { notifyOrLog(ctx, "Discord relay not running.", "warning"); return; } const changed = activeRelay.setMuted(true); notifyOrLog( ctx, changed ? "Discord output muted." : "Discord output already muted.", ); }, }); pi.registerCommand("discord-unmute", { description: "Resume Discord output for this Pi session.", handler: async (_args, ctx) => { const activeRelay = await waitForRelay(); if (!activeRelay) { notifyOrLog(ctx, "Discord relay not running.", "warning"); return; } const changed = activeRelay.setMuted(false); notifyOrLog( ctx, changed ? "Discord output resumed." : "Discord output already unmuted.", ); }, }); pi.registerCommand("discord-launcher-status", { description: "Show Discord session-launcher HTTP server status and spawn config.", handler: async (_args, ctx) => { const lines = [ `enabled=${cfg.launcherEnabled}`, `port=${cfg.launcherPort}`, `running=${launcherServer?.listening ?? false}`, `spawn_command=${cfg.spawnCommand || "(none)"}`, `spawn_args=${cfg.spawnArgs.join(" ") || "(none)"}`, `secret=${cfg.launcherSecret ? "set" : "not set"}`, `armed=${Boolean(getArmedCommandCtx())}`, ].join("\n"); notifyOrLog(ctx, lines); }, }); }