afk-code 0.1.0 → 0.1.3

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,443 +0,0 @@
1
- import { App, LogLevel } from '@slack/bolt';
2
- import { createReadStream } from 'fs';
3
- import type { SlackConfig } from './types';
4
- import { SessionManager, type SessionInfo, type ToolCallInfo, type ToolResultInfo } from './session-manager';
5
- import { ChannelManager } from './channel-manager';
6
- import {
7
- markdownToSlack,
8
- chunkMessage,
9
- formatSessionStatus,
10
- formatTodos,
11
- } from './message-formatter';
12
- import { extractImagePaths } from '../utils/image-extractor';
13
-
14
- export function createSlackApp(config: SlackConfig) {
15
- const app = new App({
16
- token: config.botToken,
17
- appToken: config.appToken,
18
- socketMode: true,
19
- logLevel: LogLevel.INFO,
20
- });
21
-
22
- const channelManager = new ChannelManager(app.client, config.userId);
23
-
24
- // Track messages sent from Slack to avoid re-posting them when they come back via JSONL
25
- const slackSentMessages = new Set<string>();
26
-
27
- // Track tool call messages for threading results
28
- const toolCallMessages = new Map<string, string>(); // toolUseId -> message ts
29
-
30
- // Create session manager with event handlers that post to Slack
31
- const sessionManager = new SessionManager({
32
- onSessionStart: async (session) => {
33
- const channel = await channelManager.createChannel(session.id, session.name, session.cwd);
34
- if (channel) {
35
- // Post initial message to channel
36
- await app.client.chat.postMessage({
37
- channel: channel.channelId,
38
- text: `${formatSessionStatus(session.status)} *Session started*\n\`${session.cwd}\``,
39
- mrkdwn: true,
40
- });
41
- }
42
- },
43
-
44
- onSessionEnd: async (sessionId) => {
45
- const channel = channelManager.getChannel(sessionId);
46
- if (channel) {
47
- channelManager.updateStatus(sessionId, 'ended');
48
-
49
- // Post final message
50
- await app.client.chat.postMessage({
51
- channel: channel.channelId,
52
- text: ':stop_sign: *Session ended* - this channel will be archived',
53
- });
54
-
55
- // Archive the channel
56
- await channelManager.archiveChannel(sessionId);
57
- }
58
- },
59
-
60
- onSessionUpdate: async (sessionId, name) => {
61
- const channel = channelManager.getChannel(sessionId);
62
- if (channel) {
63
- channelManager.updateName(sessionId, name);
64
- // Update channel topic with new name
65
- try {
66
- await app.client.conversations.setTopic({
67
- channel: channel.channelId,
68
- topic: `Claude Code session: ${name}`,
69
- });
70
- } catch (err) {
71
- console.error('[Slack] Failed to update channel topic:', err);
72
- }
73
- }
74
- },
75
-
76
- onSessionStatus: async (sessionId, status) => {
77
- const channel = channelManager.getChannel(sessionId);
78
- if (channel) {
79
- channelManager.updateStatus(sessionId, status);
80
- }
81
- },
82
-
83
- onMessage: async (sessionId, role, content) => {
84
- const channel = channelManager.getChannel(sessionId);
85
- if (channel) {
86
- const formatted = markdownToSlack(content);
87
-
88
- if (role === 'user') {
89
- // Skip messages that originated from Slack (already visible in channel)
90
- const contentKey = content.trim();
91
- if (slackSentMessages.has(contentKey)) {
92
- slackSentMessages.delete(contentKey);
93
- return;
94
- }
95
-
96
- // User message from terminal - post as the user (using their name/avatar)
97
- const chunks = chunkMessage(formatted);
98
- for (const chunk of chunks) {
99
- try {
100
- // Fetch user profile to get their name and avatar
101
- const userInfo = await app.client.users.info({ user: config.userId });
102
- const userName = userInfo.user?.real_name || userInfo.user?.name || 'User';
103
- const userIcon = userInfo.user?.profile?.image_72;
104
-
105
- await app.client.chat.postMessage({
106
- channel: channel.channelId,
107
- text: chunk,
108
- username: userName,
109
- icon_url: userIcon,
110
- mrkdwn: true,
111
- });
112
- } catch (err) {
113
- console.error('[Slack] Failed to post message:', err);
114
- }
115
- }
116
- } else {
117
- // Claude's response - post as "Claude Code"
118
- const chunks = chunkMessage(formatted);
119
- for (const chunk of chunks) {
120
- try {
121
- await app.client.chat.postMessage({
122
- channel: channel.channelId,
123
- text: chunk,
124
- username: 'Claude Code',
125
- icon_url: 'https://claude.ai/favicon.ico',
126
- mrkdwn: true,
127
- });
128
- } catch (err) {
129
- console.error('[Slack] Failed to post message:', err);
130
- }
131
- }
132
-
133
- // Extract and upload any images mentioned in the response
134
- const session = sessionManager.getSession(sessionId);
135
- const images = extractImagePaths(content, session?.cwd);
136
- for (const image of images) {
137
- try {
138
- console.log(`[Slack] Uploading image: ${image.resolvedPath}`);
139
- await app.client.files.uploadV2({
140
- channel_id: channel.channelId,
141
- file: createReadStream(image.resolvedPath),
142
- filename: image.resolvedPath.split('/').pop() || 'image',
143
- initial_comment: `📎 ${image.originalPath}`,
144
- });
145
- } catch (err) {
146
- console.error('[Slack] Failed to upload image:', err);
147
- }
148
- }
149
- }
150
- }
151
- },
152
-
153
- onTodos: async (sessionId, todos) => {
154
- const channel = channelManager.getChannel(sessionId);
155
- if (channel && todos.length > 0) {
156
- const todosText = formatTodos(todos);
157
- try {
158
- await app.client.chat.postMessage({
159
- channel: channel.channelId,
160
- text: `*Tasks:*\n${todosText}`,
161
- mrkdwn: true,
162
- });
163
- } catch (err) {
164
- console.error('[Slack] Failed to post todos:', err);
165
- }
166
- }
167
- },
168
-
169
- onToolCall: async (sessionId, tool) => {
170
- const channel = channelManager.getChannel(sessionId);
171
- if (!channel) return;
172
-
173
- // Format tool call summary
174
- let inputSummary = '';
175
- if (tool.name === 'Bash' && tool.input.command) {
176
- inputSummary = `\`${tool.input.command.slice(0, 100)}${tool.input.command.length > 100 ? '...' : ''}\``;
177
- } else if (tool.name === 'Read' && tool.input.file_path) {
178
- inputSummary = `\`${tool.input.file_path}\``;
179
- } else if (tool.name === 'Edit' && tool.input.file_path) {
180
- inputSummary = `\`${tool.input.file_path}\``;
181
- } else if (tool.name === 'Write' && tool.input.file_path) {
182
- inputSummary = `\`${tool.input.file_path}\``;
183
- } else if (tool.name === 'Grep' && tool.input.pattern) {
184
- inputSummary = `\`${tool.input.pattern}\``;
185
- } else if (tool.name === 'Glob' && tool.input.pattern) {
186
- inputSummary = `\`${tool.input.pattern}\``;
187
- } else if (tool.name === 'Task' && tool.input.description) {
188
- inputSummary = tool.input.description;
189
- }
190
-
191
- const text = inputSummary
192
- ? `:wrench: *${tool.name}*: ${inputSummary}`
193
- : `:wrench: *${tool.name}*`;
194
-
195
- try {
196
- const result = await app.client.chat.postMessage({
197
- channel: channel.channelId,
198
- text,
199
- mrkdwn: true,
200
- });
201
-
202
- // Store the message ts for threading results
203
- if (result.ts) {
204
- toolCallMessages.set(tool.id, result.ts);
205
- }
206
- } catch (err) {
207
- console.error('[Slack] Failed to post tool call:', err);
208
- }
209
- },
210
-
211
- onToolResult: async (sessionId, result) => {
212
- const channel = channelManager.getChannel(sessionId);
213
- if (!channel) return;
214
-
215
- const parentTs = toolCallMessages.get(result.toolUseId);
216
- if (!parentTs) return; // No parent message to reply to
217
-
218
- // Truncate long results
219
- const maxLen = 2000;
220
- let content = result.content;
221
- if (content.length > maxLen) {
222
- content = content.slice(0, maxLen) + '\n... (truncated)';
223
- }
224
-
225
- const prefix = result.isError ? ':x: Error:' : ':white_check_mark: Result:';
226
- const text = `${prefix}\n\`\`\`\n${content}\n\`\`\``;
227
-
228
- try {
229
- await app.client.chat.postMessage({
230
- channel: channel.channelId,
231
- thread_ts: parentTs,
232
- text: markdownToSlack(text),
233
- mrkdwn: true,
234
- });
235
-
236
- // Clean up the mapping
237
- toolCallMessages.delete(result.toolUseId);
238
- } catch (err) {
239
- console.error('[Slack] Failed to post tool result:', err);
240
- }
241
- },
242
-
243
- onPlanModeChange: async (sessionId, inPlanMode) => {
244
- const channel = channelManager.getChannel(sessionId);
245
- if (!channel) return;
246
-
247
- const emoji = inPlanMode ? ':clipboard:' : ':hammer:';
248
- const status = inPlanMode ? 'Planning mode - Claude is designing a solution' : 'Execution mode - Claude is implementing';
249
-
250
- try {
251
- await app.client.chat.postMessage({
252
- channel: channel.channelId,
253
- text: `${emoji} ${status}`,
254
- mrkdwn: true,
255
- });
256
- } catch (err) {
257
- console.error('[Slack] Failed to post plan mode change:', err);
258
- }
259
- },
260
- });
261
-
262
- // Handle messages in session channels (user sending input to Claude)
263
- app.message(async ({ message, say }) => {
264
- // Type guard for regular messages
265
- if ('subtype' in message && message.subtype) return;
266
- if (!('text' in message) || !message.text) return;
267
- if (!('channel' in message) || !message.channel) return;
268
-
269
- // Ignore bot's own messages
270
- if ('bot_id' in message && message.bot_id) return;
271
-
272
- // Ignore thread replies (we want top-level messages only)
273
- if ('thread_ts' in message && message.thread_ts) return;
274
-
275
- const sessionId = channelManager.getSessionByChannel(message.channel);
276
- if (!sessionId) return; // Not a session channel
277
-
278
- const channel = channelManager.getChannel(sessionId);
279
- if (!channel || channel.status === 'ended') {
280
- await say(':warning: This session has ended.');
281
- return;
282
- }
283
-
284
- console.log(`[Slack] Sending input to session ${sessionId}: ${message.text.slice(0, 50)}...`);
285
-
286
- // Track this message so we don't re-post it when it comes back via JSONL
287
- slackSentMessages.add(message.text.trim());
288
-
289
- const sent = sessionManager.sendInput(sessionId, message.text);
290
- if (!sent) {
291
- slackSentMessages.delete(message.text.trim());
292
- await say(':warning: Failed to send input - session not connected.');
293
- }
294
- });
295
-
296
- // Slash command: /afk [sessions]
297
- app.command('/afk', async ({ command, ack, respond }) => {
298
- await ack();
299
-
300
- const subcommand = command.text.trim().split(' ')[0];
301
-
302
- if (subcommand === 'sessions' || !subcommand) {
303
- const active = channelManager.getAllActive();
304
- if (active.length === 0) {
305
- await respond('No active sessions. Start a session with `afk-code run -- claude`');
306
- return;
307
- }
308
-
309
- const text = active
310
- .map((c) => `<#${c.channelId}> - ${formatSessionStatus(c.status)}`)
311
- .join('\n');
312
-
313
- await respond({
314
- text: `*Active Sessions:*\n${text}`,
315
- mrkdwn: true,
316
- });
317
- } else {
318
- await respond('Unknown command. Available: `/afk sessions`');
319
- }
320
- });
321
-
322
- // Slash command: /background - Send Ctrl+B to put Claude in background mode
323
- app.command('/background', async ({ command, ack, respond }) => {
324
- await ack();
325
-
326
- const sessionId = channelManager.getSessionByChannel(command.channel_id);
327
- if (!sessionId) {
328
- await respond(':warning: This channel is not associated with an active session.');
329
- return;
330
- }
331
-
332
- const channel = channelManager.getChannel(sessionId);
333
- if (!channel || channel.status === 'ended') {
334
- await respond(':warning: This session has ended.');
335
- return;
336
- }
337
-
338
- // Send Ctrl+B (ASCII 2)
339
- const sent = sessionManager.sendInput(sessionId, '\x02');
340
- if (sent) {
341
- await respond(':arrow_heading_down: Sent background command (Ctrl+B)');
342
- } else {
343
- await respond(':warning: Failed to send command - session not connected.');
344
- }
345
- });
346
-
347
- // Slash command: /interrupt - Send Escape to interrupt Claude
348
- app.command('/interrupt', async ({ command, ack, respond }) => {
349
- await ack();
350
-
351
- const sessionId = channelManager.getSessionByChannel(command.channel_id);
352
- if (!sessionId) {
353
- await respond(':warning: This channel is not associated with an active session.');
354
- return;
355
- }
356
-
357
- const channel = channelManager.getChannel(sessionId);
358
- if (!channel || channel.status === 'ended') {
359
- await respond(':warning: This session has ended.');
360
- return;
361
- }
362
-
363
- // Send Escape (ASCII 27)
364
- const sent = sessionManager.sendInput(sessionId, '\x1b');
365
- if (sent) {
366
- await respond(':stop_sign: Sent interrupt (Escape)');
367
- } else {
368
- await respond(':warning: Failed to send command - session not connected.');
369
- }
370
- });
371
-
372
- // Slash command: /mode - Send Shift+Tab to toggle mode
373
- app.command('/mode', async ({ command, ack, respond }) => {
374
- await ack();
375
-
376
- const sessionId = channelManager.getSessionByChannel(command.channel_id);
377
- if (!sessionId) {
378
- await respond(':warning: This channel is not associated with an active session.');
379
- return;
380
- }
381
-
382
- const channel = channelManager.getChannel(sessionId);
383
- if (!channel || channel.status === 'ended') {
384
- await respond(':warning: This session has ended.');
385
- return;
386
- }
387
-
388
- // Send Shift+Tab (ESC [ Z)
389
- const sent = sessionManager.sendInput(sessionId, '\x1b[Z');
390
- if (sent) {
391
- await respond(':arrows_counterclockwise: Sent mode toggle (Shift+Tab)');
392
- } else {
393
- await respond(':warning: Failed to send command - session not connected.');
394
- }
395
- });
396
-
397
- // App Home tab
398
- app.event('app_home_opened', async ({ event, client }) => {
399
- const active = channelManager.getAllActive();
400
-
401
- const blocks: any[] = [
402
- {
403
- type: 'header',
404
- text: { type: 'plain_text', text: 'AFK Code Sessions', emoji: true },
405
- },
406
- { type: 'divider' },
407
- ];
408
-
409
- if (active.length === 0) {
410
- blocks.push({
411
- type: 'section',
412
- text: {
413
- type: 'mrkdwn',
414
- text: '_No active sessions_\n\nStart a session with `afk-code run -- claude`',
415
- },
416
- });
417
- } else {
418
- for (const c of active) {
419
- blocks.push({
420
- type: 'section',
421
- text: {
422
- type: 'mrkdwn',
423
- text: `*${c.sessionName}*\n${formatSessionStatus(c.status)}\n<#${c.channelId}>`,
424
- },
425
- });
426
- }
427
- }
428
-
429
- try {
430
- await client.views.publish({
431
- user_id: event.user,
432
- view: {
433
- type: 'home',
434
- blocks,
435
- },
436
- });
437
- } catch (err) {
438
- console.error('[Slack] Failed to publish home view:', err);
439
- }
440
- });
441
-
442
- return { app, sessionManager, channelManager };
443
- }
@@ -1,6 +0,0 @@
1
- export interface SlackConfig {
2
- botToken: string;
3
- appToken: string;
4
- signingSecret: string;
5
- userId: string; // User to auto-invite to channels
6
- }
@@ -1,6 +0,0 @@
1
- // Todo item from Claude Code
2
- export interface TodoItem {
3
- content: string;
4
- status: 'pending' | 'in_progress' | 'completed';
5
- activeForm?: string;
6
- }
@@ -1,72 +0,0 @@
1
- import { existsSync, statSync } from 'fs';
2
- import { resolve } from 'path';
3
- import { homedir } from 'os';
4
-
5
- // Common image extensions
6
- const IMAGE_EXTENSIONS = new Set([
7
- '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.tif'
8
- ]);
9
-
10
- // Regex to match file paths that could be images
11
- // Matches: /absolute/path.png, ./relative/path.jpg, ~/home/path.gif, "quoted/path.png"
12
- const PATH_PATTERN = /(?:["'`]([^"'`\n]+\.(?:png|jpe?g|gif|webp|svg|bmp|ico|tiff?))|(?:^|[\s(])([~./][^\s)"'`\n]*\.(?:png|jpe?g|gif|webp|svg|bmp|ico|tiff?)))/gi;
13
-
14
- export interface ExtractedImage {
15
- originalPath: string;
16
- resolvedPath: string;
17
- }
18
-
19
- /**
20
- * Extract image paths from text content and verify they exist on disk
21
- */
22
- export function extractImagePaths(content: string, cwd?: string): ExtractedImage[] {
23
- const images: ExtractedImage[] = [];
24
- const seen = new Set<string>();
25
-
26
- // Reset regex state
27
- PATH_PATTERN.lastIndex = 0;
28
-
29
- let match;
30
- while ((match = PATH_PATTERN.exec(content)) !== null) {
31
- // Get the captured path (from quoted or unquoted group)
32
- const originalPath = (match[1] || match[2]).trim();
33
-
34
- if (!originalPath || seen.has(originalPath)) continue;
35
- seen.add(originalPath);
36
-
37
- // Resolve the path
38
- let resolvedPath = originalPath;
39
-
40
- // Handle home directory
41
- if (resolvedPath.startsWith('~/')) {
42
- resolvedPath = resolve(homedir(), resolvedPath.slice(2));
43
- }
44
- // Handle relative paths
45
- else if (resolvedPath.startsWith('./') || resolvedPath.startsWith('../')) {
46
- resolvedPath = resolve(cwd || process.cwd(), resolvedPath);
47
- }
48
- // Handle absolute paths (already resolved)
49
- else if (!resolvedPath.startsWith('/')) {
50
- // Not a recognizable path format, skip
51
- continue;
52
- }
53
-
54
- // Verify file exists and is a file (not directory)
55
- try {
56
- if (existsSync(resolvedPath)) {
57
- const stat = statSync(resolvedPath);
58
- if (stat.isFile()) {
59
- // Check extension
60
- const ext = resolvedPath.toLowerCase().slice(resolvedPath.lastIndexOf('.'));
61
- if (IMAGE_EXTENSIONS.has(ext)) {
62
- images.push({ originalPath, resolvedPath });
63
- }
64
- }
65
- }
66
- } catch {
67
- // File doesn't exist or can't be accessed, skip
68
- }
69
- }
70
-
71
- return images;
72
- }