agentcord 0.1.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/.env.example ADDED
@@ -0,0 +1,23 @@
1
+ # Required
2
+ DISCORD_TOKEN=your-bot-token-here
3
+ DISCORD_CLIENT_ID=your-application-client-id
4
+
5
+ # Optional: Guild ID for instant command registration (otherwise global, ~1hr delay)
6
+ # DISCORD_GUILD_ID=your-guild-id
7
+
8
+ # Security: Comma-separated Discord user IDs allowed to use the bot
9
+ # ALLOWED_USERS=123456789,987654321
10
+ # Or set this to true to allow everyone (not recommended)
11
+ # ALLOW_ALL_USERS=true
12
+
13
+ # Optional: Restrict which directories sessions can access
14
+ # ALLOWED_PATHS=/Users/me/Dev,/Users/me/Projects
15
+
16
+ # Optional: Default working directory for new sessions
17
+ # DEFAULT_DIRECTORY=/Users/me/Dev
18
+
19
+ # Optional: Auto-delete messages older than N days
20
+ # MESSAGE_RETENTION_DAYS=7
21
+
22
+ # Optional: Rate limit in ms between messages per user (default 1000)
23
+ # RATE_LIMIT_MS=1000
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 discord-friends contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # agentcord
2
+
3
+ Run and manage AI coding agent sessions on your machine through Discord. Currently supports [Claude Code](https://docs.anthropic.com/en/docs/claude-code), with more agents coming soon.
4
+
5
+ Each session gets a Discord channel for chatting with the agent and a tmux session for direct terminal access. Sessions are organized by project — create multiple sessions in the same codebase, each with their own channel.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npm install -g agentcord
11
+ mkdir my-bot && cd my-bot
12
+ agentcord setup
13
+ agentcord
14
+ ```
15
+
16
+ The setup wizard walks you through creating a Discord app, configuring the bot token, and adding it to your server.
17
+
18
+ ## Requirements
19
+
20
+ - **Node.js 22.6+** (uses native TypeScript execution)
21
+ - **tmux** (for terminal session access)
22
+ - **Claude Code** installed on the machine (`@anthropic-ai/claude-agent-sdk`)
23
+
24
+ ## How It Works
25
+
26
+ ```
27
+ Discord message → SDK query() → coding agent
28
+
29
+ Discord embeds ← stream processing ← async iterator
30
+ ```
31
+
32
+ **Hybrid architecture**: The agent SDK handles structured streaming for Discord interaction, while each session also gets a tmux session. You can `tmux attach` and run `claude --resume <session-id>` to take over the same conversation in your terminal.
33
+
34
+ **Project-based organization**:
35
+
36
+ ```
37
+ Discord Server
38
+ └── my-api (category)
39
+ │ ├── #claude-fix-auth ← session in ~/Dev/my-api
40
+ │ ├── #claude-add-tests ← another session, same project
41
+ │ └── #project-logs
42
+ └── frontend (category)
43
+ ├── #claude-redesign ← session in ~/Dev/frontend
44
+ └── #project-logs
45
+ ```
46
+
47
+ ## Discord Commands
48
+
49
+ ### Sessions
50
+
51
+ | Command | Description |
52
+ |---------|-------------|
53
+ | `/claude new <name> [directory]` | Create a session with a Discord channel + tmux session |
54
+ | `/claude list` | List active sessions grouped by project |
55
+ | `/claude end` | End the session in the current channel |
56
+ | `/claude continue` | Continue the conversation |
57
+ | `/claude stop` | Abort current generation |
58
+ | `/claude attach` | Show tmux attach command for terminal access |
59
+ | `/claude model <model>` | Change model for the session |
60
+ | `/claude verbose` | Toggle tool call/result visibility |
61
+ | `/claude sync` | Reconnect orphaned tmux sessions |
62
+
63
+ ### Shell
64
+
65
+ | Command | Description |
66
+ |---------|-------------|
67
+ | `/shell run <command>` | Execute a command in the session directory |
68
+ | `/shell processes` | List running background processes |
69
+ | `/shell kill <pid>` | Kill a process |
70
+
71
+ ### Agent Personas
72
+
73
+ | Command | Description |
74
+ |---------|-------------|
75
+ | `/agent use <persona>` | Switch persona (code-reviewer, architect, debugger, security, performance, devops) |
76
+ | `/agent list` | List available personas |
77
+ | `/agent clear` | Reset to default |
78
+
79
+ ### Project Config
80
+
81
+ | Command | Description |
82
+ |---------|-------------|
83
+ | `/project personality <prompt>` | Set a custom system prompt for the project |
84
+ | `/project personality-show` | Show current personality |
85
+ | `/project personality-clear` | Remove personality |
86
+ | `/project skill-add <name> <prompt>` | Add a reusable prompt template (`{input}` placeholder) |
87
+ | `/project skill-run <name> [input]` | Execute a skill |
88
+ | `/project skill-list` | List skills |
89
+ | `/project mcp-add <name> <command>` | Register an MCP server (writes `.mcp.json`) |
90
+ | `/project mcp-list` | List MCP servers |
91
+ | `/project info` | Show project config summary |
92
+
93
+ ## Features
94
+
95
+ - **Real-time streaming** — Agent responses stream into Discord with edit-in-place updates
96
+ - **Typing indicator** — Shows "Bot is typing..." while the agent is working
97
+ - **Message interruption** — Send a new message to automatically interrupt and redirect the agent
98
+ - **Interactive prompts** — Multi-choice questions render as Discord buttons
99
+ - **Task board** — Agent task lists display as visual embeds with status emojis
100
+ - **Tool output control** — Hidden by default, toggle with `/claude verbose`
101
+ - **Per-project customization** — System prompts, skills, and MCP servers scoped to projects
102
+ - **Agent personas** — Switch between specialized roles (code reviewer, architect, etc.)
103
+ - **Session persistence** — Sessions survive bot restarts
104
+ - **Terminal access** — `tmux attach` to any session for direct CLI use
105
+
106
+ ## Configuration
107
+
108
+ The setup wizard (`agentcord setup`) creates a `.env` file. You can also edit it directly:
109
+
110
+ ```env
111
+ # Required
112
+ DISCORD_TOKEN=your-bot-token
113
+ DISCORD_CLIENT_ID=your-client-id
114
+
115
+ # Optional
116
+ DISCORD_GUILD_ID=your-guild-id # Instant command registration
117
+ ALLOWED_USERS=123456789,987654321 # Comma-separated user IDs
118
+ ALLOW_ALL_USERS=false # Or true to skip whitelist
119
+ ALLOWED_PATHS=/Users/me/Dev # Restrict accessible directories
120
+ DEFAULT_DIRECTORY=/Users/me/Dev # Default for new sessions
121
+ ```
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ git clone https://github.com/manuelatravajo/agentcord.git
127
+ cd agentcord
128
+ npm install
129
+ cp .env.example .env # fill in your values
130
+ npm run dev # start with --watch
131
+ ```
132
+
133
+ ## License
134
+
135
+ MIT
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const cli = join(__dirname, '..', 'src', 'cli.ts');
9
+ const args = process.argv.slice(2);
10
+
11
+ const child = spawn(
12
+ process.execPath,
13
+ ['--experimental-strip-types', cli, ...args],
14
+ {
15
+ stdio: 'inherit',
16
+ cwd: process.cwd(),
17
+ env: { ...process.env, NODE_NO_WARNINGS: '1' },
18
+ },
19
+ );
20
+
21
+ child.on('exit', (code) => process.exit(code ?? 0));
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "agentcord",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Discord bot for managing AI coding agent sessions (Claude Code, Codex, and more)",
6
+ "bin": {
7
+ "agentcord": "./bin/agentcord.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "tsconfig.json",
13
+ ".env.example",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "setup": "node --experimental-strip-types src/setup.ts",
19
+ "start": "node --experimental-strip-types src/index.ts",
20
+ "dev": "node --experimental-strip-types --watch src/index.ts",
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest"
24
+ },
25
+ "keywords": [
26
+ "discord",
27
+ "claude",
28
+ "claude-code",
29
+ "codex",
30
+ "ai",
31
+ "agent",
32
+ "coding-agent",
33
+ "tmux",
34
+ "cli",
35
+ "bot"
36
+ ],
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=22.6.0"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/manuelatravajo/agentcord.git"
44
+ },
45
+ "homepage": "https://github.com/manuelatravajo/agentcord#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/manuelatravajo/agentcord/issues"
48
+ },
49
+ "dependencies": {
50
+ "@anthropic-ai/claude-agent-sdk": "^0.2.36",
51
+ "@clack/prompts": "^1.0.0",
52
+ "discord.js": "^14.16.3",
53
+ "dotenv": "^17.2.4"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.10.0",
57
+ "typescript": "^5.7.2",
58
+ "vitest": "^3.0.0"
59
+ }
60
+ }
package/src/agents.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { AgentPersona } from './types.ts';
2
+
3
+ export const agents: AgentPersona[] = [
4
+ {
5
+ name: 'code-reviewer',
6
+ emoji: '🔍',
7
+ description: 'Code quality, bugs, best practices',
8
+ systemPrompt: `You are a senior code reviewer. Focus on:
9
+ - Code quality and readability
10
+ - Potential bugs and edge cases
11
+ - Security vulnerabilities
12
+ - Performance concerns
13
+ - Best practices and design patterns
14
+ Be specific, cite line numbers, and suggest concrete improvements.`,
15
+ },
16
+ {
17
+ name: 'architect',
18
+ emoji: '🏗️',
19
+ description: 'System design, patterns, scalability',
20
+ systemPrompt: `You are a software architect. Focus on:
21
+ - System design and architecture patterns
22
+ - Scalability and maintainability
23
+ - Component boundaries and interfaces
24
+ - Data flow and state management
25
+ - Trade-offs between different approaches
26
+ Think in terms of systems, not just code.`,
27
+ },
28
+ {
29
+ name: 'debugger',
30
+ emoji: '🐛',
31
+ description: 'Root cause analysis, debugging strategies',
32
+ systemPrompt: `You are a debugging specialist. Focus on:
33
+ - Root cause analysis over symptoms
34
+ - Systematic debugging strategies
35
+ - Reproducing issues reliably
36
+ - Tracing data flow to find where things break
37
+ - Suggesting targeted fixes with minimal side effects
38
+ Think methodically and follow the evidence.`,
39
+ },
40
+ {
41
+ name: 'security',
42
+ emoji: '🔒',
43
+ description: 'Vulnerabilities, OWASP, secure coding',
44
+ systemPrompt: `You are a security analyst. Focus on:
45
+ - OWASP Top 10 vulnerabilities
46
+ - Input validation and sanitization
47
+ - Authentication and authorization flaws
48
+ - Injection attacks (SQL, XSS, command)
49
+ - Secure defaults and least privilege
50
+ Flag issues with severity ratings and remediation steps.`,
51
+ },
52
+ {
53
+ name: 'performance',
54
+ emoji: '🚀',
55
+ description: 'Optimization, profiling, bottlenecks',
56
+ systemPrompt: `You are a performance engineer. Focus on:
57
+ - Identifying bottlenecks and hot paths
58
+ - Algorithm and data structure choices
59
+ - Memory allocation and GC pressure
60
+ - I/O optimization and caching strategies
61
+ - Benchmarking and profiling recommendations
62
+ Quantify impact where possible.`,
63
+ },
64
+ {
65
+ name: 'devops',
66
+ emoji: '⚙️',
67
+ description: 'CI/CD, Docker, infrastructure',
68
+ systemPrompt: `You are a DevOps engineer. Focus on:
69
+ - CI/CD pipeline design and optimization
70
+ - Container and orchestration best practices
71
+ - Infrastructure as code
72
+ - Monitoring, logging, and observability
73
+ - Deployment strategies and rollback plans
74
+ Prioritize reliability and automation.`,
75
+ },
76
+ {
77
+ name: 'general',
78
+ emoji: '🧠',
79
+ description: 'Default — no specialized focus',
80
+ systemPrompt: '',
81
+ },
82
+ ];
83
+
84
+ export function getAgent(name: string): AgentPersona | undefined {
85
+ return agents.find(a => a.name === name);
86
+ }
87
+
88
+ export function listAgents(): AgentPersona[] {
89
+ return agents;
90
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,193 @@
1
+ import {
2
+ Client,
3
+ GatewayIntentBits,
4
+ ActivityType,
5
+ InteractionType,
6
+ ComponentType,
7
+ type TextChannel,
8
+ ChannelType,
9
+ Collection,
10
+ } from 'discord.js';
11
+ import { config } from './config.ts';
12
+ import { registerCommands } from './commands.ts';
13
+ import { handleClaude, handleShell, handleAgent, handleProject, setLogger } from './command-handlers.ts';
14
+ import { handleMessage } from './message-handler.ts';
15
+ import { handleButton, handleSelectMenu } from './button-handler.ts';
16
+ import { loadSessions, getAllSessions, unlinkChannel } from './session-manager.ts';
17
+ import { loadProjects } from './project-manager.ts';
18
+
19
+ let client: Client;
20
+ let logChannel: TextChannel | null = null;
21
+ let logBuffer: string[] = [];
22
+ let logTimer: ReturnType<typeof setTimeout> | null = null;
23
+
24
+ function botLog(msg: string): void {
25
+ const timestamp = new Date().toISOString().slice(11, 19);
26
+ const formatted = `\`[${timestamp}]\` ${msg}`;
27
+ console.log(`[${timestamp}] ${msg}`);
28
+
29
+ logBuffer.push(formatted);
30
+ if (!logTimer) {
31
+ logTimer = setTimeout(flushLogs, 2000);
32
+ }
33
+ }
34
+
35
+ async function flushLogs(): Promise<void> {
36
+ logTimer = null;
37
+ if (!logChannel || logBuffer.length === 0) return;
38
+
39
+ const batch = logBuffer.splice(0, logBuffer.length).join('\n');
40
+ try {
41
+ await logChannel.send(batch);
42
+ } catch {
43
+ // Log channel may have been deleted
44
+ }
45
+ }
46
+
47
+ function updatePresence(): void {
48
+ const sessionCount = getAllSessions().length;
49
+ const generating = getAllSessions().filter(s => s.isGenerating).length;
50
+
51
+ if (sessionCount === 0) {
52
+ client.user?.setPresence({
53
+ status: 'idle',
54
+ activities: [{ name: 'No active sessions', type: ActivityType.Custom }],
55
+ });
56
+ } else {
57
+ const status = generating > 0 ? `${generating} generating` : `${sessionCount} sessions`;
58
+ client.user?.setPresence({
59
+ status: 'online',
60
+ activities: [{ name: `${status}`, type: ActivityType.Watching }],
61
+ });
62
+ }
63
+ }
64
+
65
+ // Message retention cleanup
66
+ async function cleanupOldMessages(): Promise<void> {
67
+ if (!config.messageRetentionDays) return;
68
+
69
+ const cutoff = Date.now() - config.messageRetentionDays * 24 * 60 * 60 * 1000;
70
+
71
+ for (const session of getAllSessions()) {
72
+ try {
73
+ const channel = client.channels.cache.get(session.channelId) as TextChannel | undefined;
74
+ if (!channel) continue;
75
+
76
+ const messages = await channel.messages.fetch({ limit: 100 });
77
+ const old = messages.filter(m => m.createdTimestamp < cutoff);
78
+ if (old.size > 0) {
79
+ await channel.bulkDelete(old, true);
80
+ }
81
+ } catch {
82
+ // Channel may not exist
83
+ }
84
+ }
85
+ }
86
+
87
+ export async function startBot(): Promise<void> {
88
+ client = new Client({
89
+ intents: [
90
+ GatewayIntentBits.Guilds,
91
+ GatewayIntentBits.GuildMessages,
92
+ GatewayIntentBits.MessageContent,
93
+ ],
94
+ });
95
+
96
+ setLogger(botLog);
97
+
98
+ // Slash commands
99
+ client.on('interactionCreate', async interaction => {
100
+ try {
101
+ if (interaction.type === InteractionType.ApplicationCommand && interaction.isChatInputCommand()) {
102
+ switch (interaction.commandName) {
103
+ case 'claude': return await handleClaude(interaction);
104
+ case 'shell': return await handleShell(interaction);
105
+ case 'agent': return await handleAgent(interaction);
106
+ case 'project': return await handleProject(interaction);
107
+ }
108
+ }
109
+
110
+ if (interaction.isButton()) {
111
+ return await handleButton(interaction);
112
+ }
113
+
114
+ if (interaction.isStringSelectMenu()) {
115
+ return await handleSelectMenu(interaction);
116
+ }
117
+ } catch (err) {
118
+ console.error('Interaction error:', err);
119
+ try {
120
+ if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) {
121
+ await interaction.reply({ content: 'An error occurred.', ephemeral: true });
122
+ }
123
+ } catch { /* can't recover */ }
124
+ }
125
+ });
126
+
127
+ // Channel messages
128
+ client.on('messageCreate', handleMessage);
129
+
130
+ // Channel deletion cleanup
131
+ client.on('channelDelete', channel => {
132
+ if (channel.type === ChannelType.GuildText) {
133
+ unlinkChannel(channel.id);
134
+ }
135
+ });
136
+
137
+ // Ready
138
+ client.once('ready', async () => {
139
+ console.log(`Logged in as ${client.user?.tag}`);
140
+
141
+ // Register commands
142
+ await registerCommands();
143
+
144
+ // Load persisted state
145
+ await loadProjects();
146
+ await loadSessions();
147
+
148
+ // Set up log channel in the first guild
149
+ const guild = client.guilds.cache.first();
150
+ if (guild) {
151
+ // Find existing bot-logs channel or note it doesn't exist
152
+ logChannel = guild.channels.cache.find(
153
+ ch => ch.name === 'bot-logs' && ch.type === ChannelType.GuildText,
154
+ ) as TextChannel | undefined ?? null;
155
+
156
+ if (!logChannel) {
157
+ try {
158
+ logChannel = await guild.channels.create({
159
+ name: 'bot-logs',
160
+ type: ChannelType.GuildText,
161
+ });
162
+ } catch {
163
+ console.warn('Could not create #bot-logs channel');
164
+ }
165
+ }
166
+ }
167
+
168
+ botLog(`Bot online. ${getAllSessions().length} session(s) restored.`);
169
+ updatePresence();
170
+
171
+ // Presence update interval
172
+ setInterval(updatePresence, 30_000);
173
+
174
+ // Message cleanup
175
+ if (config.messageRetentionDays) {
176
+ await cleanupOldMessages();
177
+ setInterval(cleanupOldMessages, 60 * 60 * 1000); // hourly
178
+ }
179
+ });
180
+
181
+ // Graceful shutdown
182
+ const shutdown = () => {
183
+ botLog('Shutting down...');
184
+ flushLogs();
185
+ client.destroy();
186
+ process.exit(0);
187
+ };
188
+
189
+ process.on('SIGINT', shutdown);
190
+ process.on('SIGTERM', shutdown);
191
+
192
+ await client.login(config.token);
193
+ }
@@ -0,0 +1,153 @@
1
+ import type {
2
+ ButtonInteraction,
3
+ StringSelectMenuInteraction,
4
+ TextChannel,
5
+ } from 'discord.js';
6
+ import { config } from './config.ts';
7
+ import * as sessions from './session-manager.ts';
8
+ import { handleOutputStream, getExpandableContent } from './output-handler.ts';
9
+ import { isUserAllowed, truncate } from './utils.ts';
10
+
11
+ export async function handleButton(interaction: ButtonInteraction): Promise<void> {
12
+ if (!isUserAllowed(interaction.user.id, config.allowedUsers, config.allowAllUsers)) {
13
+ await interaction.reply({ content: 'Not authorized.', ephemeral: true });
14
+ return;
15
+ }
16
+
17
+ const customId = interaction.customId;
18
+
19
+ // Stop button
20
+ if (customId.startsWith('stop:')) {
21
+ const sessionId = customId.slice(5);
22
+ const stopped = sessions.abortSession(sessionId);
23
+ await interaction.reply({
24
+ content: stopped ? 'Generation stopped.' : 'Session was not generating.',
25
+ ephemeral: true,
26
+ });
27
+ return;
28
+ }
29
+
30
+ // Continue button
31
+ if (customId.startsWith('continue:')) {
32
+ const sessionId = customId.slice(9);
33
+ const session = sessions.getSession(sessionId);
34
+ if (!session) {
35
+ await interaction.reply({ content: 'Session not found.', ephemeral: true });
36
+ return;
37
+ }
38
+ if (session.isGenerating) {
39
+ await interaction.reply({ content: 'Session is already generating.', ephemeral: true });
40
+ return;
41
+ }
42
+
43
+ await interaction.deferReply();
44
+ try {
45
+ const channel = interaction.channel as TextChannel;
46
+ const stream = sessions.continueSession(sessionId);
47
+ await interaction.editReply('Continuing...');
48
+ await handleOutputStream(stream, channel, sessionId, session.verbose);
49
+ } catch (err: unknown) {
50
+ await interaction.editReply(`Error: ${(err as Error).message}`);
51
+ }
52
+ return;
53
+ }
54
+
55
+ // Expand button
56
+ if (customId.startsWith('expand:')) {
57
+ const contentId = customId.slice(7);
58
+ const content = getExpandableContent(contentId);
59
+ if (!content) {
60
+ await interaction.reply({ content: 'Content expired.', ephemeral: true });
61
+ return;
62
+ }
63
+ // Discord max message is 2000 chars
64
+ const display = truncate(content, 1950);
65
+ await interaction.reply({ content: `\`\`\`\n${display}\n\`\`\``, ephemeral: true });
66
+ return;
67
+ }
68
+
69
+ // Option buttons (numbered choices)
70
+ if (customId.startsWith('option:')) {
71
+ const parts = customId.split(':');
72
+ const sessionId = parts[1];
73
+ const optionIndex = parseInt(parts[2], 10);
74
+
75
+ const session = sessions.getSession(sessionId);
76
+ if (!session) {
77
+ await interaction.reply({ content: 'Session not found.', ephemeral: true });
78
+ return;
79
+ }
80
+
81
+ // Send the option number as input
82
+ const optionText = `${optionIndex + 1}`;
83
+ await interaction.deferReply();
84
+ try {
85
+ const channel = interaction.channel as TextChannel;
86
+ const stream = sessions.sendPrompt(sessionId, optionText);
87
+ await interaction.editReply(`Selected option ${optionIndex + 1}`);
88
+ await handleOutputStream(stream, channel, sessionId, session.verbose);
89
+ } catch (err: unknown) {
90
+ await interaction.editReply(`Error: ${(err as Error).message}`);
91
+ }
92
+ return;
93
+ }
94
+
95
+ // Confirm buttons (yes/no)
96
+ if (customId.startsWith('confirm:')) {
97
+ const parts = customId.split(':');
98
+ const sessionId = parts[1];
99
+ const answer = parts[2]; // 'yes' or 'no'
100
+
101
+ const session = sessions.getSession(sessionId);
102
+ if (!session) {
103
+ await interaction.reply({ content: 'Session not found.', ephemeral: true });
104
+ return;
105
+ }
106
+
107
+ await interaction.deferReply();
108
+ try {
109
+ const channel = interaction.channel as TextChannel;
110
+ const stream = sessions.sendPrompt(sessionId, answer);
111
+ await interaction.editReply(`Answered: ${answer}`);
112
+ await handleOutputStream(stream, channel, sessionId, session.verbose);
113
+ } catch (err: unknown) {
114
+ await interaction.editReply(`Error: ${(err as Error).message}`);
115
+ }
116
+ return;
117
+ }
118
+
119
+ await interaction.reply({ content: 'Unknown button.', ephemeral: true });
120
+ }
121
+
122
+ export async function handleSelectMenu(interaction: StringSelectMenuInteraction): Promise<void> {
123
+ if (!isUserAllowed(interaction.user.id, config.allowedUsers, config.allowAllUsers)) {
124
+ await interaction.reply({ content: 'Not authorized.', ephemeral: true });
125
+ return;
126
+ }
127
+
128
+ const customId = interaction.customId;
129
+
130
+ if (customId.startsWith('select:')) {
131
+ const sessionId = customId.slice(7);
132
+ const selected = interaction.values[0];
133
+
134
+ const session = sessions.getSession(sessionId);
135
+ if (!session) {
136
+ await interaction.reply({ content: 'Session not found.', ephemeral: true });
137
+ return;
138
+ }
139
+
140
+ await interaction.deferReply();
141
+ try {
142
+ const channel = interaction.channel as TextChannel;
143
+ const stream = sessions.sendPrompt(sessionId, selected);
144
+ await interaction.editReply(`Selected: ${truncate(selected, 100)}`);
145
+ await handleOutputStream(stream, channel, sessionId, session.verbose);
146
+ } catch (err: unknown) {
147
+ await interaction.editReply(`Error: ${(err as Error).message}`);
148
+ }
149
+ return;
150
+ }
151
+
152
+ await interaction.reply({ content: 'Unknown selection.', ephemeral: true });
153
+ }