afk-code 0.1.0 → 0.1.1

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.
@@ -1,567 +0,0 @@
1
- /**
2
- * Session manager for Slack bot - handles JSONL watching and Unix socket communication
3
- * This replaces the need for the daemon + relay.
4
- */
5
-
6
- import { watch, type FSWatcher } from 'fs';
7
- import { readdir } from 'fs/promises';
8
- import type { Socket } from 'bun';
9
- import type { TodoItem } from '../types';
10
-
11
- const DAEMON_SOCKET = '/tmp/afk-code-daemon.sock';
12
-
13
- export interface SessionInfo {
14
- id: string;
15
- name: string;
16
- cwd: string;
17
- projectDir: string;
18
- status: 'running' | 'idle' | 'ended';
19
- startedAt: Date;
20
- }
21
-
22
- interface InternalSession extends SessionInfo {
23
- socket: Socket<unknown>;
24
- watcher?: FSWatcher;
25
- watchedFile?: string;
26
- seenMessages: Set<string>;
27
- slugFound: boolean;
28
- lastTodosHash: string;
29
- inPlanMode: boolean;
30
- initialFileStats: Map<string, number>; // path -> mtime at session start
31
- }
32
-
33
- export interface ChatMessage {
34
- role: 'user' | 'assistant';
35
- content: string;
36
- timestamp: string;
37
- }
38
-
39
- export interface ToolCallInfo {
40
- id: string;
41
- name: string;
42
- input: any;
43
- }
44
-
45
- export interface ToolResultInfo {
46
- toolUseId: string;
47
- content: string;
48
- isError: boolean;
49
- }
50
-
51
- export interface SessionEvents {
52
- onSessionStart: (session: SessionInfo) => void;
53
- onSessionEnd: (sessionId: string) => void;
54
- onSessionUpdate: (sessionId: string, name: string) => void;
55
- onSessionStatus: (sessionId: string, status: 'running' | 'idle' | 'ended') => void;
56
- onMessage: (sessionId: string, role: 'user' | 'assistant', content: string) => void;
57
- onTodos: (sessionId: string, todos: TodoItem[]) => void;
58
- onToolCall: (sessionId: string, tool: ToolCallInfo) => void;
59
- onToolResult: (sessionId: string, result: ToolResultInfo) => void;
60
- onPlanModeChange: (sessionId: string, inPlanMode: boolean) => void;
61
- }
62
-
63
- export class SessionManager {
64
- private sessions = new Map<string, InternalSession>();
65
- private claimedFiles = new Set<string>();
66
- private events: SessionEvents;
67
- private server: ReturnType<typeof Bun.listen> | null = null;
68
-
69
- constructor(events: SessionEvents) {
70
- this.events = events;
71
- }
72
-
73
- async start(): Promise<void> {
74
- // Remove old socket file
75
- try {
76
- await Bun.$`rm -f ${DAEMON_SOCKET}`.quiet();
77
- } catch {}
78
-
79
- // Start Unix socket server
80
- this.server = Bun.listen({
81
- unix: DAEMON_SOCKET,
82
- socket: {
83
- data: (socket, data) => {
84
- const messages = data.toString().split('\n').filter(Boolean);
85
- for (const msg of messages) {
86
- try {
87
- const parsed = JSON.parse(msg);
88
- this.handleSessionMessage(socket, parsed);
89
- } catch (error) {
90
- console.error('[SessionManager] Error parsing message:', error);
91
- }
92
- }
93
- },
94
- error: (socket, error) => {
95
- console.error('[SessionManager] Socket error:', error);
96
- },
97
- close: (socket) => {
98
- // Find and cleanup session for this socket
99
- for (const [id, session] of this.sessions) {
100
- if (session.socket === socket) {
101
- console.log(`[SessionManager] Session disconnected: ${id}`);
102
- this.stopWatching(session);
103
- this.sessions.delete(id);
104
- this.events.onSessionEnd(id);
105
- break;
106
- }
107
- }
108
- },
109
- },
110
- });
111
-
112
- console.log(`[SessionManager] Listening on ${DAEMON_SOCKET}`);
113
- }
114
-
115
- stop(): void {
116
- for (const session of this.sessions.values()) {
117
- this.stopWatching(session);
118
- }
119
- this.sessions.clear();
120
- // Note: Bun.listen doesn't have a close method, socket will close on process exit
121
- }
122
-
123
- sendInput(sessionId: string, text: string): boolean {
124
- const session = this.sessions.get(sessionId);
125
- if (!session) {
126
- console.error(`[SessionManager] Session not found: ${sessionId}`);
127
- return false;
128
- }
129
-
130
- // Send text first, then Enter
131
- try {
132
- session.socket.write(JSON.stringify({ type: 'input', text }) + '\n');
133
- } catch (err) {
134
- console.error(`[SessionManager] Failed to send input to ${sessionId}:`, err);
135
- // Socket is dead, clean up
136
- this.stopWatching(session);
137
- this.sessions.delete(sessionId);
138
- this.events.onSessionEnd(sessionId);
139
- return false;
140
- }
141
-
142
- setTimeout(() => {
143
- try {
144
- session.socket.write(JSON.stringify({ type: 'input', text: '\r' }) + '\n');
145
- } catch {
146
- // Session likely already cleaned up from the first write failure
147
- }
148
- }, 50);
149
-
150
- return true;
151
- }
152
-
153
- getSession(sessionId: string): SessionInfo | undefined {
154
- const session = this.sessions.get(sessionId);
155
- if (!session) return undefined;
156
- return {
157
- id: session.id,
158
- name: session.name,
159
- cwd: session.cwd,
160
- projectDir: session.projectDir,
161
- status: session.status,
162
- startedAt: session.startedAt,
163
- };
164
- }
165
-
166
- getAllSessions(): SessionInfo[] {
167
- return Array.from(this.sessions.values()).map((s) => ({
168
- id: s.id,
169
- name: s.name,
170
- cwd: s.cwd,
171
- projectDir: s.projectDir,
172
- status: s.status,
173
- startedAt: s.startedAt,
174
- }));
175
- }
176
-
177
- private async handleSessionMessage(socket: Socket<unknown>, message: any): Promise<void> {
178
- switch (message.type) {
179
- case 'session_start': {
180
- // Snapshot existing JSONL files before creating session
181
- const initialFileStats = await this.snapshotJsonlFiles(message.projectDir);
182
-
183
- const session: InternalSession = {
184
- id: message.id,
185
- name: message.name || message.command?.join(' ') || 'Session',
186
- cwd: message.cwd,
187
- projectDir: message.projectDir,
188
- socket,
189
- status: 'running',
190
- seenMessages: new Set(),
191
- startedAt: new Date(),
192
- slugFound: false,
193
- lastTodosHash: '',
194
- inPlanMode: false,
195
- initialFileStats,
196
- };
197
-
198
- this.sessions.set(message.id, session);
199
- console.log(`[SessionManager] Session started: ${message.id} - ${session.name}`);
200
- console.log(`[SessionManager] Snapshot: ${initialFileStats.size} existing JSONL files`);
201
-
202
- this.events.onSessionStart({
203
- id: session.id,
204
- name: session.name,
205
- cwd: session.cwd,
206
- projectDir: session.projectDir,
207
- status: session.status,
208
- startedAt: session.startedAt,
209
- });
210
-
211
- this.startWatching(session);
212
- break;
213
- }
214
-
215
- case 'session_end': {
216
- const session = this.sessions.get(message.sessionId);
217
- if (session) {
218
- console.log(`[SessionManager] Session ended: ${message.sessionId}`);
219
- this.stopWatching(session);
220
- this.sessions.delete(message.sessionId);
221
- this.events.onSessionEnd(message.sessionId);
222
- }
223
- break;
224
- }
225
- }
226
- }
227
-
228
- private async snapshotJsonlFiles(projectDir: string): Promise<Map<string, number>> {
229
- const stats = new Map<string, number>();
230
- try {
231
- const files = await readdir(projectDir);
232
- for (const f of files) {
233
- if (f.endsWith('.jsonl') && !f.startsWith('agent-')) {
234
- const path = `${projectDir}/${f}`;
235
- const stat = await Bun.file(path).stat();
236
- const mtime = stat?.mtime instanceof Date ? stat.mtime.getTime() : Number(stat?.mtime || 0);
237
- stats.set(path, mtime);
238
- }
239
- }
240
- } catch {
241
- // Directory might not exist yet
242
- }
243
- return stats;
244
- }
245
-
246
- private async findActiveJsonlFile(session: InternalSession): Promise<string | null> {
247
- try {
248
- const files = await readdir(session.projectDir);
249
- const jsonlFiles = files.filter((f) => f.endsWith('.jsonl') && !f.startsWith('agent-'));
250
-
251
- const allPaths = jsonlFiles
252
- .map((f) => `${session.projectDir}/${f}`)
253
- .filter((path) => !this.claimedFiles.has(path));
254
-
255
- if (allPaths.length === 0) return null;
256
-
257
- // Get current file stats
258
- const fileStats = await Promise.all(
259
- allPaths.map(async (path) => {
260
- const stat = await Bun.file(path).stat();
261
- const mtime = stat?.mtime instanceof Date ? stat.mtime.getTime() : Number(stat?.mtime || 0);
262
- return { path, mtime };
263
- })
264
- );
265
-
266
- // Look for files that are either:
267
- // 1. New (didn't exist in our snapshot)
268
- // 2. Modified since our snapshot (for --continue case)
269
- for (const { path, mtime } of fileStats) {
270
- const initialMtime = session.initialFileStats.get(path);
271
-
272
- if (initialMtime === undefined) {
273
- // New file that didn't exist when session started
274
- console.log(`[SessionManager] Found new JSONL: ${path}`);
275
- return path;
276
- }
277
-
278
- if (mtime > initialMtime) {
279
- // Existing file that was modified after session start (--continue case)
280
- console.log(`[SessionManager] Found modified JSONL (--continue): ${path}`);
281
- return path;
282
- }
283
- }
284
-
285
- // No changes detected yet
286
- return null;
287
- } catch {
288
- return null;
289
- }
290
- }
291
-
292
- private async processJsonlUpdates(session: InternalSession): Promise<void> {
293
- if (!session.watchedFile) return;
294
-
295
- try {
296
- const file = Bun.file(session.watchedFile);
297
- const content = await file.text();
298
- const lines = content.split('\n').filter(Boolean);
299
-
300
- for (const line of lines) {
301
- const lineHash = Bun.hash(line).toString();
302
- if (session.seenMessages.has(lineHash)) continue;
303
- session.seenMessages.add(lineHash);
304
-
305
- // Extract session name (slug)
306
- if (!session.slugFound) {
307
- const slug = this.extractSlug(line);
308
- if (slug) {
309
- session.slugFound = true;
310
- session.name = slug;
311
- console.log(`[SessionManager] Session ${session.id} name: ${slug}`);
312
- this.events.onSessionUpdate(session.id, slug);
313
- }
314
- }
315
-
316
- // Extract todos
317
- const todos = this.extractTodos(line);
318
- if (todos) {
319
- const todosHash = Bun.hash(JSON.stringify(todos)).toString();
320
- if (todosHash !== session.lastTodosHash) {
321
- session.lastTodosHash = todosHash;
322
- this.events.onTodos(session.id, todos);
323
- }
324
- }
325
-
326
- // Detect plan mode changes
327
- const planModeStatus = this.detectPlanMode(line);
328
- if (planModeStatus !== null && planModeStatus !== session.inPlanMode) {
329
- session.inPlanMode = planModeStatus;
330
- console.log(`[SessionManager] Session ${session.id} plan mode: ${planModeStatus}`);
331
- this.events.onPlanModeChange(session.id, planModeStatus);
332
- }
333
-
334
- // Extract tool calls from assistant messages
335
- const toolCalls = this.extractToolCalls(line);
336
- for (const tool of toolCalls) {
337
- this.events.onToolCall(session.id, tool);
338
- }
339
-
340
- // Extract tool results from user messages
341
- const toolResults = this.extractToolResults(line);
342
- for (const result of toolResults) {
343
- this.events.onToolResult(session.id, result);
344
- }
345
-
346
- // Parse and forward messages
347
- const parsed = this.parseJsonlLine(line);
348
- if (parsed) {
349
- const messageTime = new Date(parsed.timestamp);
350
- if (messageTime < session.startedAt) continue;
351
-
352
- this.events.onMessage(session.id, parsed.role, parsed.content);
353
- }
354
- }
355
- } catch (err) {
356
- console.error('[SessionManager] Error processing JSONL:', err);
357
- }
358
- }
359
-
360
- private async startWatching(session: InternalSession): Promise<void> {
361
- const jsonlFile = await this.findActiveJsonlFile(session);
362
-
363
- if (jsonlFile) {
364
- session.watchedFile = jsonlFile;
365
- this.claimedFiles.add(jsonlFile);
366
- console.log(`[SessionManager] Watching: ${jsonlFile}`);
367
- await this.processJsonlUpdates(session);
368
- } else {
369
- console.log(`[SessionManager] Waiting for JSONL changes in ${session.projectDir}`);
370
- }
371
-
372
- // Watch directory for changes
373
- try {
374
- session.watcher = watch(session.projectDir, { recursive: false }, async (_, filename) => {
375
- if (!filename?.endsWith('.jsonl')) return;
376
-
377
- if (!session.watchedFile) {
378
- const newFile = await this.findActiveJsonlFile(session);
379
- if (newFile) {
380
- session.watchedFile = newFile;
381
- this.claimedFiles.add(newFile);
382
- }
383
- }
384
-
385
- const filePath = `${session.projectDir}/${filename}`;
386
- if (session.watchedFile && filePath === session.watchedFile) {
387
- await this.processJsonlUpdates(session);
388
- }
389
- });
390
- } catch (err) {
391
- console.error('[SessionManager] Error setting up watcher:', err);
392
- }
393
-
394
- // Poll as backup
395
- const pollInterval = setInterval(async () => {
396
- if (!this.sessions.has(session.id)) {
397
- clearInterval(pollInterval);
398
- return;
399
- }
400
-
401
- if (!session.watchedFile) {
402
- const newFile = await this.findActiveJsonlFile(session);
403
- if (newFile) {
404
- session.watchedFile = newFile;
405
- this.claimedFiles.add(newFile);
406
- }
407
- }
408
-
409
- if (session.watchedFile) {
410
- await this.processJsonlUpdates(session);
411
- }
412
- }, 1000);
413
- }
414
-
415
- private stopWatching(session: InternalSession): void {
416
- if (session.watcher) {
417
- session.watcher.close();
418
- }
419
- if (session.watchedFile) {
420
- this.claimedFiles.delete(session.watchedFile);
421
- }
422
- }
423
-
424
- private detectPlanMode(line: string): boolean | null {
425
- try {
426
- const data = JSON.parse(line);
427
- if (data.type !== 'user') return null;
428
-
429
- const content = data.message?.content;
430
- if (typeof content !== 'string') return null;
431
-
432
- // Check for plan mode activation
433
- if (content.includes('<system-reminder>') && content.includes('Plan mode is active')) {
434
- return true;
435
- }
436
-
437
- // Check for plan mode exit (ExitPlanMode was called)
438
- if (content.includes('Exited Plan Mode') || content.includes('exited plan mode')) {
439
- return false;
440
- }
441
-
442
- return null;
443
- } catch {
444
- return null;
445
- }
446
- }
447
-
448
- private extractToolCalls(line: string): ToolCallInfo[] {
449
- try {
450
- const data = JSON.parse(line);
451
- if (data.type !== 'assistant') return [];
452
-
453
- const content = data.message?.content;
454
- if (!Array.isArray(content)) return [];
455
-
456
- const tools: ToolCallInfo[] = [];
457
- for (const block of content) {
458
- if (block.type === 'tool_use' && block.id && block.name) {
459
- tools.push({
460
- id: block.id,
461
- name: block.name,
462
- input: block.input || {},
463
- });
464
- }
465
- }
466
- return tools;
467
- } catch {
468
- return [];
469
- }
470
- }
471
-
472
- private extractToolResults(line: string): ToolResultInfo[] {
473
- try {
474
- const data = JSON.parse(line);
475
- if (data.type !== 'user') return [];
476
-
477
- const content = data.message?.content;
478
- if (!Array.isArray(content)) return [];
479
-
480
- const results: ToolResultInfo[] = [];
481
- for (const block of content) {
482
- if (block.type === 'tool_result' && block.tool_use_id) {
483
- // Content can be string or array of text blocks
484
- let text = '';
485
- if (typeof block.content === 'string') {
486
- text = block.content;
487
- } else if (Array.isArray(block.content)) {
488
- text = block.content
489
- .filter((b: any) => b.type === 'text')
490
- .map((b: any) => b.text)
491
- .join('\n');
492
- }
493
-
494
- results.push({
495
- toolUseId: block.tool_use_id,
496
- content: text,
497
- isError: block.is_error === true,
498
- });
499
- }
500
- }
501
- return results;
502
- } catch {
503
- return [];
504
- }
505
- }
506
-
507
- private extractSlug(line: string): string | null {
508
- try {
509
- const data = JSON.parse(line);
510
- if (data.slug && typeof data.slug === 'string') {
511
- return data.slug;
512
- }
513
- return null;
514
- } catch {
515
- return null;
516
- }
517
- }
518
-
519
- private extractTodos(line: string): TodoItem[] | null {
520
- try {
521
- const data = JSON.parse(line);
522
- if (data.todos && Array.isArray(data.todos) && data.todos.length > 0) {
523
- return data.todos.map((t: any) => ({
524
- content: t.content || '',
525
- status: t.status || 'pending',
526
- activeForm: t.activeForm,
527
- }));
528
- }
529
- return null;
530
- } catch {
531
- return null;
532
- }
533
- }
534
-
535
- private parseJsonlLine(line: string): ChatMessage | null {
536
- try {
537
- const data = JSON.parse(line);
538
-
539
- if (data.type !== 'user' && data.type !== 'assistant') return null;
540
- if (data.isMeta || data.subtype) return null;
541
-
542
- const message = data.message;
543
- if (!message || !message.role) return null;
544
-
545
- let content = '';
546
- if (typeof message.content === 'string') {
547
- content = message.content;
548
- } else if (Array.isArray(message.content)) {
549
- for (const block of message.content) {
550
- if (block.type === 'text' && block.text) {
551
- content += block.text;
552
- }
553
- }
554
- }
555
-
556
- if (!content.trim()) return null;
557
-
558
- return {
559
- role: message.role as 'user' | 'assistant',
560
- content: content.trim(),
561
- timestamp: data.timestamp || new Date().toISOString(),
562
- };
563
- } catch {
564
- return null;
565
- }
566
- }
567
- }