afk-code 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,359 @@
1
+ import { Client, GatewayIntentBits, Events, ChannelType, AttachmentBuilder, REST, Routes, SlashCommandBuilder } from 'discord.js';
2
+ import type { DiscordConfig } from './types';
3
+ import { SessionManager, type SessionInfo, type ToolCallInfo, type ToolResultInfo } from '../slack/session-manager';
4
+ import { ChannelManager } from './channel-manager';
5
+ import { markdownToSlack, chunkMessage, formatSessionStatus, formatTodos } from '../slack/message-formatter';
6
+ import { extractImagePaths } from '../utils/image-extractor';
7
+
8
+ export function createDiscordApp(config: DiscordConfig) {
9
+ const client = new Client({
10
+ intents: [
11
+ GatewayIntentBits.Guilds,
12
+ GatewayIntentBits.GuildMessages,
13
+ GatewayIntentBits.MessageContent,
14
+ ],
15
+ });
16
+
17
+ const channelManager = new ChannelManager(client, config.userId);
18
+
19
+ // Track messages sent from Discord to avoid re-posting
20
+ const discordSentMessages = new Set<string>();
21
+
22
+ // Track tool call messages for threading results
23
+ const toolCallMessages = new Map<string, string>(); // toolUseId -> message id
24
+
25
+ // Create session manager with event handlers that post to Discord
26
+ const sessionManager = new SessionManager({
27
+ onSessionStart: async (session) => {
28
+ const channel = await channelManager.createChannel(session.id, session.name, session.cwd);
29
+ if (channel) {
30
+ const discordChannel = await client.channels.fetch(channel.channelId);
31
+ if (discordChannel?.type === ChannelType.GuildText) {
32
+ await discordChannel.send(
33
+ `${formatSessionStatus(session.status)} **Session started**\n\`${session.cwd}\``
34
+ );
35
+ }
36
+ }
37
+ },
38
+
39
+ onSessionEnd: async (sessionId) => {
40
+ const channel = channelManager.getChannel(sessionId);
41
+ if (channel) {
42
+ channelManager.updateStatus(sessionId, 'ended');
43
+
44
+ const discordChannel = await client.channels.fetch(channel.channelId);
45
+ if (discordChannel?.type === ChannelType.GuildText) {
46
+ await discordChannel.send('🛑 **Session ended** - this channel will be archived');
47
+ }
48
+
49
+ await channelManager.archiveChannel(sessionId);
50
+ }
51
+ },
52
+
53
+ onSessionUpdate: async (sessionId, name) => {
54
+ const channel = channelManager.getChannel(sessionId);
55
+ if (channel) {
56
+ channelManager.updateName(sessionId, name);
57
+ // Update channel topic
58
+ try {
59
+ const discordChannel = await client.channels.fetch(channel.channelId);
60
+ if (discordChannel?.type === ChannelType.GuildText) {
61
+ await discordChannel.setTopic(`Claude Code session: ${name}`);
62
+ }
63
+ } catch (err) {
64
+ console.error('[Discord] Failed to update channel topic:', err);
65
+ }
66
+ }
67
+ },
68
+
69
+ onSessionStatus: async (sessionId, status) => {
70
+ const channel = channelManager.getChannel(sessionId);
71
+ if (channel) {
72
+ channelManager.updateStatus(sessionId, status);
73
+ }
74
+ },
75
+
76
+ onMessage: async (sessionId, role, content) => {
77
+ const channel = channelManager.getChannel(sessionId);
78
+ if (channel) {
79
+ // Discord markdown is similar to Slack's mrkdwn but uses standard markdown
80
+ const formatted = content; // Discord uses standard markdown
81
+
82
+ if (role === 'user') {
83
+ // Skip messages that originated from Discord
84
+ const contentKey = content.trim();
85
+ if (discordSentMessages.has(contentKey)) {
86
+ discordSentMessages.delete(contentKey);
87
+ return;
88
+ }
89
+
90
+ // User message from terminal
91
+ const discordChannel = await client.channels.fetch(channel.channelId);
92
+ if (discordChannel?.type === ChannelType.GuildText) {
93
+ const chunks = chunkMessage(formatted);
94
+ for (const chunk of chunks) {
95
+ await discordChannel.send(`**User:** ${chunk}`);
96
+ }
97
+ }
98
+ } else {
99
+ // Claude's response
100
+ const discordChannel = await client.channels.fetch(channel.channelId);
101
+ if (discordChannel?.type === ChannelType.GuildText) {
102
+ const chunks = chunkMessage(formatted);
103
+ for (const chunk of chunks) {
104
+ await discordChannel.send(chunk);
105
+ }
106
+
107
+ // Extract and upload any images mentioned in the response
108
+ const session = sessionManager.getSession(sessionId);
109
+ const images = extractImagePaths(content, session?.cwd);
110
+ for (const image of images) {
111
+ try {
112
+ console.log(`[Discord] Uploading image: ${image.resolvedPath}`);
113
+ const attachment = new AttachmentBuilder(image.resolvedPath);
114
+ await discordChannel.send({
115
+ content: `📎 ${image.originalPath}`,
116
+ files: [attachment],
117
+ });
118
+ } catch (err) {
119
+ console.error('[Discord] Failed to upload image:', err);
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+ },
126
+
127
+ onTodos: async (sessionId, todos) => {
128
+ const channel = channelManager.getChannel(sessionId);
129
+ if (channel && todos.length > 0) {
130
+ const todosText = formatTodos(todos);
131
+ try {
132
+ const discordChannel = await client.channels.fetch(channel.channelId);
133
+ if (discordChannel?.type === ChannelType.GuildText) {
134
+ await discordChannel.send(`**Tasks:**\n${todosText}`);
135
+ }
136
+ } catch (err) {
137
+ console.error('[Discord] Failed to post todos:', err);
138
+ }
139
+ }
140
+ },
141
+
142
+ onToolCall: async (sessionId, tool) => {
143
+ const channel = channelManager.getChannel(sessionId);
144
+ if (!channel) return;
145
+
146
+ // Format tool call summary
147
+ let inputSummary = '';
148
+ if (tool.name === 'Bash' && tool.input.command) {
149
+ inputSummary = `\`${tool.input.command.slice(0, 100)}${tool.input.command.length > 100 ? '...' : ''}\``;
150
+ } else if (tool.name === 'Read' && tool.input.file_path) {
151
+ inputSummary = `\`${tool.input.file_path}\``;
152
+ } else if (tool.name === 'Edit' && tool.input.file_path) {
153
+ inputSummary = `\`${tool.input.file_path}\``;
154
+ } else if (tool.name === 'Write' && tool.input.file_path) {
155
+ inputSummary = `\`${tool.input.file_path}\``;
156
+ } else if (tool.name === 'Grep' && tool.input.pattern) {
157
+ inputSummary = `\`${tool.input.pattern}\``;
158
+ } else if (tool.name === 'Glob' && tool.input.pattern) {
159
+ inputSummary = `\`${tool.input.pattern}\``;
160
+ } else if (tool.name === 'Task' && tool.input.description) {
161
+ inputSummary = tool.input.description;
162
+ }
163
+
164
+ const text = inputSummary
165
+ ? `🔧 **${tool.name}**: ${inputSummary}`
166
+ : `🔧 **${tool.name}**`;
167
+
168
+ try {
169
+ const discordChannel = await client.channels.fetch(channel.channelId);
170
+ if (discordChannel?.type === ChannelType.GuildText) {
171
+ const message = await discordChannel.send(text);
172
+ // Store the message id for threading results
173
+ toolCallMessages.set(tool.id, message.id);
174
+ }
175
+ } catch (err) {
176
+ console.error('[Discord] Failed to post tool call:', err);
177
+ }
178
+ },
179
+
180
+ onToolResult: async (sessionId, result) => {
181
+ const channel = channelManager.getChannel(sessionId);
182
+ if (!channel) return;
183
+
184
+ const parentMessageId = toolCallMessages.get(result.toolUseId);
185
+ if (!parentMessageId) return; // No parent message to reply to
186
+
187
+ // Truncate long results
188
+ const maxLen = 1800; // Discord has 2000 char limit
189
+ let content = result.content;
190
+ if (content.length > maxLen) {
191
+ content = content.slice(0, maxLen) + '\n... (truncated)';
192
+ }
193
+
194
+ const prefix = result.isError ? '❌ Error:' : '✅ Result:';
195
+ const text = `${prefix}\n\`\`\`\n${content}\n\`\`\``;
196
+
197
+ try {
198
+ const discordChannel = await client.channels.fetch(channel.channelId);
199
+ if (discordChannel?.type === ChannelType.GuildText) {
200
+ // Fetch the parent message and create a thread
201
+ const parentMessage = await discordChannel.messages.fetch(parentMessageId);
202
+ if (parentMessage) {
203
+ // Create a thread if one doesn't exist, or use existing
204
+ let thread = parentMessage.thread;
205
+ if (!thread) {
206
+ thread = await parentMessage.startThread({
207
+ name: 'Result',
208
+ autoArchiveDuration: 60,
209
+ });
210
+ }
211
+ await thread.send(text);
212
+ }
213
+
214
+ // Clean up the mapping
215
+ toolCallMessages.delete(result.toolUseId);
216
+ }
217
+ } catch (err) {
218
+ console.error('[Discord] Failed to post tool result:', err);
219
+ }
220
+ },
221
+
222
+ onPlanModeChange: async (sessionId, inPlanMode) => {
223
+ const channel = channelManager.getChannel(sessionId);
224
+ if (!channel) return;
225
+
226
+ const emoji = inPlanMode ? '📋' : '🔨';
227
+ const status = inPlanMode ? 'Planning mode - Claude is designing a solution' : 'Execution mode - Claude is implementing';
228
+
229
+ try {
230
+ const discordChannel = await client.channels.fetch(channel.channelId);
231
+ if (discordChannel?.type === ChannelType.GuildText) {
232
+ await discordChannel.send(`${emoji} ${status}`);
233
+ }
234
+ } catch (err) {
235
+ console.error('[Discord] Failed to post plan mode change:', err);
236
+ }
237
+ },
238
+ });
239
+
240
+ // Handle messages in session channels (user sending input to Claude)
241
+ client.on(Events.MessageCreate, async (message) => {
242
+ // Ignore bot's own messages
243
+ if (message.author.bot) return;
244
+
245
+ // Ignore DMs
246
+ if (!message.guild) return;
247
+
248
+ const sessionId = channelManager.getSessionByChannel(message.channelId);
249
+ if (!sessionId) return; // Not a session channel
250
+
251
+ const channel = channelManager.getChannel(sessionId);
252
+ if (!channel || channel.status === 'ended') {
253
+ await message.reply('⚠️ This session has ended.');
254
+ return;
255
+ }
256
+
257
+ console.log(`[Discord] Sending input to session ${sessionId}: ${message.content.slice(0, 50)}...`);
258
+
259
+ // Track this message so we don't re-post it
260
+ discordSentMessages.add(message.content.trim());
261
+
262
+ const sent = sessionManager.sendInput(sessionId, message.content);
263
+ if (!sent) {
264
+ discordSentMessages.delete(message.content.trim());
265
+ await message.reply('⚠️ Failed to send input - session not connected.');
266
+ }
267
+ });
268
+
269
+ // When bot is ready
270
+ client.once(Events.ClientReady, async (c) => {
271
+ console.log(`[Discord] Logged in as ${c.user.tag}`);
272
+ await channelManager.initialize();
273
+
274
+ // Register slash commands
275
+ const commands = [
276
+ new SlashCommandBuilder()
277
+ .setName('background')
278
+ .setDescription('Send Claude to background mode (Ctrl+B)'),
279
+ new SlashCommandBuilder()
280
+ .setName('interrupt')
281
+ .setDescription('Interrupt Claude (Escape)'),
282
+ new SlashCommandBuilder()
283
+ .setName('mode')
284
+ .setDescription('Toggle Claude mode (Shift+Tab)'),
285
+ new SlashCommandBuilder()
286
+ .setName('afk')
287
+ .setDescription('List active Claude Code sessions'),
288
+ ];
289
+
290
+ try {
291
+ const rest = new REST({ version: '10' }).setToken(config.botToken);
292
+ await rest.put(Routes.applicationCommands(c.user.id), {
293
+ body: commands.map((cmd) => cmd.toJSON()),
294
+ });
295
+ console.log('[Discord] Slash commands registered');
296
+ } catch (err) {
297
+ console.error('[Discord] Failed to register slash commands:', err);
298
+ }
299
+ });
300
+
301
+ // Handle slash commands
302
+ client.on(Events.InteractionCreate, async (interaction) => {
303
+ if (!interaction.isChatInputCommand()) return;
304
+
305
+ const { commandName, channelId } = interaction;
306
+
307
+ if (commandName === 'afk') {
308
+ const active = channelManager.getAllActive();
309
+ if (active.length === 0) {
310
+ await interaction.reply('No active sessions. Start a session with `afk-code run -- claude`');
311
+ return;
312
+ }
313
+
314
+ const text = active
315
+ .map((c) => `<#${c.channelId}> - ${formatSessionStatus(c.status)}`)
316
+ .join('\n');
317
+
318
+ await interaction.reply(`**Active Sessions:**\n${text}`);
319
+ return;
320
+ }
321
+
322
+ if (commandName === 'background' || commandName === 'interrupt' || commandName === 'mode') {
323
+ const sessionId = channelManager.getSessionByChannel(channelId);
324
+ if (!sessionId) {
325
+ await interaction.reply('⚠️ This channel is not associated with an active session.');
326
+ return;
327
+ }
328
+
329
+ const channel = channelManager.getChannel(sessionId);
330
+ if (!channel || channel.status === 'ended') {
331
+ await interaction.reply('⚠️ This session has ended.');
332
+ return;
333
+ }
334
+
335
+ // Send the appropriate escape sequence
336
+ let key: string;
337
+ let message: string;
338
+ if (commandName === 'background') {
339
+ key = '\x02'; // Ctrl+B
340
+ message = '⬇️ Sent background command (Ctrl+B)';
341
+ } else if (commandName === 'interrupt') {
342
+ key = '\x1b'; // Escape
343
+ message = '🛑 Sent interrupt (Escape)';
344
+ } else {
345
+ key = '\x1b[Z'; // Shift+Tab
346
+ message = '🔄 Sent mode toggle (Shift+Tab)';
347
+ }
348
+
349
+ const sent = sessionManager.sendInput(sessionId, key);
350
+ if (sent) {
351
+ await interaction.reply(message);
352
+ } else {
353
+ await interaction.reply('⚠️ Failed to send command - session not connected.');
354
+ }
355
+ }
356
+ });
357
+
358
+ return { client, sessionManager, channelManager };
359
+ }
@@ -0,0 +1,4 @@
1
+ export interface DiscordConfig {
2
+ botToken: string;
3
+ userId: string; // User to notify
4
+ }
@@ -0,0 +1,175 @@
1
+ import type { WebClient } from '@slack/web-api';
2
+
3
+ export interface ChannelMapping {
4
+ sessionId: string;
5
+ channelId: string;
6
+ channelName: string;
7
+ sessionName: string;
8
+ status: 'running' | 'idle' | 'ended';
9
+ createdAt: Date;
10
+ }
11
+
12
+ /**
13
+ * Sanitize a string for use as a Slack channel name.
14
+ * Rules: lowercase, no spaces, max 80 chars, only letters/numbers/hyphens/underscores
15
+ */
16
+ function sanitizeChannelName(name: string): string {
17
+ return name
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9-_\s]/g, '') // Remove invalid chars
20
+ .replace(/\s+/g, '-') // Spaces to hyphens
21
+ .replace(/-+/g, '-') // Collapse multiple hyphens
22
+ .replace(/^-|-$/g, '') // Trim hyphens from ends
23
+ .slice(0, 70); // Leave room for "afk-" prefix and uniqueness suffix
24
+ }
25
+
26
+ export class ChannelManager {
27
+ private channels = new Map<string, ChannelMapping>();
28
+ private channelToSession = new Map<string, string>();
29
+ private client: WebClient;
30
+ private userId: string;
31
+
32
+ constructor(client: WebClient, userId: string) {
33
+ this.client = client;
34
+ this.userId = userId;
35
+ }
36
+
37
+ async createChannel(
38
+ sessionId: string,
39
+ sessionName: string,
40
+ cwd: string
41
+ ): Promise<ChannelMapping | null> {
42
+ // Check if channel already exists for this session
43
+ if (this.channels.has(sessionId)) {
44
+ return this.channels.get(sessionId)!;
45
+ }
46
+
47
+ // Extract just the folder name from the path
48
+ const folderName = cwd.split('/').filter(Boolean).pop() || 'session';
49
+ const baseName = `afk-${sanitizeChannelName(folderName)}`;
50
+
51
+ // Try to create channel, incrementing suffix if name is taken
52
+ let channelName = baseName;
53
+ let suffix = 1;
54
+ let result;
55
+
56
+ while (true) {
57
+ // Ensure max 80 chars
58
+ const nameToTry = channelName.length > 80 ? channelName.slice(0, 80) : channelName;
59
+
60
+ try {
61
+ result = await this.client.conversations.create({
62
+ name: nameToTry,
63
+ is_private: true,
64
+ });
65
+ channelName = nameToTry;
66
+ break; // Success!
67
+ } catch (err: any) {
68
+ if (err.data?.error === 'name_taken') {
69
+ // Try next number
70
+ suffix++;
71
+ channelName = `${baseName}-${suffix}`;
72
+ } else {
73
+ throw err; // Different error, rethrow
74
+ }
75
+ }
76
+ }
77
+
78
+ if (!result?.channel?.id) {
79
+ console.error('[ChannelManager] Failed to create channel - no ID returned');
80
+ return null;
81
+ }
82
+
83
+ const mapping: ChannelMapping = {
84
+ sessionId,
85
+ channelId: result.channel.id,
86
+ channelName,
87
+ sessionName,
88
+ status: 'running',
89
+ createdAt: new Date(),
90
+ };
91
+
92
+ this.channels.set(sessionId, mapping);
93
+ this.channelToSession.set(result.channel.id, sessionId);
94
+
95
+ // Set channel topic
96
+ try {
97
+ await this.client.conversations.setTopic({
98
+ channel: result.channel.id,
99
+ topic: `Claude Code session: ${sessionName}`,
100
+ });
101
+ } catch (err: any) {
102
+ console.error('[ChannelManager] Failed to set topic:', err.message);
103
+ }
104
+
105
+ // Invite user to channel
106
+ if (this.userId) {
107
+ try {
108
+ await this.client.conversations.invite({
109
+ channel: result.channel.id,
110
+ users: this.userId,
111
+ });
112
+ console.log(`[ChannelManager] Invited user to channel`);
113
+ } catch (err: any) {
114
+ // Ignore "already_in_channel" error
115
+ if (err.data?.error !== 'already_in_channel') {
116
+ console.error('[ChannelManager] Failed to invite user:', err.message);
117
+ }
118
+ }
119
+ }
120
+
121
+ console.log(`[ChannelManager] Created channel #${channelName} for session ${sessionId}`);
122
+ return mapping;
123
+ }
124
+
125
+ async archiveChannel(sessionId: string): Promise<boolean> {
126
+ const mapping = this.channels.get(sessionId);
127
+ if (!mapping) return false;
128
+
129
+ try {
130
+ // Rename channel before archiving to free up the name for reuse
131
+ const timestamp = Date.now().toString(36);
132
+ const archivedName = `${mapping.channelName}-archived-${timestamp}`.slice(0, 80);
133
+
134
+ await this.client.conversations.rename({
135
+ channel: mapping.channelId,
136
+ name: archivedName,
137
+ });
138
+
139
+ await this.client.conversations.archive({
140
+ channel: mapping.channelId,
141
+ });
142
+ console.log(`[ChannelManager] Archived channel #${mapping.channelName}`);
143
+ return true;
144
+ } catch (err: any) {
145
+ console.error('[ChannelManager] Failed to archive channel:', err.message);
146
+ return false;
147
+ }
148
+ }
149
+
150
+ getChannel(sessionId: string): ChannelMapping | undefined {
151
+ return this.channels.get(sessionId);
152
+ }
153
+
154
+ getSessionByChannel(channelId: string): string | undefined {
155
+ return this.channelToSession.get(channelId);
156
+ }
157
+
158
+ updateStatus(sessionId: string, status: 'running' | 'idle' | 'ended'): void {
159
+ const mapping = this.channels.get(sessionId);
160
+ if (mapping) {
161
+ mapping.status = status;
162
+ }
163
+ }
164
+
165
+ updateName(sessionId: string, name: string): void {
166
+ const mapping = this.channels.get(sessionId);
167
+ if (mapping) {
168
+ mapping.sessionName = name;
169
+ }
170
+ }
171
+
172
+ getAllActive(): ChannelMapping[] {
173
+ return Array.from(this.channels.values()).filter((c) => c.status !== 'ended');
174
+ }
175
+ }
@@ -0,0 +1,58 @@
1
+ import { createSlackApp } from './slack-app';
2
+ import type { SlackConfig } from './types';
3
+
4
+ async function main() {
5
+ const config: SlackConfig = {
6
+ botToken: process.env.SLACK_BOT_TOKEN || '',
7
+ appToken: process.env.SLACK_APP_TOKEN || '',
8
+ signingSecret: process.env.SLACK_SIGNING_SECRET || '',
9
+ userId: process.env.SLACK_USER_ID || '',
10
+ };
11
+
12
+ // Validate required config
13
+ const required: (keyof SlackConfig)[] = ['botToken', 'appToken', 'userId'];
14
+
15
+ const missing = required.filter((key) => !config[key]);
16
+ if (missing.length > 0) {
17
+ console.error(`[Slack] Missing required config: ${missing.join(', ')}`);
18
+ console.error('');
19
+ console.error('Required environment variables:');
20
+ console.error(' SLACK_BOT_TOKEN - Bot User OAuth Token (xoxb-...)');
21
+ console.error(' SLACK_APP_TOKEN - App-Level Token for Socket Mode (xapp-...)');
22
+ console.error(' SLACK_USER_ID - Your Slack user ID (U...)');
23
+ console.error('');
24
+ console.error('Optional:');
25
+ console.error(' SLACK_SIGNING_SECRET - Signing secret (for request verification)');
26
+ process.exit(1);
27
+ }
28
+
29
+ console.log('[Slack] Starting AFK Code bot...');
30
+
31
+ const { app, sessionManager } = createSlackApp(config);
32
+
33
+ // Start session manager (Unix socket server for CLI connections)
34
+ try {
35
+ await sessionManager.start();
36
+ console.log('[Slack] Session manager started');
37
+ } catch (err) {
38
+ console.error('[Slack] Failed to start session manager:', err);
39
+ process.exit(1);
40
+ }
41
+
42
+ // Start Slack app
43
+ try {
44
+ await app.start();
45
+ console.log('[Slack] Bot is running!');
46
+ console.log('');
47
+ console.log('Start a Claude Code session with: afk-code run -- claude');
48
+ console.log('Each session will create a private #afk-* channel');
49
+ } catch (err) {
50
+ console.error('[Slack] Failed to start app:', err);
51
+ process.exit(1);
52
+ }
53
+ }
54
+
55
+ main().catch((err) => {
56
+ console.error('[Slack] Fatal error:', err);
57
+ process.exit(1);
58
+ });
@@ -0,0 +1,91 @@
1
+ import type { TodoItem } from '../types';
2
+
3
+ /**
4
+ * Convert GitHub-flavored markdown to Slack mrkdwn format
5
+ */
6
+ export function markdownToSlack(markdown: string): string {
7
+ let text = markdown;
8
+
9
+ // Bold: **text** -> *text*
10
+ text = text.replace(/\*\*(.+?)\*\*/g, '*$1*');
11
+
12
+ // Headers: # Header -> *Header*
13
+ text = text.replace(/^#{1,6}\s+(.+)$/gm, '*$1*');
14
+
15
+ // Links: [text](url) -> <url|text>
16
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
17
+
18
+ // Strikethrough: ~~text~~ -> ~text~
19
+ text = text.replace(/~~(.+?)~~/g, '~$1~');
20
+
21
+ return text;
22
+ }
23
+
24
+ /**
25
+ * Split long messages to fit within Slack's 40k char limit
26
+ */
27
+ export function chunkMessage(text: string, maxLength = 39000): string[] {
28
+ if (text.length <= maxLength) return [text];
29
+
30
+ const chunks: string[] = [];
31
+ let remaining = text;
32
+
33
+ while (remaining.length > 0) {
34
+ if (remaining.length <= maxLength) {
35
+ chunks.push(remaining);
36
+ break;
37
+ }
38
+
39
+ // Find a good break point (newline or space)
40
+ let breakPoint = remaining.lastIndexOf('\n', maxLength);
41
+ if (breakPoint === -1 || breakPoint < maxLength / 2) {
42
+ breakPoint = remaining.lastIndexOf(' ', maxLength);
43
+ }
44
+ if (breakPoint === -1 || breakPoint < maxLength / 2) {
45
+ breakPoint = maxLength;
46
+ }
47
+
48
+ chunks.push(remaining.slice(0, breakPoint));
49
+ remaining = remaining.slice(breakPoint).trimStart();
50
+ }
51
+
52
+ return chunks;
53
+ }
54
+
55
+ /**
56
+ * Format session status with emoji
57
+ */
58
+ export function formatSessionStatus(status: 'running' | 'idle' | 'ended'): string {
59
+ const icons: Record<string, string> = {
60
+ running: ':hourglass_flowing_sand:',
61
+ idle: ':white_check_mark:',
62
+ ended: ':stop_sign:',
63
+ };
64
+ const labels: Record<string, string> = {
65
+ running: 'Running',
66
+ idle: 'Idle',
67
+ ended: 'Ended',
68
+ };
69
+ return `${icons[status]} ${labels[status]}`;
70
+ }
71
+
72
+ /**
73
+ * Format todo list with status icons
74
+ */
75
+ export function formatTodos(todos: TodoItem[]): string {
76
+ if (todos.length === 0) return '';
77
+
78
+ const icons: Record<string, string> = {
79
+ pending: ':white_circle:',
80
+ in_progress: ':large_blue_circle:',
81
+ completed: ':white_check_mark:',
82
+ };
83
+
84
+ return todos
85
+ .map((t) => {
86
+ const icon = icons[t.status] || ':white_circle:';
87
+ const text = t.status === 'in_progress' && t.activeForm ? t.activeForm : t.content;
88
+ return `${icon} ${text}`;
89
+ })
90
+ .join('\n');
91
+ }