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 +19 -3
- package/dist/{chunk-QRVSGBED.js → chunk-S4TTOKFY.js} +7 -2
- package/dist/chunk-S4TTOKFY.js.map +1 -0
- package/dist/daemon.js +257 -17
- package/dist/daemon.js.map +1 -1
- package/dist/index.js +63 -12
- package/dist/index.js.map +1 -1
- package/dist/mcp-discord-channels.d.ts +2 -0
- package/dist/mcp-discord-channels.js +227 -0
- package/dist/mcp-discord-channels.js.map +1 -0
- package/package.json +4 -2
- package/dist/chunk-QRVSGBED.js.map +0 -1
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 (
|
|
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-
|
|
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":";;;AAAA,SAAS,cAAc,eAAe,YAAY,YAAY,iBAAiB;AAC/E,SAAS,eAAe;AAEjB,SAAS,SAAS,SAAiB,KAAmB;AAC3D,YAAU,QAAQ,OAAO,GAAG,EAAE,WAAW,KAAK,CAAC;AAC/C,gBAAc,SAAS,OAAO,GAAG,GAAG,OAAO;AAC7C;AAEO,SAAS,QAAQ,SAAgC;AACtD,MAAI,CAAC,WAAW,OAAO,EAAG,QAAO;AACjC,QAAM,UAAU,aAAa,SAAS,OAAO,EAAE,KAAK;AACpD,QAAM,MAAM,SAAS,SAAS,EAAE;AAChC,SAAO,MAAM,GAAG,IAAI,OAAO;AAC7B;AAEO,SAAS,UAAU,SAAuB;AAC/C,MAAI,WAAW,OAAO,EAAG,YAAW,OAAO;AAC7C;AAEO,SAAS,gBAAgB,SAA0B;AACxD,QAAM,MAAM,QAAQ,OAAO;AAC3B,MAAI,QAAQ,KAAM,QAAO;AACzB,MAAI;AACF,YAAQ,KAAK,KAAK,CAAC;AACnB,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;;;AC5BA,SAAS,aAAa;AACtB,SAAS,gBAAAA,qBAAoB;AAGtB,SAAS,YAAY,MAAyB;AACnD,QAAM,MAAM,MAAM,IAAI;AAEtB,QAAM,UAAU,IAAI;AACpB,MAAI,CAAC,SAAS,SAAS,OAAO,QAAQ,UAAU,UAAU;AACxD,UAAM,IAAI,MAAM,iCAAiC;AAAA,EACnD;AACA,MAAI,QAAQ,MAAM,KAAK,EAAE,WAAW,GAAG;AACrC,UAAM,IAAI,MAAM,iCAAiC;AAAA,EACnD;AAEA,QAAM,SAAS,IAAI;AACnB,MAAI,CAAC,UAAU,OAAO,KAAK,MAAM,EAAE,WAAW,GAAG;AAC/C,UAAM,IAAI,MAAM,oDAAoD;AAAA,EACtE;AAEA,QAAM,eAAoC,CAAC;AAC3C,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AAElD,QAAI,CAAC,MAAM,WAAW,OAAO,MAAM,YAAY,UAAU;AACvD,YAAM,IAAI,MAAM,UAAU,IAAI,qCAAqC;AAAA,IACrE;AAGA,QAAI,MAAM,SAAS,QAAW;AAC5B,UAAI,CAAC,MAAM,QAAQ,MAAM,IAAI,KAAK,CAAC,MAAM,KAAK,MAAM,CAAC,MAAe,OAAO,MAAM,QAAQ,GAAG;AAC1F,cAAM,IAAI,MAAM,UAAU,IAAI,mCAAmC;AAAA,MACnE;AAAA,IACF;AAGA,QAAI,MAAM,iBAAiB,QAAW;AACpC,UAAI,OAAO,MAAM,iBAAiB,YAAY,MAAM,gBAAgB,GAAG;AACrE,cAAM,IAAI,MAAM,UAAU,IAAI,yCAAyC;AAAA,MACzE;AAAA,IACF;AAGA,QAAI,MAAM,QAAQ,UAAa,OAAO,MAAM,QAAQ,UAAU;AAC5D,YAAM,IAAI,MAAM,UAAU,IAAI,uBAAuB;AAAA,IACvD;AAGA,QAAI,MAAM,kBAAkB,UAAa,OAAO,MAAM,kBAAkB,WAAW;AACjF,YAAM,IAAI,MAAM,UAAU,IAAI,kCAAkC;AAAA,IAClE;AAEA,iBAAa,IAAI,IAAI;AAAA,MACnB,SAAS,MAAM;AAAA,MACf,MAAO,MAAM,QAAqB,CAAC;AAAA,MACnC,KAAK,OAAO,MAAM,QAAQ,WAAW,MAAM,MAAM,QAAQ,IAAI;AAAA,MAC7D,cAAc,OAAO,MAAM,iBAAiB,WAAW,MAAM,eAAe;AAAA,MAC5E,eAAe,MAAM,kBAAkB;AAAA,IACzC;AAAA,EACF;AAEA,QAAM,WAAY,IAAI,YAAY,CAAC;AACnC,QAAM,iBAAwC,CAAC;AAC/C,aAAW,CAAC,IAAI,EAAE,KAAK,OAAO,QAAQ,QAAQ,GAAG;AAC/C,UAAM,WAAW,GAAG,SAAS;AAC7B,QAAI,OAAO,aAAa,UAAU;AAChC,YAAM,IAAI,MAAM,YAAY,EAAE,yBAAyB;AAAA,IACzD;AACA,QAAI,CAAC,aAAa,QAAQ,GAAG;AAC3B,YAAM,IAAI,MAAM,YAAY,EAAE,oCAAoC,QAAQ,GAAG;AAAA,IAC/E;AACA,QAAI,GAAG,QAAQ,UAAa,OAAO,GAAG,QAAQ,UAAU;AACtD,YAAM,IAAI,MAAM,YAAY,EAAE,uBAAuB;AAAA,IACvD;AACA,QAAI,GAAG,eAAe,UAAa,OAAO,GAAG,eAAe,WAAW;AACrE,YAAM,IAAI,MAAM,YAAY,EAAE,+BAA+B;AAAA,IAC/D;AAEA,mBAAe,EAAE,IAAI;AAAA,MACnB,OAAO;AAAA,MACP,KAAK,GAAG,MAAM,OAAO,GAAG,GAAG,IAAI;AAAA,MAC/B,YAAY,GAAG,eAAe;AAAA,IAChC;AAAA,EACF;AAEA,SAAO;AAAA,IACL,SAAS,EAAE,OAAO,OAAO,QAAQ,KAAK,EAAE;AAAA,IACxC,QAAQ;AAAA,IACR,UAAU;AAAA,EACZ;AACF;AAEO,SAAS,WAAW,YAA+B;AACxD,QAAM,UAAUA,cAAa,YAAY,OAAO;AAChD,SAAO,YAAY,OAAO;AAC5B;AAEO,SAAS,qBACd,QACA,WAC8B;AAC9B,QAAM,cAAc,OAAO,SAAS,SAAS;AAC7C,MAAI,CAAC,YAAa,QAAO;AAEzB,QAAM,YAAY,OAAO,OAAO,YAAY,KAAK;AACjD,MAAI,CAAC,UAAW,QAAO;AAEvB,SAAO;AAAA,IACL;AAAA,IACA,WAAW,YAAY;AAAA,IACvB,OAAO;AAAA,MACL,GAAG;AAAA,MACH,KAAK,YAAY,OAAO,UAAU;AAAA,IACpC;AAAA,IACA,WAAW,YAAY,eAAe;AAAA,EACxC;AACF;","names":["readFileSync"]}
|
package/dist/daemon.js
CHANGED
|
@@ -4,11 +4,11 @@ import {
|
|
|
4
4
|
removePid,
|
|
5
5
|
resolveChannelConfig,
|
|
6
6
|
writePid
|
|
7
|
-
} from "./chunk-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
750
|
-
var CONFIG_PATH =
|
|
751
|
-
var PID_PATH =
|
|
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));
|