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.
@@ -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
+ }