acp-discord 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ Send a message in Discord, get AI coding assistance back — with tool call visu
11
11
  - **File diffs** — see unified diffs in Discord when the agent modifies files
12
12
  - **Tool call visualization** — see what the agent is doing (⏳ pending → 🔄 running → ✅ done / ❌ failed), with a ⏹️ stop button to cancel
13
13
  - **Permission UI** — Discord buttons for approving/denying agent actions, with file diffs shown inline for review before approval
14
+ - **Discord channel management** — agents can create/delete/modify Discord channels via MCP tools, with user confirmation for all mutating operations
14
15
  - **Auto-reply mode** — optionally respond to all messages in a channel, not just mentions
15
16
  - **Multi-agent support** — different channels can use different agents
16
17
  - **Daemon mode** — runs in background with auto-start (systemd/launchd)
@@ -46,6 +47,7 @@ command = "claude-code"
46
47
  args = ["--acp"]
47
48
  cwd = "/path/to/your/project"
48
49
  idle_timeout = 600 # seconds before idle session is terminated (default: 600)
50
+ discord_tools = true # enable Discord channel management MCP tools (default: false)
49
51
 
50
52
  [channels.1234567890123456]
51
53
  agent = "claude"
@@ -93,6 +95,20 @@ acp-discord update # Update to latest version, auto-restarts daemon
93
95
 
94
96
  If a prompt is sent while the agent is already working, it gets queued and processed after the current task completes.
95
97
 
98
+ ### Channel Management
99
+
100
+ When `discord_tools = true` is set on an agent, the bot injects an MCP server that gives the agent these tools:
101
+
102
+ | Tool | Description | Requires Approval |
103
+ |------|-------------|:-----------------:|
104
+ | `list_channels` | List all text channels in the server | No |
105
+ | `create_channel` | Create a new text channel | Yes |
106
+ | `delete_channel` | Delete a channel | Yes |
107
+ | `update_channel` | Update channel name/topic | Yes |
108
+ | `send_message` | Send a message to a channel | No |
109
+
110
+ All mutating operations (create, delete, update) require user approval via Discord buttons before executing. Newly created channels are automatically registered so the bot responds to messages there.
111
+
96
112
  ### Development
97
113
 
