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
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { query, type SDKMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
4
|
+
import { Store } from './persistence.ts';
|
|
5
|
+
import { getAgent } from './agents.ts';
|
|
6
|
+
import { getPersonality } from './project-manager.ts';
|
|
7
|
+
import { sanitizeSessionName, resolvePath, isPathAllowed } from './utils.ts';
|
|
8
|
+
import type { Session, SessionPersistData } from './types.ts';
|
|
9
|
+
import { config } from './config.ts';
|
|
10
|
+
|
|
11
|
+
const SESSION_PREFIX = 'claude-';
|
|
12
|
+
const sessionStore = new Store<SessionPersistData[]>('sessions.json');
|
|
13
|
+
|
|
14
|
+
const sessions = new Map<string, Session>();
|
|
15
|
+
const channelToSession = new Map<string, string>();
|
|
16
|
+
|
|
17
|
+
// Async tmux helper — never blocks the event loop
|
|
18
|
+
function tmux(...args: string[]): Promise<string> {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
execFile('tmux', args, { encoding: 'utf-8' }, (err, stdout) => {
|
|
21
|
+
if (err) reject(err);
|
|
22
|
+
else resolve(stdout);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function tmuxSessionExists(tmuxName: string): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
await tmux('has-session', '-t', tmuxName);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Persistence
|
|
37
|
+
|
|
38
|
+
export async function loadSessions(): Promise<void> {
|
|
39
|
+
const data = await sessionStore.read();
|
|
40
|
+
if (!data) return;
|
|
41
|
+
|
|
42
|
+
for (const s of data) {
|
|
43
|
+
const exists = await tmuxSessionExists(s.tmuxName);
|
|
44
|
+
sessions.set(s.id, {
|
|
45
|
+
...s,
|
|
46
|
+
verbose: s.verbose ?? false,
|
|
47
|
+
isGenerating: false,
|
|
48
|
+
});
|
|
49
|
+
channelToSession.set(s.channelId, s.id);
|
|
50
|
+
|
|
51
|
+
// If tmux session is gone, recreate it
|
|
52
|
+
if (!exists) {
|
|
53
|
+
try {
|
|
54
|
+
await tmux('new-session', '-d', '-s', s.tmuxName, '-c', s.directory);
|
|
55
|
+
} catch {
|
|
56
|
+
console.warn(`Could not recreate tmux session ${s.tmuxName}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(`Restored ${sessions.size} session(s)`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function saveSessions(): Promise<void> {
|
|
65
|
+
const data: SessionPersistData[] = [];
|
|
66
|
+
for (const [, s] of sessions) {
|
|
67
|
+
data.push({
|
|
68
|
+
id: s.id,
|
|
69
|
+
channelId: s.channelId,
|
|
70
|
+
directory: s.directory,
|
|
71
|
+
projectName: s.projectName,
|
|
72
|
+
tmuxName: s.tmuxName,
|
|
73
|
+
claudeSessionId: s.claudeSessionId,
|
|
74
|
+
model: s.model,
|
|
75
|
+
agentPersona: s.agentPersona,
|
|
76
|
+
verbose: s.verbose || undefined,
|
|
77
|
+
createdAt: s.createdAt,
|
|
78
|
+
lastActivity: s.lastActivity,
|
|
79
|
+
messageCount: s.messageCount,
|
|
80
|
+
totalCost: s.totalCost,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
await sessionStore.write(data);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Session CRUD
|
|
87
|
+
|
|
88
|
+
export async function createSession(
|
|
89
|
+
name: string,
|
|
90
|
+
directory: string,
|
|
91
|
+
channelId: string,
|
|
92
|
+
projectName: string,
|
|
93
|
+
): Promise<Session> {
|
|
94
|
+
const resolvedDir = resolvePath(directory);
|
|
95
|
+
|
|
96
|
+
if (!isPathAllowed(resolvedDir, config.allowedPaths)) {
|
|
97
|
+
throw new Error(`Directory not in allowed paths: ${resolvedDir}`);
|
|
98
|
+
}
|
|
99
|
+
if (!existsSync(resolvedDir)) {
|
|
100
|
+
throw new Error(`Directory does not exist: ${resolvedDir}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Auto-deduplicate: append -2, -3, etc. if name is taken
|
|
104
|
+
let id = sanitizeSessionName(name);
|
|
105
|
+
let tmuxName = `${SESSION_PREFIX}${id}`;
|
|
106
|
+
let suffix = 1;
|
|
107
|
+
while (sessions.has(id) || await tmuxSessionExists(tmuxName)) {
|
|
108
|
+
suffix++;
|
|
109
|
+
id = sanitizeSessionName(`${name}-${suffix}`);
|
|
110
|
+
tmuxName = `${SESSION_PREFIX}${id}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Create tmux session with a shell in the directory
|
|
114
|
+
await tmux('new-session', '-d', '-s', tmuxName, '-c', resolvedDir);
|
|
115
|
+
|
|
116
|
+
const session: Session = {
|
|
117
|
+
id,
|
|
118
|
+
channelId,
|
|
119
|
+
directory: resolvedDir,
|
|
120
|
+
projectName,
|
|
121
|
+
tmuxName,
|
|
122
|
+
verbose: false,
|
|
123
|
+
isGenerating: false,
|
|
124
|
+
createdAt: Date.now(),
|
|
125
|
+
lastActivity: Date.now(),
|
|
126
|
+
messageCount: 0,
|
|
127
|
+
totalCost: 0,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
sessions.set(id, session);
|
|
131
|
+
channelToSession.set(channelId, id);
|
|
132
|
+
await saveSessions();
|
|
133
|
+
|
|
134
|
+
return session;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function getSession(id: string): Session | undefined {
|
|
138
|
+
return sessions.get(id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getSessionByChannel(channelId: string): Session | undefined {
|
|
142
|
+
const id = channelToSession.get(channelId);
|
|
143
|
+
return id ? sessions.get(id) : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function getAllSessions(): Session[] {
|
|
147
|
+
return Array.from(sessions.values());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function endSession(id: string): Promise<void> {
|
|
151
|
+
const session = sessions.get(id);
|
|
152
|
+
if (!session) throw new Error(`Session "${id}" not found`);
|
|
153
|
+
|
|
154
|
+
// Abort if generating
|
|
155
|
+
if (session.isGenerating && (session as any)._controller) {
|
|
156
|
+
(session as any)._controller.abort();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Kill tmux
|
|
160
|
+
try {
|
|
161
|
+
await tmux('kill-session', '-t', session.tmuxName);
|
|
162
|
+
} catch {
|
|
163
|
+
// Already dead
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
channelToSession.delete(session.channelId);
|
|
167
|
+
sessions.delete(id);
|
|
168
|
+
await saveSessions();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function linkChannel(sessionId: string, channelId: string): void {
|
|
172
|
+
const session = sessions.get(sessionId);
|
|
173
|
+
if (session) {
|
|
174
|
+
channelToSession.delete(session.channelId);
|
|
175
|
+
session.channelId = channelId;
|
|
176
|
+
channelToSession.set(channelId, sessionId);
|
|
177
|
+
saveSessions();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function unlinkChannel(channelId: string): void {
|
|
182
|
+
const sessionId = channelToSession.get(channelId);
|
|
183
|
+
if (sessionId) {
|
|
184
|
+
channelToSession.delete(channelId);
|
|
185
|
+
const session = sessions.get(sessionId);
|
|
186
|
+
if (session) {
|
|
187
|
+
sessions.delete(sessionId);
|
|
188
|
+
}
|
|
189
|
+
saveSessions();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Model management
|
|
194
|
+
|
|
195
|
+
export function setModel(sessionId: string, model: string): void {
|
|
196
|
+
const session = sessions.get(sessionId);
|
|
197
|
+
if (session) {
|
|
198
|
+
session.model = model;
|
|
199
|
+
saveSessions();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Agent persona management
|
|
204
|
+
|
|
205
|
+
export function setVerbose(sessionId: string, verbose: boolean): void {
|
|
206
|
+
const session = sessions.get(sessionId);
|
|
207
|
+
if (session) {
|
|
208
|
+
session.verbose = verbose;
|
|
209
|
+
saveSessions();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function setAgentPersona(sessionId: string, persona: string | undefined): void {
|
|
214
|
+
const session = sessions.get(sessionId);
|
|
215
|
+
if (session) {
|
|
216
|
+
session.agentPersona = persona;
|
|
217
|
+
saveSessions();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Build system prompt from project personality + agent persona
|
|
222
|
+
|
|
223
|
+
function buildSystemPrompt(session: Session): string | { type: 'preset'; preset: 'claude_code'; append?: string } {
|
|
224
|
+
const parts: string[] = [];
|
|
225
|
+
|
|
226
|
+
const personality = getPersonality(session.projectName);
|
|
227
|
+
if (personality) parts.push(personality);
|
|
228
|
+
|
|
229
|
+
if (session.agentPersona) {
|
|
230
|
+
const agent = getAgent(session.agentPersona);
|
|
231
|
+
if (agent?.systemPrompt) parts.push(agent.systemPrompt);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Use Claude Code's default system prompt and append our customizations
|
|
235
|
+
if (parts.length > 0) {
|
|
236
|
+
return { type: 'preset', preset: 'claude_code', append: parts.join('\n\n') };
|
|
237
|
+
}
|
|
238
|
+
return { type: 'preset', preset: 'claude_code' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Claude Code SDK interaction
|
|
242
|
+
|
|
243
|
+
export async function* sendPrompt(
|
|
244
|
+
sessionId: string,
|
|
245
|
+
prompt: string,
|
|
246
|
+
): AsyncGenerator<SDKMessage> {
|
|
247
|
+
const session = sessions.get(sessionId);
|
|
248
|
+
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
249
|
+
if (session.isGenerating) throw new Error('Session is already generating');
|
|
250
|
+
|
|
251
|
+
const controller = new AbortController();
|
|
252
|
+
(session as any)._controller = controller;
|
|
253
|
+
session.isGenerating = true;
|
|
254
|
+
session.lastActivity = Date.now();
|
|
255
|
+
|
|
256
|
+
const systemPrompt = buildSystemPrompt(session);
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const stream = query({
|
|
260
|
+
prompt,
|
|
261
|
+
options: {
|
|
262
|
+
cwd: session.directory,
|
|
263
|
+
resume: session.claudeSessionId,
|
|
264
|
+
abortController: controller,
|
|
265
|
+
permissionMode: 'bypassPermissions',
|
|
266
|
+
allowDangerouslySkipPermissions: true,
|
|
267
|
+
model: session.model,
|
|
268
|
+
systemPrompt: systemPrompt,
|
|
269
|
+
includePartialMessages: true,
|
|
270
|
+
settingSources: ['user', 'project', 'local'],
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
for await (const message of stream) {
|
|
275
|
+
// Capture session ID from init message
|
|
276
|
+
if (message.type === 'system' && 'subtype' in message && message.subtype === 'init') {
|
|
277
|
+
session.claudeSessionId = message.session_id;
|
|
278
|
+
await saveSessions();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Track cost from result
|
|
282
|
+
if (message.type === 'result') {
|
|
283
|
+
if ('total_cost_usd' in message) {
|
|
284
|
+
session.totalCost += message.total_cost_usd;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
yield message;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
session.messageCount++;
|
|
292
|
+
} catch (err: unknown) {
|
|
293
|
+
if ((err as Error).name === 'AbortError') {
|
|
294
|
+
// User cancelled — that's fine
|
|
295
|
+
} else {
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
session.isGenerating = false;
|
|
300
|
+
session.lastActivity = Date.now();
|
|
301
|
+
delete (session as any)._controller;
|
|
302
|
+
await saveSessions();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function* continueSession(
|
|
307
|
+
sessionId: string,
|
|
308
|
+
): AsyncGenerator<SDKMessage> {
|
|
309
|
+
const session = sessions.get(sessionId);
|
|
310
|
+
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
311
|
+
if (session.isGenerating) throw new Error('Session is already generating');
|
|
312
|
+
|
|
313
|
+
const controller = new AbortController();
|
|
314
|
+
(session as any)._controller = controller;
|
|
315
|
+
session.isGenerating = true;
|
|
316
|
+
session.lastActivity = Date.now();
|
|
317
|
+
|
|
318
|
+
const systemPrompt = buildSystemPrompt(session);
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
const stream = query({
|
|
322
|
+
prompt: '',
|
|
323
|
+
options: {
|
|
324
|
+
cwd: session.directory,
|
|
325
|
+
continue: true,
|
|
326
|
+
resume: session.claudeSessionId,
|
|
327
|
+
abortController: controller,
|
|
328
|
+
permissionMode: 'bypassPermissions',
|
|
329
|
+
allowDangerouslySkipPermissions: true,
|
|
330
|
+
model: session.model,
|
|
331
|
+
systemPrompt: systemPrompt,
|
|
332
|
+
includePartialMessages: true,
|
|
333
|
+
settingSources: ['user', 'project', 'local'],
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
for await (const message of stream) {
|
|
338
|
+
if (message.type === 'system' && 'subtype' in message && message.subtype === 'init') {
|
|
339
|
+
session.claudeSessionId = message.session_id;
|
|
340
|
+
await saveSessions();
|
|
341
|
+
}
|
|
342
|
+
if (message.type === 'result' && 'total_cost_usd' in message) {
|
|
343
|
+
session.totalCost += message.total_cost_usd;
|
|
344
|
+
}
|
|
345
|
+
yield message;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
session.messageCount++;
|
|
349
|
+
} catch (err: unknown) {
|
|
350
|
+
if ((err as Error).name === 'AbortError') {
|
|
351
|
+
// cancelled
|
|
352
|
+
} else {
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
} finally {
|
|
356
|
+
session.isGenerating = false;
|
|
357
|
+
session.lastActivity = Date.now();
|
|
358
|
+
delete (session as any)._controller;
|
|
359
|
+
await saveSessions();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function abortSession(sessionId: string): boolean {
|
|
364
|
+
const session = sessions.get(sessionId);
|
|
365
|
+
if (!session) return false;
|
|
366
|
+
const controller = (session as any)._controller as AbortController | undefined;
|
|
367
|
+
if (controller) {
|
|
368
|
+
controller.abort();
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Tmux info for /claude attach
|
|
375
|
+
|
|
376
|
+
export function getAttachInfo(sessionId: string): { command: string; sessionId?: string } | null {
|
|
377
|
+
const session = sessions.get(sessionId);
|
|
378
|
+
if (!session) return null;
|
|
379
|
+
return {
|
|
380
|
+
command: `tmux attach -t ${session.tmuxName}`,
|
|
381
|
+
sessionId: session.claudeSessionId,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// List tmux sessions for sync
|
|
386
|
+
|
|
387
|
+
export async function listTmuxSessions(): Promise<Array<{ id: string; tmuxName: string; directory: string }>> {
|
|
388
|
+
try {
|
|
389
|
+
const output = await tmux(
|
|
390
|
+
'list-sessions', '-F', '#{session_name}|#{pane_current_path}',
|
|
391
|
+
);
|
|
392
|
+
return output
|
|
393
|
+
.trim()
|
|
394
|
+
.split('\n')
|
|
395
|
+
.filter(line => line.startsWith(SESSION_PREFIX))
|
|
396
|
+
.map(line => {
|
|
397
|
+
const [name, path] = line.split('|');
|
|
398
|
+
return {
|
|
399
|
+
id: name.replace(SESSION_PREFIX, ''),
|
|
400
|
+
tmuxName: name,
|
|
401
|
+
directory: path || 'unknown',
|
|
402
|
+
};
|
|
403
|
+
});
|
|
404
|
+
} catch {
|
|
405
|
+
return [];
|
|
406
|
+
}
|
|
407
|
+
}
|