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 +23 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/bin/agentcord.js +21 -0
- package/package.json +60 -0
- package/src/agents.ts +90 -0
- package/src/bot.ts +193 -0
- package/src/button-handler.ts +153 -0
- package/src/cli.ts +50 -0
- package/src/command-handlers.ts +623 -0
- package/src/commands.ts +166 -0
- package/src/config.ts +45 -0
- package/src/index.ts +18 -0
- package/src/message-handler.ts +60 -0
- package/src/output-handler.ts +515 -0
- package/src/persistence.ts +33 -0
- package/src/project-manager.ts +165 -0
- package/src/session-manager.ts +407 -0
- package/src/setup.ts +381 -0
- package/src/shell-handler.ts +91 -0
- package/src/types.ts +80 -0
- package/src/utils.ts +112 -0
- package/tsconfig.json +17 -0
package/src/setup.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, basename } from 'node:path';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const ENV_PATH = resolve(process.cwd(), '.env');
|
|
7
|
+
|
|
8
|
+
function color(text: string, code: number): string {
|
|
9
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
10
|
+
}
|
|
11
|
+
const dim = (t: string) => color(t, 2);
|
|
12
|
+
const cyan = (t: string) => color(t, 36);
|
|
13
|
+
const green = (t: string) => color(t, 32);
|
|
14
|
+
const yellow = (t: string) => color(t, 33);
|
|
15
|
+
const bold = (t: string) => color(t, 1);
|
|
16
|
+
|
|
17
|
+
function cancelled(): never {
|
|
18
|
+
p.cancel('Setup cancelled.');
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadExistingEnv(): Record<string, string> {
|
|
23
|
+
if (!existsSync(ENV_PATH)) return {};
|
|
24
|
+
const env: Record<string, string> = {};
|
|
25
|
+
for (const line of readFileSync(ENV_PATH, 'utf-8').split('\n')) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
28
|
+
const eq = trimmed.indexOf('=');
|
|
29
|
+
if (eq === -1) continue;
|
|
30
|
+
env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
31
|
+
}
|
|
32
|
+
return env;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeEnvFile(env: Record<string, string>): void {
|
|
36
|
+
const lines: string[] = [
|
|
37
|
+
'# agentcord configuration',
|
|
38
|
+
'# Generated by: npm run setup',
|
|
39
|
+
'',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const sections: Array<{ comment: string; keys: string[] }> = [
|
|
43
|
+
{ comment: '# Discord App', keys: ['DISCORD_TOKEN', 'DISCORD_CLIENT_ID', 'DISCORD_GUILD_ID'] },
|
|
44
|
+
{ comment: '# Security', keys: ['ALLOWED_USERS', 'ALLOW_ALL_USERS'] },
|
|
45
|
+
{ comment: '# Paths', keys: ['ALLOWED_PATHS', 'DEFAULT_DIRECTORY'] },
|
|
46
|
+
{ comment: '# Optional', keys: ['MESSAGE_RETENTION_DAYS', 'RATE_LIMIT_MS'] },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
for (const section of sections) {
|
|
50
|
+
const sectionEntries = section.keys.filter(k => env[k]);
|
|
51
|
+
if (sectionEntries.length === 0) continue;
|
|
52
|
+
lines.push(section.comment);
|
|
53
|
+
for (const key of sectionEntries) {
|
|
54
|
+
lines.push(`${key}=${env[key]}`);
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
writeFileSync(ENV_PATH, lines.join('\n'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getDiscordUserId(): string | null {
|
|
63
|
+
try {
|
|
64
|
+
// Try to get from existing .env
|
|
65
|
+
const env = loadExistingEnv();
|
|
66
|
+
if (env.ALLOWED_USERS) return env.ALLOWED_USERS.split(',')[0].trim();
|
|
67
|
+
} catch { /* ignore */ }
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runSetup(): Promise<void> {
|
|
72
|
+
const existing = loadExistingEnv();
|
|
73
|
+
const isReconfigure = Object.keys(existing).length > 0;
|
|
74
|
+
|
|
75
|
+
p.intro(bold(' agentcord setup '));
|
|
76
|
+
|
|
77
|
+
if (isReconfigure) {
|
|
78
|
+
p.note(
|
|
79
|
+
'Existing .env file detected.\nYour current values will be shown as defaults.',
|
|
80
|
+
'Reconfiguring',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Step 1: Discord App Creation Guide ───
|
|
85
|
+
|
|
86
|
+
const hasApp = await p.confirm({
|
|
87
|
+
message: 'Do you already have a Discord Application created?',
|
|
88
|
+
initialValue: !!existing.DISCORD_TOKEN,
|
|
89
|
+
});
|
|
90
|
+
if (p.isCancel(hasApp)) cancelled();
|
|
91
|
+
|
|
92
|
+
if (!hasApp) {
|
|
93
|
+
p.note(
|
|
94
|
+
[
|
|
95
|
+
`${bold('1.')} Go to ${cyan('https://discord.com/developers/applications')}`,
|
|
96
|
+
`${bold('2.')} Click ${green('"New Application"')} and give it a name`,
|
|
97
|
+
`${bold('3.')} Go to the ${bold('Bot')} tab on the left`,
|
|
98
|
+
`${bold('4.')} Click ${green('"Reset Token"')} and copy the token`,
|
|
99
|
+
`${bold('5.')} Under ${bold('Privileged Gateway Intents')}, enable:`,
|
|
100
|
+
` ${yellow('*')} Message Content Intent`,
|
|
101
|
+
` ${yellow('*')} Server Members Intent`,
|
|
102
|
+
`${bold('6.')} Go to the ${bold('General Information')} tab`,
|
|
103
|
+
`${bold('7.')} Copy the ${bold('Application ID')} (this is your Client ID)`,
|
|
104
|
+
'',
|
|
105
|
+
dim('Keep this tab open — you\'ll need the token and ID next.'),
|
|
106
|
+
].join('\n'),
|
|
107
|
+
'Create a Discord App',
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await p.confirm({
|
|
111
|
+
message: 'Ready to continue?',
|
|
112
|
+
initialValue: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Step 2: Bot Token ───
|
|
117
|
+
|
|
118
|
+
const token = await p.password({
|
|
119
|
+
message: 'Paste your Discord Bot Token:',
|
|
120
|
+
validate(value) {
|
|
121
|
+
if (!value || !value.trim()) return 'Token is required';
|
|
122
|
+
if (value.length < 50) return 'That doesn\'t look like a valid bot token';
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
if (p.isCancel(token)) cancelled();
|
|
126
|
+
|
|
127
|
+
// ─── Step 3: Client ID ───
|
|
128
|
+
|
|
129
|
+
const clientId = await p.text({
|
|
130
|
+
message: 'Paste your Application (Client) ID:',
|
|
131
|
+
placeholder: existing.DISCORD_CLIENT_ID || '123456789012345678',
|
|
132
|
+
initialValue: existing.DISCORD_CLIENT_ID,
|
|
133
|
+
validate(value) {
|
|
134
|
+
if (!value || !value.trim()) return 'Client ID is required';
|
|
135
|
+
if (!/^\d{17,20}$/.test(value.trim())) return 'Client ID should be a 17-20 digit number';
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
if (p.isCancel(clientId)) cancelled();
|
|
139
|
+
|
|
140
|
+
// ─── Step 4: Guild ID ───
|
|
141
|
+
|
|
142
|
+
const guildSetup = await p.confirm({
|
|
143
|
+
message: 'Do you want to register commands to a specific server? (instant, recommended for testing)',
|
|
144
|
+
initialValue: !!existing.DISCORD_GUILD_ID,
|
|
145
|
+
});
|
|
146
|
+
if (p.isCancel(guildSetup)) cancelled();
|
|
147
|
+
|
|
148
|
+
let guildId = '';
|
|
149
|
+
if (guildSetup) {
|
|
150
|
+
if (!hasApp) {
|
|
151
|
+
p.note(
|
|
152
|
+
[
|
|
153
|
+
`${bold('1.')} Open Discord and go to ${bold('User Settings > Advanced')}`,
|
|
154
|
+
`${bold('2.')} Enable ${green('"Developer Mode"')}`,
|
|
155
|
+
`${bold('3.')} Right-click your server name and click ${green('"Copy Server ID"')}`,
|
|
156
|
+
].join('\n'),
|
|
157
|
+
'How to get your Server ID',
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const guildInput = await p.text({
|
|
162
|
+
message: 'Paste your Server (Guild) ID:',
|
|
163
|
+
placeholder: existing.DISCORD_GUILD_ID || '123456789012345678',
|
|
164
|
+
initialValue: existing.DISCORD_GUILD_ID,
|
|
165
|
+
validate(value) {
|
|
166
|
+
if (!value || !value.trim()) return 'Guild ID is required';
|
|
167
|
+
if (!/^\d{17,20}$/.test(value.trim())) return 'Guild ID should be a 17-20 digit number';
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
if (p.isCancel(guildInput)) cancelled();
|
|
171
|
+
guildId = guildInput.trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Step 5: Allowed Users ───
|
|
175
|
+
|
|
176
|
+
const authMode = await p.select({
|
|
177
|
+
message: 'Who should be allowed to use the bot?',
|
|
178
|
+
options: [
|
|
179
|
+
{
|
|
180
|
+
value: 'whitelist',
|
|
181
|
+
label: 'Specific users (recommended)',
|
|
182
|
+
hint: 'comma-separated Discord user IDs',
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
value: 'all',
|
|
186
|
+
label: 'Everyone in the server',
|
|
187
|
+
hint: 'not recommended for shared servers',
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
initialValue: existing.ALLOW_ALL_USERS === 'true' ? 'all' : 'whitelist',
|
|
191
|
+
});
|
|
192
|
+
if (p.isCancel(authMode)) cancelled();
|
|
193
|
+
|
|
194
|
+
let allowedUsers = '';
|
|
195
|
+
if (authMode === 'whitelist') {
|
|
196
|
+
const existingUser = getDiscordUserId();
|
|
197
|
+
|
|
198
|
+
if (!hasApp && !existingUser) {
|
|
199
|
+
p.note(
|
|
200
|
+
[
|
|
201
|
+
`${bold('1.')} Open Discord with ${bold('Developer Mode')} enabled`,
|
|
202
|
+
`${bold('2.')} Click on your profile picture or username`,
|
|
203
|
+
`${bold('3.')} Click ${green('"Copy User ID"')}`,
|
|
204
|
+
'',
|
|
205
|
+
dim('You can add more users later by editing .env'),
|
|
206
|
+
].join('\n'),
|
|
207
|
+
'How to get your User ID',
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const usersInput = await p.text({
|
|
212
|
+
message: 'Enter allowed Discord User IDs (comma-separated):',
|
|
213
|
+
placeholder: '123456789012345678',
|
|
214
|
+
initialValue: existing.ALLOWED_USERS,
|
|
215
|
+
validate(value) {
|
|
216
|
+
if (!value || !value.trim()) return 'At least one user ID is required';
|
|
217
|
+
const ids = value.split(',').map(s => s.trim());
|
|
218
|
+
for (const id of ids) {
|
|
219
|
+
if (!/^\d{17,20}$/.test(id)) return `Invalid user ID: ${id}`;
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
if (p.isCancel(usersInput)) cancelled();
|
|
224
|
+
allowedUsers = usersInput.trim();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ─── Step 6: Paths ───
|
|
228
|
+
|
|
229
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '/';
|
|
230
|
+
|
|
231
|
+
const defaultDir = await p.text({
|
|
232
|
+
message: 'Default working directory for new sessions:',
|
|
233
|
+
placeholder: homeDir,
|
|
234
|
+
initialValue: existing.DEFAULT_DIRECTORY || homeDir,
|
|
235
|
+
validate(value) {
|
|
236
|
+
if (!value) return 'Directory is required';
|
|
237
|
+
const resolved = resolve(value.trim().replace(/^~/, homeDir));
|
|
238
|
+
if (!existsSync(resolved)) return `Directory does not exist: ${resolved}`;
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
if (p.isCancel(defaultDir)) cancelled();
|
|
242
|
+
const resolvedDefault = resolve(defaultDir.trim().replace(/^~/, homeDir));
|
|
243
|
+
|
|
244
|
+
const restrictPaths = await p.confirm({
|
|
245
|
+
message: 'Restrict which directories the bot can access?',
|
|
246
|
+
initialValue: !!existing.ALLOWED_PATHS,
|
|
247
|
+
});
|
|
248
|
+
if (p.isCancel(restrictPaths)) cancelled();
|
|
249
|
+
|
|
250
|
+
let allowedPaths = '';
|
|
251
|
+
if (restrictPaths) {
|
|
252
|
+
const pathsInput = await p.text({
|
|
253
|
+
message: 'Allowed directories (comma-separated):',
|
|
254
|
+
placeholder: `${homeDir}/Dev,${homeDir}/Projects`,
|
|
255
|
+
initialValue: existing.ALLOWED_PATHS || resolvedDefault,
|
|
256
|
+
validate(value) {
|
|
257
|
+
if (!value || !value.trim()) return 'At least one path is required';
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
if (p.isCancel(pathsInput)) cancelled();
|
|
261
|
+
allowedPaths = pathsInput.trim();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Step 7: Write .env ───
|
|
265
|
+
|
|
266
|
+
const env: Record<string, string> = {
|
|
267
|
+
DISCORD_TOKEN: token.trim(),
|
|
268
|
+
DISCORD_CLIENT_ID: clientId.trim(),
|
|
269
|
+
};
|
|
270
|
+
if (guildId) env.DISCORD_GUILD_ID = guildId;
|
|
271
|
+
if (authMode === 'all') {
|
|
272
|
+
env.ALLOW_ALL_USERS = 'true';
|
|
273
|
+
} else {
|
|
274
|
+
env.ALLOWED_USERS = allowedUsers;
|
|
275
|
+
}
|
|
276
|
+
env.DEFAULT_DIRECTORY = resolvedDefault;
|
|
277
|
+
if (allowedPaths) env.ALLOWED_PATHS = allowedPaths;
|
|
278
|
+
|
|
279
|
+
// Preserve optional settings from existing config
|
|
280
|
+
if (existing.MESSAGE_RETENTION_DAYS) env.MESSAGE_RETENTION_DAYS = existing.MESSAGE_RETENTION_DAYS;
|
|
281
|
+
if (existing.RATE_LIMIT_MS) env.RATE_LIMIT_MS = existing.RATE_LIMIT_MS;
|
|
282
|
+
|
|
283
|
+
const s = p.spinner();
|
|
284
|
+
s.start('Writing .env file');
|
|
285
|
+
writeEnvFile(env);
|
|
286
|
+
s.stop('Configuration saved to .env');
|
|
287
|
+
|
|
288
|
+
// ─── Step 8: Generate Invite URL ───
|
|
289
|
+
|
|
290
|
+
const permissions = 8; // Administrator (simplest for category/channel management)
|
|
291
|
+
const scopes = 'bot%20applications.commands';
|
|
292
|
+
const inviteUrl = `https://discord.com/oauth2/authorize?client_id=${clientId.trim()}&permissions=${permissions}&scope=${scopes}`;
|
|
293
|
+
|
|
294
|
+
p.note(
|
|
295
|
+
[
|
|
296
|
+
bold('Add the bot to your server:'),
|
|
297
|
+
'',
|
|
298
|
+
cyan(inviteUrl),
|
|
299
|
+
'',
|
|
300
|
+
dim('Open this URL in your browser and select your server.'),
|
|
301
|
+
].join('\n'),
|
|
302
|
+
'Invite Link',
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const openBrowser = await p.confirm({
|
|
306
|
+
message: 'Open the invite URL in your browser?',
|
|
307
|
+
initialValue: true,
|
|
308
|
+
});
|
|
309
|
+
if (p.isCancel(openBrowser)) cancelled();
|
|
310
|
+
|
|
311
|
+
if (openBrowser) {
|
|
312
|
+
try {
|
|
313
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
314
|
+
execSync(`${cmd} "${inviteUrl}"`, { stdio: 'ignore' });
|
|
315
|
+
} catch {
|
|
316
|
+
p.log.warn('Could not open browser. Please open the URL manually.');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Step 9: Verify Connection ───
|
|
321
|
+
|
|
322
|
+
const verify = await p.confirm({
|
|
323
|
+
message: 'Test the bot connection now?',
|
|
324
|
+
initialValue: true,
|
|
325
|
+
});
|
|
326
|
+
if (p.isCancel(verify)) cancelled();
|
|
327
|
+
|
|
328
|
+
if (verify) {
|
|
329
|
+
s.start('Connecting to Discord...');
|
|
330
|
+
try {
|
|
331
|
+
// Dynamic import to avoid loading discord.js at module level
|
|
332
|
+
const { Client, GatewayIntentBits } = await import('discord.js');
|
|
333
|
+
const client = new Client({
|
|
334
|
+
intents: [GatewayIntentBits.Guilds],
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const loginResult = await Promise.race([
|
|
338
|
+
new Promise<string>((res, rej) => {
|
|
339
|
+
client.once('ready', () => {
|
|
340
|
+
const name = client.user?.tag || 'Unknown';
|
|
341
|
+
const guildCount = client.guilds.cache.size;
|
|
342
|
+
client.destroy();
|
|
343
|
+
res(`${name} — connected to ${guildCount} server(s)`);
|
|
344
|
+
});
|
|
345
|
+
client.login(token.trim()).catch(rej);
|
|
346
|
+
}),
|
|
347
|
+
new Promise<never>((_, rej) =>
|
|
348
|
+
setTimeout(() => rej(new Error('Connection timed out after 15s')), 15000),
|
|
349
|
+
),
|
|
350
|
+
]);
|
|
351
|
+
|
|
352
|
+
s.stop(green(`Connected: ${loginResult}`));
|
|
353
|
+
} catch (err: unknown) {
|
|
354
|
+
s.stop(`Connection failed: ${(err as Error).message}`);
|
|
355
|
+
p.log.warn('Double-check your bot token and try again.');
|
|
356
|
+
p.log.info(`You can re-run setup with: ${cyan('npm run setup')}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Done ───
|
|
361
|
+
|
|
362
|
+
p.note(
|
|
363
|
+
[
|
|
364
|
+
`Start the bot: ${cyan('npm start')}`,
|
|
365
|
+
`Dev mode: ${cyan('npm run dev')}`,
|
|
366
|
+
`Re-run setup: ${cyan('npm run setup')}`,
|
|
367
|
+
'',
|
|
368
|
+
`Once running, use ${bold('/claude new <name> <directory>')} in Discord`,
|
|
369
|
+
`to create your first session.`,
|
|
370
|
+
].join('\n'),
|
|
371
|
+
'Next Steps',
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
p.outro(green('Setup complete!'));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Run if called directly
|
|
378
|
+
runSetup().catch(err => {
|
|
379
|
+
console.error('Setup failed:', err);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import type { TextChannel } from 'discord.js';
|
|
3
|
+
import type { ShellProcess } from './types.ts';
|
|
4
|
+
import { truncate } from './utils.ts';
|
|
5
|
+
|
|
6
|
+
const runningProcesses = new Map<number, ShellProcess>();
|
|
7
|
+
let pidCounter = 0;
|
|
8
|
+
|
|
9
|
+
const TIMEOUT_MS = 60_000;
|
|
10
|
+
const EDIT_DEBOUNCE = 500;
|
|
11
|
+
|
|
12
|
+
export async function executeShellCommand(
|
|
13
|
+
command: string,
|
|
14
|
+
cwd: string,
|
|
15
|
+
channel: TextChannel,
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const pid = ++pidCounter;
|
|
18
|
+
const child = spawn('bash', ['-c', command], {
|
|
19
|
+
cwd,
|
|
20
|
+
env: process.env,
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const shellProcess: ShellProcess = {
|
|
25
|
+
pid,
|
|
26
|
+
command,
|
|
27
|
+
startedAt: Date.now(),
|
|
28
|
+
process: child,
|
|
29
|
+
};
|
|
30
|
+
runningProcesses.set(pid, shellProcess);
|
|
31
|
+
|
|
32
|
+
let output = '';
|
|
33
|
+
let message = await channel.send(`\`\`\`\n$ ${command}\n\`\`\``);
|
|
34
|
+
let lastEdit = Date.now();
|
|
35
|
+
let editTimer: ReturnType<typeof setTimeout> | null = null;
|
|
36
|
+
|
|
37
|
+
const updateMessage = async () => {
|
|
38
|
+
const display = truncate(output, 1900);
|
|
39
|
+
try {
|
|
40
|
+
await message.edit(`\`\`\`\n$ ${command}\n${display}\n\`\`\``);
|
|
41
|
+
} catch { /* message deleted */ }
|
|
42
|
+
lastEdit = Date.now();
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const scheduleEdit = () => {
|
|
46
|
+
if (editTimer) return;
|
|
47
|
+
const delay = Math.max(0, EDIT_DEBOUNCE - (Date.now() - lastEdit));
|
|
48
|
+
editTimer = setTimeout(async () => {
|
|
49
|
+
editTimer = null;
|
|
50
|
+
await updateMessage();
|
|
51
|
+
}, delay);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const onData = (chunk: Buffer) => {
|
|
55
|
+
output += chunk.toString();
|
|
56
|
+
scheduleEdit();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
child.stdout?.on('data', onData);
|
|
60
|
+
child.stderr?.on('data', onData);
|
|
61
|
+
|
|
62
|
+
// Timeout
|
|
63
|
+
const timeout = setTimeout(() => {
|
|
64
|
+
child.kill('SIGTERM');
|
|
65
|
+
output += '\n[Timed out after 60s]';
|
|
66
|
+
}, TIMEOUT_MS);
|
|
67
|
+
|
|
68
|
+
return new Promise<void>(resolve => {
|
|
69
|
+
child.on('close', async (code) => {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
if (editTimer) clearTimeout(editTimer);
|
|
72
|
+
runningProcesses.delete(pid);
|
|
73
|
+
|
|
74
|
+
output += `\n[Exit code: ${code ?? 'killed'}]`;
|
|
75
|
+
await updateMessage();
|
|
76
|
+
resolve();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function listProcesses(): ShellProcess[] {
|
|
82
|
+
return Array.from(runningProcesses.values());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function killProcess(pid: number): boolean {
|
|
86
|
+
const proc = runningProcesses.get(pid);
|
|
87
|
+
if (!proc) return false;
|
|
88
|
+
proc.process.kill('SIGTERM');
|
|
89
|
+
runningProcesses.delete(pid);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export interface McpServer {
|
|
2
|
+
name: string;
|
|
3
|
+
command: string;
|
|
4
|
+
args?: string[];
|
|
5
|
+
env?: Record<string, string>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Project {
|
|
9
|
+
name: string;
|
|
10
|
+
directory: string;
|
|
11
|
+
categoryId: string;
|
|
12
|
+
logChannelId?: string;
|
|
13
|
+
personality?: string;
|
|
14
|
+
skills: Record<string, string>;
|
|
15
|
+
mcpServers: McpServer[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Session {
|
|
19
|
+
id: string;
|
|
20
|
+
channelId: string;
|
|
21
|
+
directory: string;
|
|
22
|
+
projectName: string;
|
|
23
|
+
tmuxName: string;
|
|
24
|
+
claudeSessionId?: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
agentPersona?: string;
|
|
27
|
+
verbose: boolean;
|
|
28
|
+
isGenerating: boolean;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
lastActivity: number;
|
|
31
|
+
messageCount: number;
|
|
32
|
+
totalCost: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SessionPersistData {
|
|
36
|
+
id: string;
|
|
37
|
+
channelId: string;
|
|
38
|
+
directory: string;
|
|
39
|
+
projectName: string;
|
|
40
|
+
tmuxName: string;
|
|
41
|
+
claudeSessionId?: string;
|
|
42
|
+
model?: string;
|
|
43
|
+
agentPersona?: string;
|
|
44
|
+
verbose?: boolean;
|
|
45
|
+
createdAt: number;
|
|
46
|
+
lastActivity: number;
|
|
47
|
+
messageCount: number;
|
|
48
|
+
totalCost: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AgentPersona {
|
|
52
|
+
name: string;
|
|
53
|
+
description: string;
|
|
54
|
+
systemPrompt: string;
|
|
55
|
+
emoji: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ShellProcess {
|
|
59
|
+
pid: number;
|
|
60
|
+
command: string;
|
|
61
|
+
startedAt: number;
|
|
62
|
+
process: import('node:child_process').ChildProcess;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Config {
|
|
66
|
+
token: string;
|
|
67
|
+
clientId: string;
|
|
68
|
+
guildId: string | null;
|
|
69
|
+
allowedUsers: string[];
|
|
70
|
+
allowAllUsers: boolean;
|
|
71
|
+
allowedPaths: string[];
|
|
72
|
+
defaultDirectory: string;
|
|
73
|
+
messageRetentionDays: number | null;
|
|
74
|
+
rateLimitMs: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ExpandableContent {
|
|
78
|
+
content: string;
|
|
79
|
+
createdAt: number;
|
|
80
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
|
|
4
|
+
export function sanitizeSessionName(name: string): string {
|
|
5
|
+
return name
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
8
|
+
.replace(/-+/g, '-')
|
|
9
|
+
.replace(/^-|-$/g, '')
|
|
10
|
+
.slice(0, 50);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function formatUptime(startTime: number): string {
|
|
14
|
+
const ms = Date.now() - startTime;
|
|
15
|
+
const seconds = Math.floor(ms / 1000);
|
|
16
|
+
const minutes = Math.floor(seconds / 60);
|
|
17
|
+
const hours = Math.floor(minutes / 60);
|
|
18
|
+
const days = Math.floor(hours / 24);
|
|
19
|
+
|
|
20
|
+
if (days > 0) return `${days}d ${hours % 24}h`;
|
|
21
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
22
|
+
if (minutes > 0) return `${minutes}m`;
|
|
23
|
+
return `${seconds}s`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatLastActivity(timestamp: number): string {
|
|
27
|
+
const ms = Date.now() - timestamp;
|
|
28
|
+
const seconds = Math.floor(ms / 1000);
|
|
29
|
+
const minutes = Math.floor(seconds / 60);
|
|
30
|
+
const hours = Math.floor(minutes / 60);
|
|
31
|
+
const days = Math.floor(hours / 24);
|
|
32
|
+
|
|
33
|
+
if (days > 0) return `${days}d ago`;
|
|
34
|
+
if (hours > 0) return `${hours}h ago`;
|
|
35
|
+
if (minutes > 0) return `${minutes}m ago`;
|
|
36
|
+
return 'just now';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isPathAllowed(targetPath: string, allowedPaths: string[]): boolean {
|
|
40
|
+
if (allowedPaths.length === 0) return true;
|
|
41
|
+
const resolved = resolve(targetPath);
|
|
42
|
+
return allowedPaths.some(allowed => {
|
|
43
|
+
const resolvedAllowed = resolve(allowed.startsWith('~') ? allowed.replace('~', homedir()) : allowed);
|
|
44
|
+
return resolved === resolvedAllowed || resolved.startsWith(resolvedAllowed + '/');
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolvePath(dir: string): string {
|
|
49
|
+
const expanded = dir.startsWith('~') ? dir.replace('~', homedir()) : dir;
|
|
50
|
+
return resolve(expanded);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function truncate(text: string, maxLen: number): string {
|
|
54
|
+
if (text.length <= maxLen) return text;
|
|
55
|
+
return text.slice(0, maxLen - 3) + '...';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function splitMessage(text: string, maxLen: number = 1900): string[] {
|
|
59
|
+
if (text.length <= maxLen) return [text];
|
|
60
|
+
|
|
61
|
+
const chunks: string[] = [];
|
|
62
|
+
let remaining = text;
|
|
63
|
+
|
|
64
|
+
while (remaining.length > 0) {
|
|
65
|
+
if (remaining.length <= maxLen) {
|
|
66
|
+
chunks.push(remaining);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let splitAt = remaining.lastIndexOf('\n', maxLen);
|
|
71
|
+
if (splitAt === -1 || splitAt < maxLen / 2) {
|
|
72
|
+
splitAt = maxLen;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
76
|
+
remaining = remaining.slice(splitAt).replace(/^\n/, '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return chunks;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isUserAllowed(userId: string, allowedUsers: string[], allowAll: boolean): boolean {
|
|
83
|
+
if (allowAll) return true;
|
|
84
|
+
return allowedUsers.includes(userId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function projectNameFromDir(directory: string): string {
|
|
88
|
+
const resolved = resolvePath(directory);
|
|
89
|
+
const basename = resolved.split('/').pop() || 'unknown';
|
|
90
|
+
return sanitizeSessionName(basename);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function detectNumberedOptions(text: string): string[] | null {
|
|
94
|
+
const lines = text.trim().split('\n');
|
|
95
|
+
const options: string[] = [];
|
|
96
|
+
const optionRegex = /^\s*(\d+)[.)]\s+(.+)$/;
|
|
97
|
+
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
const match = line.match(optionRegex);
|
|
100
|
+
if (match) {
|
|
101
|
+
options.push(match[2].trim());
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return options.length >= 2 ? options : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function detectYesNoPrompt(text: string): boolean {
|
|
109
|
+
const lower = text.toLowerCase();
|
|
110
|
+
return /\b(y\/n|yes\/no|confirm|proceed)\b/.test(lower) ||
|
|
111
|
+
/\?\s*$/.test(text.trim()) && /\b(should|would you|do you want|shall)\b/.test(lower);
|
|
112
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2024",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"verbatimModuleSyntax": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*"],
|
|
16
|
+
"exclude": ["node_modules", "dist", "test"]
|
|
17
|
+
}
|