98
114
  ```bash
@@ -107,9 +123,9 @@ pnpm test:watch # Watch mode
107
123
  Discord User
108
124
  ↓ slash command / mention
109
125
  Discord Bot (discord.js)
110
- ↓ channel routing
111
- Session Manager (per-channel sessions)
112
- ↓ spawn agent subprocess
126
+ ↓ channel routing ↑ IPC (Unix socket)
127
+ Session Manager MCP Server (discord-channels)
128
+ ↓ spawn agent subprocess ↑ MCP tools (stdio)
113
129
  ACP Client (JSON-RPC over stdio)
114
130
  ↓ prompt / permissions / tool calls
115
131
  Agent (claude-code, codex, etc.)
@@ -61,11 +61,15 @@ function parseConfig(toml) {
61
61
  if (agent.cwd !== void 0 && typeof agent.cwd !== "string") {
62
62
  throw new Error(`agents.${name}.cwd must be a string`);
63
63
  }
64
+ if (agent.discord_tools !== void 0 && typeof agent.discord_tools !== "boolean") {
65
+ throw new Error(`agents.${name}.discord_tools must be a boolean`);
66
+ }
64
67
  parsedAgents[name] = {
65
68
  command: agent.command,
66
69
  args: agent.args ?? [],
67
70
  cwd: typeof agent.cwd === "string" ? agent.cwd : process.cwd(),
68
- idle_timeout: typeof agent.idle_timeout === "number" ? agent.idle_timeout : 600
71
+ idle_timeout: typeof agent.idle_timeout === "number" ? agent.idle_timeout : 600,
72
+ discord_tools: agent.discord_tools === true
69
73
  };
70
74
  }
71
75
  const channels = raw.channels ?? {};
@@ -107,6 +111,7 @@ function resolveChannelConfig(config, channelId) {
107
111
  if (!agentConf) return null;
108
112
  return {
109
113
  channelId,
114
+ agentName: channelConf.agent,
110
115
  agent: {
111
116
  ...agentConf,
112
117
  cwd: channelConf.cwd ?? agentConf.cwd
@@ -124,4 +129,4 @@ export {
124
129
  loadConfig,
125
130
  resolveChannelConfig
126
131
  };
127
- //# sourceMappingURL=chunk-QRVSGBED.js.map
132
+ //# sourceMappingURL=chunk-S4TTOKFY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli/pid.ts","../src/shared/config.ts"],"sourcesContent":["import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\n\nexport function writePid(pidPath: string, pid: number): void {\n mkdirSync(dirname(pidPath), { recursive: true });\n writeFileSync(pidPath, String(pid), \"utf-8\");\n}\n\nexport function readPid(pidPath: string): number | null {\n if (!existsSync(pidPath)) return null;\n const content = readFileSync(pidPath, \"utf-8\").trim();\n const pid = parseInt(content, 10);\n return isNaN(pid) ? null : pid;\n}\n\nexport function removePid(pidPath: string): void {\n if (existsSync(pidPath)) unlinkSync(pidPath);\n}\n\nexport function isDaemonRunning(pidPath: string): boolean {\n const pid = readPid(pidPath);\n if (pid === null) return false;\n try {\n process.kill(pid, 0); // signal 0 = check if process exists\n return true;\n } catch {\n return false;\n }\n}\n","import { parse } from \"smol-toml\";\nimport { readFileSync } from \"node:fs\";\nimport type { AppConfig, ResolvedChannelConfig } from \"./types.js\";\n\nexport function parseConfig(toml: string): AppConfig {\n const raw = parse(toml) as Record<string, unknown>;\n\n const discord = raw.discord as Record<string, unknown> | undefined;\n if (!discord?.token || typeof discord.token !== \"string\") {\n throw new Error(\"Missing required: discord.token\");\n }\n if (discord.token.trim().length === 0) {\n throw new Error(\"discord.token must not be empty\");\n }\n\n const agents = raw.agents as Record<string, Record<string, unknown>> | undefined;\n if (!agents || Object.keys(agents).length === 0) {\n throw new Error(\"Missing required: at least one agent in [agents.*]\");\n }\n\n const parsedAgents: AppConfig[\"agents\"] = {};\n for (const [name, agent] of Object.entries(agents)) {\n // Validate command is a non-empty string\n if (!agent.command || typeof agent.command !== \"string\") {\n throw new Error(`agents.${name}.command must be a non-empty string`);\n }\n\n // Validate args is an array of strings\n if (agent.args !== undefined) {\n if (!Array.isArray(agent.args) || !agent.args.every((a: unknown) => typeof a === \"string\")) {\n throw new Error(`agents.${name}.args must be an array of strings`);\n }\n }\n\n // Validate idle_timeout is a positive number\n if (agent.idle_timeout !== undefined) {\n if (typeof agent.idle_timeout !== \"number\" || agent.idle_timeout <= 0) {\n throw new Error(`agents.${name}.idle_timeout must be a positive number`);\n }\n }\n\n // Validate cwd is a string if provided\n if (agent.cwd !== undefined && typeof agent.cwd !== \"string\") {\n throw new Error(`agents.${name}.cwd must be a string`);\n }\n\n // Validate discord_tools is a boolean if provided\n if (agent.discord_tools !== undefined && typeof agent.discord_tools !== \"boolean\") {\n throw new Error(`agents.${name}.discord_tools must be a boolean`);\n }\n\n parsedAgents[name] = {\n command: agent.command,\n args: (agent.args as string[]) ?? [],\n cwd: typeof agent.cwd === \"string\" ? agent.cwd : process.cwd(),\n idle_timeout: typeof agent.idle_timeout === \"number\" ? agent.idle_timeout : 600,\n discord_tools: agent.discord_tools === true,\n };\n }\n\n const channels = (raw.channels ?? {}) as Record<string, Record<string, unknown>>;\n const parsedChannels: AppConfig[\"channels\"] = {};\n for (const [id, ch] of Object.entries(channels)) {\n const agentRef = ch.agent ?? \"default\";\n if (typeof agentRef !== \"string\") {\n throw new Error(`channels.${id}.agent must be a string`);\n }\n if (!parsedAgents[agentRef]) {\n throw new Error(`channels.${id}.agent references unknown agent \"${agentRef}\"`);\n }\n if (ch.cwd !== undefined && typeof ch.cwd !== \"string\") {\n throw new Error(`channels.${id}.cwd must be a string`);\n }\n if (ch.auto_reply !== undefined && typeof ch.auto_reply !== \"boolean\") {\n throw new Error(`channels.${id}.auto_reply must be a boolean`);\n }\n\n parsedChannels[id] = {\n agent: agentRef,\n cwd: ch.cwd ? String(ch.cwd) : undefined,\n auto_reply: ch.auto_reply === true,\n };\n }\n\n return {\n discord: { token: String(discord.token) },\n agents: parsedAgents,\n channels: parsedChannels,\n };\n}\n\nexport function loadConfig(configPath: string): AppConfig {\n const content = readFileSync(configPath, \"utf-8\");\n return parseConfig(content);\n}\n\nexport function resolveChannelConfig(\n config: AppConfig,\n channelId: string,\n): ResolvedChannelConfig | null {\n const channelConf = config.channels[channelId];\n if (!channelConf) return null;\n\n const agentConf = config.agents[channelConf.agent];\n if (!agentConf) return null;\n\n return {\n channelId,\n agentName: channelConf.agent,\n agent: {\n ...agentConf,\n cwd: channelConf.cwd ?? agentConf.cwd,\n },\n autoReply: channelConf.auto_reply === true,\n };\n}\n"],"mappings":"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","names":["readFileSync"]}
package/dist/daemon.js CHANGED
@@ -4,11 +4,11 @@ import {
4
4
  removePid,
5
5
  resolveChannelConfig,
6
6
  writePid
7
- } from "./chunk-QRVSGBED.js";
7
+ } from "./chunk-S4TTOKFY.js";
8
8
 
9
9
  // src/daemon/index.ts
10
- import { join } from "path";
11
- import { homedir } from "os";
10
+ import { join as join2 } from "path";
11
+ import { homedir as homedir2 } from "os";
12
12
 
13
13
  // src/daemon/discord-bot.ts
14
14
  import {
@@ -20,8 +20,11 @@ import {
20
20
  SlashCommandBuilder,
21
21
  ActionRowBuilder as ActionRowBuilder2,
22
22
  ButtonBuilder as ButtonBuilder2,
23
- ButtonStyle as ButtonStyle2
23
+ ButtonStyle as ButtonStyle2,
24
+ EmbedBuilder as EmbedBuilder2
24
25
  } from "discord.js";
26
+ import { resolve as resolvePath, dirname as dirname2 } from "path";
27
+ import { fileURLToPath } from "url";
25
28
 
26
29
  // src/daemon/channel-router.ts
27
30
  var ChannelRouter = class {
@@ -35,6 +38,19 @@ var ChannelRouter = class {
35
38
  isConfigured(channelId) {
36
39
  return this.resolve(channelId) !== null;
37
40
  }
41
+ registerDynamic(channelId, agentName, autoReply) {
42
+ if (!this.config.agents[agentName]) {
43
+ console.error(`Cannot register dynamic channel: unknown agent "${agentName}"`);
44
+ return;
45
+ }
46
+ this.config.channels[channelId] = {
47
+ agent: agentName,
48
+ auto_reply: autoReply
49
+ };
50
+ }
51
+ unregisterDynamic(channelId) {
52
+ delete this.config.channels[channelId];
53
+ }
38
54
  };
39
55
 
40
56
  // src/daemon/session-manager.ts
@@ -129,8 +145,8 @@ var SessionManager = class {
129
145
  constructor(handlers) {
130
146
  this.handlers = handlers;
131
147
  }
132
- async prompt(channelId, text, agentConfig, requestorId) {
133
- const session = await this.getOrCreate(channelId, agentConfig, requestorId);
148
+ async prompt(channelId, text, agentConfig, requestorId, mcpServers) {
149
+ const session = await this.getOrCreate(channelId, agentConfig, requestorId, mcpServers);
134
150
  session.lastActivity = Date.now();
135
151
  this.resetIdleTimer(session, agentConfig.idle_timeout);
136
152
  if (session.prompting) {
@@ -165,12 +181,12 @@ var SessionManager = class {
165
181
  session.connection.cancel({ sessionId: session.sessionId });
166
182
  }
167
183
  }
168
- async getOrCreate(channelId, agentConfig, requestorId) {
184
+ async getOrCreate(channelId, agentConfig, requestorId, mcpServers) {
169
185
  const existing = this.sessions.get(channelId);
170
186
  if (existing) return existing;
171
- return this.createSession(channelId, agentConfig, requestorId);
187
+ return this.createSession(channelId, agentConfig, requestorId, mcpServers);
172
188
  }
173
- async createSession(channelId, config, requestorId) {
189
+ async createSession(channelId, config, requestorId, mcpServers) {
174
190
  const proc = spawn(config.command, config.args, {
175
191
  stdio: ["pipe", "pipe", "inherit"],
176
192
  cwd: config.cwd
@@ -183,11 +199,14 @@ var SessionManager = class {
183
199
  this.sessions.delete(channelId);
184
200
  }
185
201
  });
186
- proc.on("exit", () => {
202
+ proc.on("exit", (code) => {
187
203
  const session = this.sessions.get(channelId);
188
204
  if (session?.process === proc) {
189
- this.sessions.delete(channelId);
190
205
  clearTimeout(session.idleTimer);
206
+ this.sessions.delete(channelId);
207
+ if (code !== 0 && code !== null) {
208
+ console.warn(`Agent process for channel ${channelId} exited with code ${code}`);
209
+ }
191
210
  }
192
211
  });
193
212
  let connection;
@@ -215,7 +234,7 @@ var SessionManager = class {
215
234
  });
216
235
  const result = await connection.newSession({
217
236
  cwd: config.cwd,
218
- mcpServers: []
237
+ mcpServers: mcpServers ?? []
219
238
  });
220
239
  sessionId = result.sessionId;
221
240
  } catch (err) {
@@ -460,6 +479,116 @@ async function sendPermissionRequest(channel, toolTitle, toolKind, options, requ
460
479
  });
461
480
  }
462
481
 
482
+ // src/daemon/ipc-server.ts
483
+ import { createServer } from "net";
484
+ import { existsSync, unlinkSync, mkdirSync, chmodSync } from "fs";
485
+ import { dirname } from "path";
486
+ import { homedir } from "os";
487
+ import { join } from "path";
488
+ var DEFAULT_IPC_SOCKET_PATH = join(homedir(), ".acp-discord", "ipc.sock");
489
+ var IpcServer = class {
490
+ server = null;
491
+ socketPath;
492
+ handler;
493
+ connections = /* @__PURE__ */ new Set();
494
+ constructor(handler, socketPath = DEFAULT_IPC_SOCKET_PATH) {
495
+ this.handler = handler;
496
+ this.socketPath = socketPath;
497
+ }
498
+ async start() {
499
+ if (existsSync(this.socketPath)) {
500
+ unlinkSync(this.socketPath);
501
+ }
502
+ mkdirSync(dirname(this.socketPath), { recursive: true });
503
+ return new Promise((resolve, reject) => {
504
+ this.server = createServer((socket) => this.handleConnection(socket));
505
+ this.server.on("error", (err) => {
506
+ console.error("IPC server error:", err);
507
+ reject(err);
508
+ });
509
+ this.server.listen(this.socketPath, () => {
510
+ chmodSync(this.socketPath, 384);
511
+ console.log(`IPC server listening on ${this.socketPath}`);
512
+ resolve();
513
+ });
514
+ });
515
+ }
516
+ handleConnection(socket) {
517
+ this.connections.add(socket);
518
+ let buffer = "";
519
+ socket.on("data", (data) => {
520
+ buffer += data.toString();
521
+ let newlineIdx;
522
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
523
+ const line = buffer.slice(0, newlineIdx).trim();
524
+ buffer = buffer.slice(newlineIdx + 1);
525
+ if (line) {
526
+ this.processMessage(socket, line).catch((err) => {
527
+ console.error("IPC message processing error:", err);
528
+ });
529
+ }
530
+ }
531
+ });
532
+ socket.on("close", () => {
533
+ this.connections.delete(socket);
534
+ });
535
+ socket.on("error", (err) => {
536
+ console.error("IPC connection error:", err);
537
+ this.connections.delete(socket);
538
+ });
539
+ }
540
+ async processMessage(socket, raw) {
541
+ let msg;
542
+ try {
543
+ msg = JSON.parse(raw);
544
+ } catch {
545
+ console.error("IPC: invalid JSON:", raw);
546
+ return;
547
+ }
548
+ switch (msg.action) {
549
+ case "register_channel":
550
+ if (msg.channelId && msg.agentName) {
551
+ this.handler.registerChannel(msg.channelId, msg.agentName, msg.autoReply ?? true);
552
+ }
553
+ break;
554
+ case "unregister_channel":
555
+ if (msg.channelId) {
556
+ this.handler.unregisterChannel(msg.channelId);
557
+ }
558
+ break;
559
+ case "confirm_action":
560
+ if (msg.requestId && msg.sourceChannelId && msg.description) {
561
+ const approved = await this.handler.confirmAction(
562
+ msg.sourceChannelId,
563
+ msg.description,
564
+ msg.details ?? ""
565
+ );
566
+ const response = JSON.stringify({ requestId: msg.requestId, approved }) + "\n";
567
+ socket.write(response);
568
+ }
569
+ break;
570
+ default:
571
+ console.error("IPC: unknown action:", msg.action);
572
+ }
573
+ }
574
+ stop() {
575
+ for (const conn of this.connections) {
576
+ conn.destroy();
577
+ }
578
+ this.connections.clear();
579
+ if (this.server) {
580
+ this.server.close();
581
+ this.server = null;
582
+ }
583
+ if (existsSync(this.socketPath)) {
584
+ try {
585
+ unlinkSync(this.socketPath);
586
+ } catch {
587
+ }
588
+ }
589
+ }
590
+ };
591
+
463
592
  // src/daemon/discord-bot.ts
464
593
  async function startDiscordBot(config) {
465
594
  const router = new ChannelRouter(config);
@@ -471,6 +600,51 @@ async function startDiscordBot(config) {
471
600
  const pendingDiffs = /* @__PURE__ */ new Map();
472
601
  const permissionDiffShown = /* @__PURE__ */ new Map();
473
602
  let discordClient;
603
+ const pendingConfirmations = /* @__PURE__ */ new Map();
604
+ async function handleConfirmAction(sourceChannelId, description, details) {
605
+ const channel = await fetchChannel(sourceChannelId);
606
+ if (!channel) return false;
607
+ const allowedUserId = sessionManager.getActiveRequestorId(sourceChannelId);
608
+ const requestId = `mcp_confirm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
609
+ const embed = new EmbedBuilder2().setColor(16753920).setTitle(`Channel Action: ${description}`).setDescription(details || "No additional details").setTimestamp();
610
+ const row = new ActionRowBuilder2().addComponents(
611
+ new ButtonBuilder2().setCustomId(`mcp_approve_${requestId}`).setLabel("\u2705 Approve").setStyle(ButtonStyle2.Success),
612
+ new ButtonBuilder2().setCustomId(`mcp_reject_${requestId}`).setLabel("\u274C Reject").setStyle(ButtonStyle2.Danger)
613
+ );
614
+ const msg = await channel.send({ embeds: [embed], components: [row] });
615
+ return new Promise((resolve) => {
616
+ const timeout = setTimeout(() => {
617
+ pendingConfirmations.delete(requestId);
618
+ msg.delete().catch(() => msg.edit({ components: [] }).catch(() => {
619
+ }));
620
+ resolve(false);
621
+ }, 5 * 60 * 1e3);
622
+ pendingConfirmations.set(requestId, {
623
+ resolve: (approved) => {
624
+ clearTimeout(timeout);
625
+ pendingConfirmations.delete(requestId);
626
+ msg.delete().catch(() => msg.edit({ components: [] }).catch(() => {
627
+ }));
628
+ resolve(approved);
629
+ },
630
+ allowedUserId
631
+ });
632
+ });
633
+ }
634
+ const ipcServer = new IpcServer(
635
+ {
636
+ registerChannel(channelId, agentName, autoReply) {
637
+ router.registerDynamic(channelId, agentName, autoReply);
638
+ console.log(`IPC: registered dynamic channel ${channelId} -> agent ${agentName}`);
639
+ },
640
+ unregisterChannel(channelId) {
641
+ router.unregisterDynamic(channelId);
642
+ console.log(`IPC: unregistered dynamic channel ${channelId}`);
643
+ },
644
+ confirmAction: handleConfirmAction
645
+ },
646
+ DEFAULT_IPC_SOCKET_PATH
647
+ );
474
648
  const handlers = {
475
649
  onToolCall(channelId, toolCallId, title, _kind, status, diffs, rawInput) {
476
650
  if (!toolStates.has(channelId)) toolStates.set(channelId, /* @__PURE__ */ new Map());
@@ -517,6 +691,26 @@ async function startDiscordBot(config) {
517
691
  }
518
692
  };
519
693
  const sessionManager = new SessionManager(handlers);
694
+ function buildMcpServers(channelId, agentName, guildId) {
695
+ const resolved = router.resolve(channelId);
696
+ if (!resolved?.agent.discord_tools) return [];
697
+ const currentDir = import.meta.dirname ?? dirname2(fileURLToPath(import.meta.url));
698
+ const mcpScriptPath = resolvePath(currentDir, "mcp-discord-channels.js");
699
+ return [
700
+ {
701
+ name: "discord-channels",
702
+ command: "node",
703
+ args: [mcpScriptPath],
704
+ env: [
705
+ { name: "DISCORD_TOKEN", value: config.discord.token },
706
+ { name: "GUILD_ID", value: guildId },
707
+ { name: "IPC_SOCKET_PATH", value: DEFAULT_IPC_SOCKET_PATH },
708
+ { name: "AGENT_NAME", value: agentName },
709
+ { name: "SOURCE_CHANNEL_ID", value: channelId }
710
+ ]
711
+ }
712
+ ];
713
+ }
520
714
  function accumulateDiffs(channelId, toolCallId, diffs) {
521
715
  if (diffs.length === 0) return;
522
716
  if (!pendingDiffs.has(channelId)) pendingDiffs.set(channelId, /* @__PURE__ */ new Map());
@@ -622,6 +816,13 @@ async function startDiscordBot(config) {
622
816
  }
623
817
  }
624
818
  }
819
+ function getGuildId(message) {
820
+ return message.guildId ?? null;
821
+ }
822
+ async function promptWithMcp(channelId, text, agentName, guildId, agentConfig, requestorId) {
823
+ const mcpServers = guildId ? buildMcpServers(channelId, agentName, guildId) : void 0;
824
+ await sessionManager.prompt(channelId, text, agentConfig, requestorId, mcpServers);
825
+ }
625
826
  discordClient = new Client({
626
827
  intents: [
627
828
  GatewayIntentBits.Guilds,
@@ -629,6 +830,18 @@ async function startDiscordBot(config) {
629
830
  GatewayIntentBits.MessageContent
630
831
  ]
631
832
  });
833
+ discordClient.on("error", (err) => {
834
+ console.error("Discord client error:", err);
835
+ });
836
+ discordClient.on("warn", (msg) => {
837
+ console.warn("Discord client warning:", msg);
838
+ });
839
+ discordClient.on("shardDisconnect", (event, shardId) => {
840
+ console.warn(`Shard ${shardId} disconnected (code: ${event.code})`);
841
+ });
842
+ discordClient.on("shardReconnecting", (shardId) => {
843
+ console.log(`Shard ${shardId} reconnecting...`);
844
+ });
632
845
  discordClient.on(Events.ClientReady, async (c) => {
633
846
  console.log(`Discord bot ready: ${c.user.tag}`);
634
847
  const askCommand = new SlashCommandBuilder().setName("ask").setDescription("Ask the coding agent a question").addStringOption(
@@ -661,7 +874,7 @@ async function startDiscordBot(config) {
661
874
  await message.reply("\u23F3 Agent is working. Your message has been queued.");
662
875
  }
663
876
  try {
664
- await sessionManager.prompt(channelId, text, resolved.agent, message.author.id);
877
+ await promptWithMcp(channelId, text, resolved.agentName, getGuildId(message), resolved.agent, message.author.id);
665
878
  } catch (err) {
666
879
  console.error(`Prompt failed for channel ${channelId}:`, err);
667
880
  await message.reply("An error occurred while processing your request.").catch(() => {
@@ -680,6 +893,21 @@ async function startDiscordBot(config) {
680
893
  sessionManager.cancel(channelId);
681
894
  await interaction.update({ components: [] });
682
895
  }
896
+ if (interaction.customId.startsWith("mcp_approve_") || interaction.customId.startsWith("mcp_reject_")) {
897
+ const approved = interaction.customId.startsWith("mcp_approve_");
898
+ const requestId = interaction.customId.replace(/^mcp_(approve|reject)_/, "");
899
+ const pending = pendingConfirmations.get(requestId);
900
+ if (pending) {
901
+ if (pending.allowedUserId && interaction.user.id !== pending.allowedUserId) {
902
+ await interaction.reply({ content: "Only the user who started this prompt can approve or reject.", ephemeral: true });
903
+ return;
904
+ }
905
+ await interaction.deferUpdate();
906
+ pending.resolve(approved);
907
+ } else {
908
+ await interaction.reply({ content: "This confirmation has expired.", ephemeral: true });
909
+ }
910
+ }
683
911
  });
684
912
  discordClient.on(Events.InteractionCreate, async (interaction) => {
685
913
  if (!interaction.isChatInputCommand()) return;
@@ -698,7 +926,8 @@ async function startDiscordBot(config) {
698
926
  await interaction.editReply(`\u{1F4AC} Processing: ${text.slice(0, 100)}...`);
699
927
  }
700
928
  try {
701
- await sessionManager.prompt(channelId, text, resolved.agent, interaction.user.id);
929
+ const guildId = interaction.guildId ?? null;
930
+ await promptWithMcp(channelId, text, resolved.agentName, guildId, resolved.agent, interaction.user.id);
702
931
  } catch (err) {
703
932
  console.error(`Prompt failed for channel ${channelId}:`, err);
704
933
  await interaction.followUp({ content: "An error occurred while processing your request.", ephemeral: true }).catch(() => {
@@ -721,11 +950,14 @@ async function startDiscordBot(config) {
721
950
  flushTimers.delete(channelId);
722
951
  await interaction.reply("Session cleared. Next message will start a fresh agent.");
723
952
  });
953
+ await ipcServer.start();
724
954
  process.on("SIGTERM", () => {
955
+ ipcServer.stop();
725
956
  sessionManager.teardownAll();
726
957
  discordClient.destroy();
727
958
  });
728
959
  process.on("SIGINT", () => {
960
+ ipcServer.stop();
729
961
  sessionManager.teardownAll();
730
962
  discordClient.destroy();
731
963
  });
@@ -741,15 +973,23 @@ async function startDiscordBot(config) {
741
973
  } else {
742
974
  console.error("Error: Failed to connect to Discord:", message);
743
975
  }
976
+ ipcServer.stop();
744
977
  process.exit(1);
745
978
  }
746
979
  }
747
980
 
748
981
  // src/daemon/index.ts
749
- var CONFIG_DIR = join(homedir(), ".acp-discord");
750
- var CONFIG_PATH = join(CONFIG_DIR, "config.toml");
751
- var PID_PATH = join(CONFIG_DIR, "daemon.pid");
982
+ var CONFIG_DIR = join2(homedir2(), ".acp-discord");
983
+ var CONFIG_PATH = join2(CONFIG_DIR, "config.toml");
984
+ var PID_PATH = join2(CONFIG_DIR, "daemon.pid");
752
985
  async function runDaemon() {
986
+ process.on("uncaughtException", (err) => {
987
+ console.error("Uncaught exception:", err);
988
+ setTimeout(() => process.exit(1), 1e3);
989
+ });
990
+ process.on("unhandledRejection", (reason) => {
991
+ console.error("Unhandled rejection:", reason);
992
+ });
753
993
  const config = loadConfig(CONFIG_PATH);
754
994
  writePid(PID_PATH, process.pid);
755
995
  process.on("exit", () => removePid(PID_PATH));