alaska-ai 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.
Files changed (74) hide show
  1. package/README.md +56 -0
  2. package/dist/adapters/slack.d.ts +130 -0
  3. package/dist/adapters/slack.js +1484 -0
  4. package/dist/adapters/slack.js.map +1 -0
  5. package/dist/backends/claude.d.ts +78 -0
  6. package/dist/backends/claude.js +452 -0
  7. package/dist/backends/claude.js.map +1 -0
  8. package/dist/backends/codex.d.ts +53 -0
  9. package/dist/backends/codex.js +324 -0
  10. package/dist/backends/codex.js.map +1 -0
  11. package/dist/cli/init.d.ts +50 -0
  12. package/dist/cli/init.js +386 -0
  13. package/dist/cli/init.js.map +1 -0
  14. package/dist/cli/prompt.d.ts +31 -0
  15. package/dist/cli/prompt.js +145 -0
  16. package/dist/cli/prompt.js.map +1 -0
  17. package/dist/cli/start.d.ts +28 -0
  18. package/dist/cli/start.js +522 -0
  19. package/dist/cli/start.js.map +1 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +65 -0
  22. package/dist/cli.js.map +1 -0
  23. package/dist/index.d.ts +1 -0
  24. package/dist/index.js +8 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/mcp/callbacks.d.ts +32 -0
  27. package/dist/mcp/callbacks.js +181 -0
  28. package/dist/mcp/callbacks.js.map +1 -0
  29. package/dist/mcp/config.d.ts +15 -0
  30. package/dist/mcp/config.js +22 -0
  31. package/dist/mcp/config.js.map +1 -0
  32. package/dist/mcp/entry.d.ts +13 -0
  33. package/dist/mcp/entry.js +119 -0
  34. package/dist/mcp/entry.js.map +1 -0
  35. package/dist/mcp/file-browser.d.ts +15 -0
  36. package/dist/mcp/file-browser.js +135 -0
  37. package/dist/mcp/file-browser.js.map +1 -0
  38. package/dist/mcp/ipc-server.d.ts +64 -0
  39. package/dist/mcp/ipc-server.js +380 -0
  40. package/dist/mcp/ipc-server.js.map +1 -0
  41. package/dist/mcp/preview-server.d.ts +33 -0
  42. package/dist/mcp/preview-server.js +254 -0
  43. package/dist/mcp/preview-server.js.map +1 -0
  44. package/dist/mcp/server.d.ts +51 -0
  45. package/dist/mcp/server.js +257 -0
  46. package/dist/mcp/server.js.map +1 -0
  47. package/dist/mcp/tunnel.d.ts +17 -0
  48. package/dist/mcp/tunnel.js +154 -0
  49. package/dist/mcp/tunnel.js.map +1 -0
  50. package/dist/router.d.ts +113 -0
  51. package/dist/router.js +511 -0
  52. package/dist/router.js.map +1 -0
  53. package/dist/sandbox-policy.d.ts +6 -0
  54. package/dist/sandbox-policy.js +46 -0
  55. package/dist/sandbox-policy.js.map +1 -0
  56. package/dist/scheduler.d.ts +42 -0
  57. package/dist/scheduler.js +169 -0
  58. package/dist/scheduler.js.map +1 -0
  59. package/dist/store.d.ts +95 -0
  60. package/dist/store.js +353 -0
  61. package/dist/store.js.map +1 -0
  62. package/dist/types/adapter.d.ts +50 -0
  63. package/dist/types/adapter.js +9 -0
  64. package/dist/types/adapter.js.map +1 -0
  65. package/dist/types/backend.d.ts +73 -0
  66. package/dist/types/backend.js +8 -0
  67. package/dist/types/backend.js.map +1 -0
  68. package/dist/types/events.d.ts +47 -0
  69. package/dist/types/events.js +9 -0
  70. package/dist/types/events.js.map +1 -0
  71. package/dist/utils.d.ts +59 -0
  72. package/dist/utils.js +272 -0
  73. package/dist/utils.js.map +1 -0
  74. package/package.json +50 -0
@@ -0,0 +1,1484 @@
1
+ /**
2
+ * Slack adapter for Alaska.
3
+ *
4
+ * Connects to Slack via Socket Mode using @slack/bolt.
5
+ * Listens for messages and slash commands, routes them through the router,
6
+ * and posts responses back to the appropriate threads.
7
+ */
8
+ import * as path from 'node:path';
9
+ import * as fs from 'node:fs';
10
+ import { App } from '@slack/bolt';
11
+ import { splitText, downloadAndStageFile, markdownToSlackMrkdwn } from '../utils.js';
12
+ import { resolvePermission, resolveUserQuestion, hasPendingQuestion, resolveQuestionByThread } from '../mcp/ipc-server.js';
13
+ import { clearPostMessageFlag, wasPostMessageCalled, clearScheduleFlag, wasScheduleCreated } from '../mcp/callbacks.js';
14
+ import { requireSandboxRoot, resolveWithinSandbox, validateSandboxRoot } from '../sandbox-policy.js';
15
+ const SLACK_MESSAGE_LIMIT = 4000;
16
+ /**
17
+ * Number of projects to show per page in the project picker.
18
+ * Capped at 20 (Slack allows max 5 action blocks x 5 buttons, minus one row for "Show more").
19
+ */
20
+ const PICKER_PAGE_SIZE = 15;
21
+ export function createBoltApp(botToken, appToken) {
22
+ return new App({
23
+ token: botToken,
24
+ socketMode: true,
25
+ appToken: appToken,
26
+ });
27
+ }
28
+ export class SlackAdapter {
29
+ app;
30
+ router;
31
+ store;
32
+ botUserId = null;
33
+ /** Message refs for pending AskUserQuestion prompts, for updating on resolution. */
34
+ questionMessages = new Map();
35
+ /** Message refs for todo checklist messages, keyed by threadId (one per thread). */
36
+ todoMessages = new Map();
37
+ /** Tracked permission/question ack messages per thread, for :eyes: cleanup after backend responds. */
38
+ permissionAckMessages = new Map();
39
+ constructor(options) {
40
+ this.router = options.router;
41
+ this.store = options.store;
42
+ this.app = options.app ?? createBoltApp(options.botToken, options.appToken);
43
+ this.registerHandlers();
44
+ }
45
+ /** Start the Slack adapter - connects via Socket Mode. */
46
+ async start() {
47
+ await this.app.start();
48
+ // Fetch our own bot user ID to filter out self-messages
49
+ const authResult = await this.app.client.auth.test({ token: this.app.token });
50
+ this.botUserId = authResult.user_id ?? null;
51
+ console.log('[slack] connected via Socket Mode');
52
+ }
53
+ /** Stop the Slack adapter - disconnect. */
54
+ async stop() {
55
+ await this.app.stop();
56
+ console.log('[slack] disconnected');
57
+ }
58
+ /** Get the underlying Bolt app (for testing). */
59
+ getApp() {
60
+ return this.app;
61
+ }
62
+ /** Register all Slack event handlers. */
63
+ registerHandlers() {
64
+ // Message handler
65
+ this.app.message(async ({ message, client }) => {
66
+ await this.handleMessage(message, client);
67
+ });
68
+ // Button action handlers for permission prompts
69
+ this.app.action('permission_allow', async ({ body, ack, client }) => {
70
+ await ack();
71
+ await this.handlePermissionAction(body, 'allow', client);
72
+ });
73
+ this.app.action('permission_deny', async ({ body, ack, client }) => {
74
+ await ack();
75
+ await this.handlePermissionAction(body, 'deny', client);
76
+ });
77
+ // AskUserQuestion dynamic option buttons
78
+ this.app.action(/^question_answer_/, async ({ body, ack, client }) => {
79
+ await ack();
80
+ await this.handleQuestionAnswer(body, client);
81
+ });
82
+ // Project bind/create buttons - delegate to shared helpers
83
+ this.app.action('project_bind_here', async ({ body, ack, client }) => {
84
+ await ack();
85
+ const projectDir = body.actions?.[0]?.value;
86
+ const channelId = body.channel?.id;
87
+ if (!projectDir || !channelId)
88
+ return;
89
+ await this.bindProjectToChannel(channelId, projectDir, client);
90
+ });
91
+ this.app.action('project_create_new', async ({ body, ack, client }) => {
92
+ await ack();
93
+ const projectDir = body.actions?.[0]?.value;
94
+ const sourceChannelId = body.channel?.id;
95
+ if (!projectDir || !sourceChannelId)
96
+ return;
97
+ const userId = body.user?.id;
98
+ const backend = await this.getDefaultBackend();
99
+ await this.createChannelAndBind(sourceChannelId, projectDir, backend, userId, client);
100
+ });
101
+ this.app.action('project_bind_existing', async ({ body, ack, client }) => {
102
+ await ack();
103
+ const action = body.actions?.[0];
104
+ const sourceChannelId = body.channel?.id;
105
+ if (!action?.value || !sourceChannelId)
106
+ return;
107
+ const { channelId: targetChannelId, projectDir } = JSON.parse(action.value);
108
+ await this.bindProjectToChannel(targetChannelId, projectDir, client, sourceChannelId);
109
+ });
110
+ this.app.action(/^project_pick_/, async ({ body, ack, client }) => {
111
+ await ack();
112
+ const action = body.actions?.[0];
113
+ const channelId = body.channel?.id;
114
+ const projectDir = action?.value;
115
+ if (!projectDir || !channelId)
116
+ return;
117
+ await this.handleProjectConnect(channelId, projectDir, { user_id: body.user?.id }, client);
118
+ });
119
+ this.app.action('permission_always_allow', async ({ body, ack, client }) => {
120
+ await ack();
121
+ await this.handlePermissionAction(body, 'always_allow', client);
122
+ });
123
+ this.app.action('perm_mode_supervised', async ({ body, ack, client }) => {
124
+ await ack();
125
+ await this.handlePermModeAction(body, 'supervised', client);
126
+ });
127
+ this.app.action('project_picker_more', async ({ body, ack, client }) => {
128
+ await ack();
129
+ const channelId = body.channel?.id;
130
+ const offset = parseInt(body.actions?.[0]?.value ?? '0', 10);
131
+ if (!channelId)
132
+ return;
133
+ await this.postProjectPicker(channelId, offset, client);
134
+ });
135
+ // Slash commands
136
+ this.app.command('/project', async ({ command, ack, client }) => {
137
+ await ack();
138
+ if (!(await this.ensureInChannel(command.channel_id, client)))
139
+ return;
140
+ await this.handleProjectCommand(command, client);
141
+ });
142
+ this.app.command('/settings', async ({ command, ack, client }) => {
143
+ await ack();
144
+ if (!(await this.ensureInChannel(command.channel_id, client)))
145
+ return;
146
+ await this.handleSettingsCommand(command, client);
147
+ });
148
+ // /schedule subcommands are handled under /settings (schedule list, schedule cancel)
149
+ }
150
+ /**
151
+ * Auto-join a channel if the bot isn't already in it.
152
+ * Returns true if the bot is in the channel, false if it can't join.
153
+ */
154
+ async ensureInChannel(channelId, client) {
155
+ // First check if we're already in the channel (works for both public and private)
156
+ try {
157
+ const info = await client.conversations.info({ channel: channelId });
158
+ if (info.channel?.is_member) {
159
+ return true;
160
+ }
161
+ }
162
+ catch {
163
+ // Can't even see the channel - bot hasn't been invited to a private channel
164
+ console.log(`[slack] cannot access channel ${channelId} - bot may not be invited`);
165
+ return false;
166
+ }
167
+ // Not a member yet - try to join (only works for public channels)
168
+ try {
169
+ await client.conversations.join({ channel: channelId });
170
+ return true;
171
+ }
172
+ catch (err) {
173
+ const errorCode = err?.data?.error;
174
+ // Private channel - bot can see it but can't self-join
175
+ try {
176
+ await client.chat.postMessage({
177
+ channel: channelId,
178
+ text: 'I need to be in this channel first. Run `/invite @Alaska` and try again.',
179
+ });
180
+ }
181
+ catch (postErr) {
182
+ console.error(`[slack] cannot join or post to channel ${channelId}: ${errorCode}, post error: ${postErr.message}`);
183
+ }
184
+ return false;
185
+ }
186
+ }
187
+ /** React with :eyes: to acknowledge a user's message. */
188
+ async reactSeen(channelId, messageTs, client) {
189
+ try {
190
+ await client.reactions.add({ channel: channelId, timestamp: messageTs, name: 'eyes' });
191
+ }
192
+ catch (err) {
193
+ console.error(`[slack] failed to add eyes reaction in ${channelId}: ${err.message}`);
194
+ }
195
+ }
196
+ /** Remove the :eyes: reaction after the backend has responded. */
197
+ async removeReactSeen(channelId, messageTs, client) {
198
+ try {
199
+ await client.reactions.remove({ channel: channelId, timestamp: messageTs, name: 'eyes' });
200
+ }
201
+ catch {
202
+ // Non-fatal - reaction may already be removed or missing
203
+ }
204
+ }
205
+ /** Swap :eyes: to checkmarkto confirm a schedule was created (no text reply needed). */
206
+ async reactScheduleConfirm(channelId, messageTs, client) {
207
+ await this.removeReactSeen(channelId, messageTs, client);
208
+ try {
209
+ await client.reactions.add({ channel: channelId, timestamp: messageTs, name: 'white_check_mark' });
210
+ }
211
+ catch (err) {
212
+ console.error(`[slack] failed to add checkmark reaction in ${channelId}: ${err.message}`);
213
+ }
214
+ }
215
+ /** Track a permission/question ack message for later :eyes: cleanup. */
216
+ trackPermissionAck(threadTs, channelId, messageTs) {
217
+ const list = this.permissionAckMessages.get(threadTs) ?? [];
218
+ list.push({ channel: channelId, ts: messageTs });
219
+ this.permissionAckMessages.set(threadTs, list);
220
+ }
221
+ /** Remove :eyes: from all tracked permission ack messages for a thread. */
222
+ async cleanupPermissionAcks(threadTs, client) {
223
+ const acks = this.permissionAckMessages.get(threadTs);
224
+ if (!acks || acks.length === 0)
225
+ return;
226
+ this.permissionAckMessages.delete(threadTs);
227
+ for (const ack of acks) {
228
+ await this.removeReactSeen(ack.channel, ack.ts, client);
229
+ }
230
+ }
231
+ /** Handle an incoming Slack message. */
232
+ async handleMessage(message, client) {
233
+ // Ignore bot messages (including our own) and system messages
234
+ // Allow file_share subtype through so image+text messages are handled
235
+ if (message.bot_id) {
236
+ return;
237
+ }
238
+ if (message.subtype && message.subtype !== 'file_share') {
239
+ return;
240
+ }
241
+ if (this.botUserId && message.user === this.botUserId) {
242
+ return;
243
+ }
244
+ const channelId = message.channel;
245
+ const text = message.text || '';
246
+ let threadTs = message.thread_ts || null;
247
+ // Check if this channel is bound to a project
248
+ const project = await this.store.getProjectByChannelId(channelId);
249
+ if (!project) {
250
+ return; // Not a bound channel, ignore
251
+ }
252
+ // Handle text commands in threads (slash commands don't work in Slack threads)
253
+ if (threadTs) {
254
+ const trimmed = text.trim().toLowerCase();
255
+ if (trimmed === 'cancel') {
256
+ try {
257
+ const cancelled = await this.router.cancelBackend(channelId, threadTs);
258
+ if (cancelled) {
259
+ await client.chat.postMessage({
260
+ channel: channelId,
261
+ thread_ts: threadTs,
262
+ text: 'Cancelling the running task...',
263
+ });
264
+ }
265
+ else {
266
+ await client.chat.postMessage({
267
+ channel: channelId,
268
+ thread_ts: threadTs,
269
+ text: 'Nothing to cancel - no task is currently running in this thread.',
270
+ });
271
+ }
272
+ }
273
+ catch (err) {
274
+ await this.postError(channelId, threadTs, err.message, client);
275
+ }
276
+ return;
277
+ }
278
+ if (trimmed === 'new') {
279
+ try {
280
+ await this.router.resetSession(channelId, threadTs);
281
+ await client.chat.postMessage({
282
+ channel: channelId,
283
+ thread_ts: threadTs,
284
+ text: 'Session reset. Your next message will start a fresh conversation.',
285
+ });
286
+ }
287
+ catch (err) {
288
+ await this.postError(channelId, threadTs, err.message, client);
289
+ }
290
+ return;
291
+ }
292
+ }
293
+ // If this is a top-level message (no thread_ts), create a new thread
294
+ if (!threadTs) {
295
+ threadTs = message.ts;
296
+ }
297
+ // React with :eyes: to acknowledge the user's message
298
+ await this.reactSeen(channelId, message.ts, client);
299
+ // Handle file attachments - route through handleFileUpload
300
+ if (Array.isArray(message.files) && message.files.length > 0) {
301
+ await this.handleFileUpload(channelId, threadTs, message.files, text, client, message.ts);
302
+ return;
303
+ }
304
+ // If there's a pending AskUserQuestion for this thread, resolve it with the typed text
305
+ if (hasPendingQuestion(threadTs)) {
306
+ const requestId = resolveQuestionByThread(threadTs, text);
307
+ console.log(`[slack] resolved pending question for thread ${threadTs} with typed response`);
308
+ // Update the button message to show it's been answered
309
+ if (requestId) {
310
+ const msgRef = this.questionMessages.get(requestId);
311
+ if (msgRef) {
312
+ this.questionMessages.delete(requestId);
313
+ try {
314
+ const updatedBlocks = [...msgRef.blocks, { type: 'section', text: { type: 'mrkdwn', text: `*Answered:* ${text}` } }];
315
+ await client.chat.update({
316
+ channel: msgRef.channel,
317
+ ts: msgRef.ts,
318
+ text: `Answered: ${text}`,
319
+ blocks: updatedBlocks,
320
+ });
321
+ }
322
+ catch (err) {
323
+ console.error(`[slack] failed to update question message for thread ${threadTs}: ${err.message}`);
324
+ }
325
+ }
326
+ }
327
+ // Track the :eyes: on this freeform message so it gets cleaned up
328
+ // when the original handleMessage call finishes (cleanupPermissionAcks)
329
+ this.trackPermissionAck(threadTs, channelId, message.ts);
330
+ return;
331
+ }
332
+ // Check if the session is waiting_for_input (freeform text response)
333
+ const session = await this.store.getSessionByThreadId(threadTs);
334
+ if (session && session.state === 'waiting_for_input') {
335
+ await this.handleFreeformResponse(channelId, threadTs, text, client, message.ts);
336
+ return;
337
+ }
338
+ // Route through the router
339
+ clearPostMessageFlag(threadTs);
340
+ clearScheduleFlag(threadTs);
341
+ let result;
342
+ try {
343
+ result = await this.router.send(channelId, threadTs, text);
344
+ }
345
+ catch (err) {
346
+ await this.postError(channelId, threadTs, err.message, client);
347
+ return;
348
+ }
349
+ // If a schedule was created, swap eyes ->checkmark and suppress text
350
+ const scheduleCreated = wasScheduleCreated(threadTs);
351
+ clearScheduleFlag(threadTs); // Always clear after checking to avoid stale flags
352
+ if (scheduleCreated) {
353
+ await this.reactScheduleConfirm(channelId, message.ts, client);
354
+ await this.cleanupPermissionAcks(threadTs, client);
355
+ }
356
+ else {
357
+ await this.renderEvents(channelId, threadTs, result.events, client);
358
+ await this.removeReactSeen(channelId, message.ts, client);
359
+ await this.cleanupPermissionAcks(threadTs, client);
360
+ }
361
+ }
362
+ /** Handle freeform text when session is waiting_for_input. */
363
+ async handleFreeformResponse(channelId, threadTs, text, client, messageTs) {
364
+ clearPostMessageFlag(threadTs);
365
+ clearScheduleFlag(threadTs);
366
+ let result;
367
+ try {
368
+ result = await this.router.respond(channelId, threadTs, text);
369
+ }
370
+ catch (err) {
371
+ await this.postError(channelId, threadTs, err.message, client);
372
+ return;
373
+ }
374
+ await this.renderEvents(channelId, threadTs, result.events, client);
375
+ if (messageTs)
376
+ await this.removeReactSeen(channelId, messageTs, client);
377
+ await this.cleanupPermissionAcks(threadTs, client);
378
+ }
379
+ /** Handle permission Allow/Deny/Always Allow button clicks.
380
+ * Button value is "toolName|requestId" (hook flow) or "toolName" (legacy). */
381
+ async handlePermissionAction(body, action, client) {
382
+ const channelId = body.channel?.id;
383
+ const threadTs = body.message?.thread_ts;
384
+ const messageTs = body.message?.ts;
385
+ const rawValue = body.actions?.[0]?.value ?? '';
386
+ if (!channelId || !threadTs) {
387
+ return;
388
+ }
389
+ // Parse button value: "toolName|requestId" or just "toolName"
390
+ const pipeIdx = rawValue.indexOf('|');
391
+ const toolName = pipeIdx >= 0 ? rawValue.slice(0, pipeIdx) : rawValue;
392
+ const requestId = pipeIdx >= 0 ? rawValue.slice(pipeIdx + 1) : undefined;
393
+ // For "always_allow", persist the tool pattern for future sessions
394
+ if (action === 'always_allow' && toolName) {
395
+ const project = await this.store.getProjectByChannelId(channelId);
396
+ if (project) {
397
+ await this.store.addAllowedTool(project.id, toolName);
398
+ console.log(`[slack] added always-allow tool '${toolName}' for project ${project.id}`);
399
+ }
400
+ }
401
+ // Determine display label and effective action
402
+ const isAllow = action === 'allow' || action === 'always_allow';
403
+ const actionLabel = action === 'always_allow' ? 'Always Allowed' : (isAllow ? 'Allowed' : 'Denied');
404
+ // Update the original message to show which action was taken
405
+ try {
406
+ await client.chat.update({
407
+ channel: channelId,
408
+ ts: messageTs,
409
+ text: `Permission: ${actionLabel}`,
410
+ blocks: [
411
+ {
412
+ type: 'section',
413
+ text: {
414
+ type: 'mrkdwn',
415
+ text: `*Permission ${actionLabel}*`,
416
+ },
417
+ },
418
+ ],
419
+ });
420
+ }
421
+ catch (err) {
422
+ console.error(`[slack] failed to update permission message in ${channelId}: ${err.message}`);
423
+ }
424
+ // React with :eyes: on the permission message to acknowledge the click
425
+ if (channelId && messageTs) {
426
+ await this.reactSeen(channelId, messageTs, client);
427
+ this.trackPermissionAck(threadTs, channelId, messageTs);
428
+ }
429
+ // Hook-based flow: resolve in-process, no need to call router.respond()
430
+ if (requestId) {
431
+ const decision = isAllow ? 'allow' : 'deny';
432
+ resolvePermission(requestId, decision);
433
+ console.log(`[slack] resolved permission ${requestId} ->${decision}`);
434
+ return;
435
+ }
436
+ // Legacy flow: route through router.respond()
437
+ const responseText = isAllow ? 'yes' : 'no';
438
+ const allowedTools = isAllow && toolName ? [toolName] : undefined;
439
+ clearPostMessageFlag(threadTs);
440
+ clearScheduleFlag(threadTs);
441
+ let result;
442
+ try {
443
+ result = await this.router.respond(channelId, threadTs, responseText, allowedTools);
444
+ }
445
+ catch (err) {
446
+ await this.postError(channelId, threadTs, err.message, client);
447
+ return;
448
+ }
449
+ await this.renderEvents(channelId, threadTs, result.events, client);
450
+ await this.cleanupPermissionAcks(threadTs, client);
451
+ }
452
+ /** Render normalized events as Slack messages in a thread. */
453
+ async renderEvents(channelId, threadTs, events, client) {
454
+ // Deduplicate consecutive identical permission_denied events
455
+ const seenDenials = new Set();
456
+ const deduped = events.filter((event) => {
457
+ if (event.type === 'permission_denied') {
458
+ const key = `${event.toolName}:${JSON.stringify(event.toolInput)}`;
459
+ if (seenDenials.has(key))
460
+ return false;
461
+ seenDenials.add(key);
462
+ }
463
+ return true;
464
+ });
465
+ // If Claude used post_message during this turn, suppress assistant_text
466
+ // (Claude already communicated directly). Otherwise, render only the last
467
+ // assistant_text block as a fallback summary.
468
+ const postMessageUsed = wasPostMessageCalled(threadTs);
469
+ const assistantTexts = deduped.filter(e => e.type === 'assistant_text');
470
+ const lastAssistantText = assistantTexts.length > 0
471
+ ? assistantTexts[assistantTexts.length - 1]
472
+ : null;
473
+ for (const event of deduped) {
474
+ switch (event.type) {
475
+ case 'assistant_text':
476
+ if (!postMessageUsed && event === lastAssistantText) {
477
+ await this.postText(channelId, threadTs, event.text, client);
478
+ }
479
+ break;
480
+ case 'permission_denied':
481
+ // AskUserQuestion denials are expected - the hook already rendered buttons
482
+ if (event.toolName === 'AskUserQuestion')
483
+ break;
484
+ if (event.toolName === 'sandbox') {
485
+ await this.postSandboxDeniedNotice(channelId, threadTs, event.context || '', client);
486
+ }
487
+ else {
488
+ await this.postPermissionPrompt(channelId, threadTs, event, client);
489
+ }
490
+ break;
491
+ case 'error':
492
+ await this.postError(channelId, threadTs, event.message, client);
493
+ break;
494
+ // Other event types (tool_use, tool_result, command_execution, etc.)
495
+ // are not rendered to the user - they're internal to the backend
496
+ default:
497
+ break;
498
+ }
499
+ }
500
+ }
501
+ /** Post text response, splitting if it exceeds Slack's limit. */
502
+ async postText(channelId, threadTs, text, client) {
503
+ const converted = markdownToSlackMrkdwn(text);
504
+ if (converted.length <= SLACK_MESSAGE_LIMIT) {
505
+ await client.chat.postMessage({
506
+ channel: channelId,
507
+ thread_ts: threadTs,
508
+ text: converted,
509
+ });
510
+ }
511
+ else {
512
+ // Split into chunks
513
+ const chunks = splitText(converted, SLACK_MESSAGE_LIMIT);
514
+ for (const chunk of chunks) {
515
+ await client.chat.postMessage({
516
+ channel: channelId,
517
+ thread_ts: threadTs,
518
+ text: chunk,
519
+ });
520
+ }
521
+ }
522
+ }
523
+ /** Post a permission denial prompt with Allow/Deny buttons.
524
+ * When event.requestId is set (hook-based flow), it's embedded in button values
525
+ * so the action handler can resolve the permission in-process. */
526
+ async postPermissionPrompt(channelId, threadTs, event, client) {
527
+ let inputStr = JSON.stringify(event.toolInput, null, 2);
528
+ // Truncate tool input to avoid exceeding Slack's 3000-char block text limit
529
+ const MAX_INPUT_DISPLAY = 500;
530
+ if (inputStr.length > MAX_INPUT_DISPLAY) {
531
+ inputStr = inputStr.slice(0, MAX_INPUT_DISPLAY) + '\n... (truncated)';
532
+ }
533
+ const contextStr = event.context ? `\n${event.context}` : '';
534
+ // Embed requestId in button value: "toolName|requestId" (hook flow) or "toolName" (legacy)
535
+ const buttonValue = event.requestId
536
+ ? `${event.toolName}|${event.requestId}`
537
+ : event.toolName;
538
+ // When called from IPC callback (requestPermission), client is null - use stored client
539
+ const chatClient = client ?? this.app.client;
540
+ await chatClient.chat.postMessage({
541
+ channel: channelId,
542
+ thread_ts: threadTs,
543
+ text: `Permission requested: ${event.toolName}`,
544
+ blocks: [
545
+ {
546
+ type: 'section',
547
+ text: {
548
+ type: 'mrkdwn',
549
+ text: `*Permission requested: \`${event.toolName}\`*\n\`\`\`${inputStr}\`\`\`${contextStr}`,
550
+ },
551
+ },
552
+ {
553
+ type: 'actions',
554
+ elements: [
555
+ {
556
+ type: 'button',
557
+ text: { type: 'plain_text', text: 'Allow' },
558
+ style: 'primary',
559
+ action_id: 'permission_allow',
560
+ value: buttonValue,
561
+ },
562
+ {
563
+ type: 'button',
564
+ text: { type: 'plain_text', text: 'Always Allow' },
565
+ action_id: 'permission_always_allow',
566
+ value: buttonValue,
567
+ },
568
+ {
569
+ type: 'button',
570
+ text: { type: 'plain_text', text: 'Deny' },
571
+ style: 'danger',
572
+ action_id: 'permission_deny',
573
+ value: buttonValue,
574
+ },
575
+ ],
576
+ },
577
+ {
578
+ type: 'context',
579
+ elements: [
580
+ {
581
+ type: 'mrkdwn',
582
+ text: '_or type a custom response_',
583
+ },
584
+ ],
585
+ },
586
+ ],
587
+ });
588
+ }
589
+ /** Post an interactive question with dynamic option buttons.
590
+ * Renders the first question from AskUserQuestion as Slack blocks with buttons. */
591
+ async postUserQuestion(channelId, threadTs, questions, requestId, client) {
592
+ const chatClient = client ?? this.app.client;
593
+ const q = questions[0];
594
+ if (!q)
595
+ return;
596
+ const optionLines = q.options.map((o) => `- *${o.label}*${o.description ? ` - ${o.description}` : ''}`).join('\n');
597
+ const buttons = q.options.map((opt, i) => ({
598
+ type: 'button',
599
+ text: { type: 'plain_text', text: opt.label.slice(0, 75) },
600
+ action_id: `question_answer_${i}`,
601
+ value: requestId,
602
+ }));
603
+ const questionBlock = {
604
+ type: 'section',
605
+ text: { type: 'mrkdwn', text: `*${q.question}*\n${optionLines}` },
606
+ };
607
+ const result = await chatClient.chat.postMessage({
608
+ channel: channelId,
609
+ thread_ts: threadTs,
610
+ text: q.question,
611
+ blocks: [questionBlock, { type: 'actions', elements: buttons }],
612
+ });
613
+ if (result.ts) {
614
+ this.questionMessages.set(requestId, { channel: channelId, ts: result.ts, blocks: [questionBlock] });
615
+ }
616
+ }
617
+ /** Handle AskUserQuestion option button clicks.
618
+ * Resolves the pending question via IPC and updates the message. */
619
+ async handleQuestionAnswer(body, client) {
620
+ const action = body.actions?.[0];
621
+ if (!action)
622
+ return;
623
+ const requestId = action.value;
624
+ const label = action.text?.text ?? '';
625
+ const channelId = body.channel?.id;
626
+ const messageTs = body.message?.ts;
627
+ const threadTs = body.message?.thread_ts;
628
+ if (!requestId || !label)
629
+ return;
630
+ resolveUserQuestion(requestId, label);
631
+ this.questionMessages.delete(requestId);
632
+ console.log(`[slack] resolved question ${requestId} ->"${label}"`);
633
+ if (channelId && messageTs) {
634
+ try {
635
+ // Keep original question/options blocks, remove action buttons, append answer
636
+ const origBlocks = (body.message?.blocks ?? []);
637
+ const keptBlocks = origBlocks.filter((b) => b.type !== 'actions');
638
+ keptBlocks.push({ type: 'section', text: { type: 'mrkdwn', text: `*Answered:* ${label}` } });
639
+ await client.chat.update({
640
+ channel: channelId,
641
+ ts: messageTs,
642
+ text: `Answered: ${label}`,
643
+ blocks: keptBlocks,
644
+ });
645
+ }
646
+ catch (err) {
647
+ console.error(`[slack] failed to update question answer message in ${channelId}: ${err.message}`);
648
+ }
649
+ }
650
+ // React with :eyes: on the question message to acknowledge the click
651
+ if (channelId && messageTs) {
652
+ await this.reactSeen(channelId, messageTs, client);
653
+ if (threadTs)
654
+ this.trackPermissionAck(threadTs, channelId, messageTs);
655
+ }
656
+ }
657
+ /** Post an error message. */
658
+ async postError(channelId, threadTs, message, client) {
659
+ const MAX_ERROR = 3000;
660
+ const truncated = message.length > MAX_ERROR
661
+ ? message.slice(0, MAX_ERROR) + '\n... (truncated)'
662
+ : message;
663
+ await client.chat.postMessage({
664
+ channel: channelId,
665
+ thread_ts: threadTs,
666
+ text: `:warning: *Error:* ${truncated}`,
667
+ });
668
+ }
669
+ /** Build the list of /project subcommand descriptions. */
670
+ async getProjectCommandLines() {
671
+ const root = await this.store.getSetting('projects_root');
672
+ const lines = [
673
+ '- `/project new my-app` - create a new project under the sandbox root and connect it',
674
+ ];
675
+ if (root) {
676
+ lines.push('- `/project connect` - pick a project from your projects root');
677
+ lines.push('- `/project connect <path-under-root>` - connect a specific directory in the sandbox root');
678
+ }
679
+ else {
680
+ lines.push('- Configure sandbox root first with `/settings root /absolute/path`');
681
+ }
682
+ lines.push('- `/project list` - show all connected projects');
683
+ lines.push('- `/project disconnect` - disconnect this channel');
684
+ lines.push('- `/project backend codex` - switch the AI backend for this project');
685
+ return lines;
686
+ }
687
+ /** Handle /project slash command. */
688
+ async handleProjectCommand(command, client) {
689
+ const channelId = command.channel_id;
690
+ const rawArgs = (command.text || '').trim();
691
+ const parts = rawArgs.split(/\s+/);
692
+ const subcommand = parts[0] || '';
693
+ const subArg = parts.slice(1).join(' ');
694
+ // /project (no args) or /project help - show usage
695
+ if (!subcommand || subcommand === 'help') {
696
+ const lines = [
697
+ '*`/project` commands:*',
698
+ ...(await this.getProjectCommandLines()),
699
+ '',
700
+ '*Other commands:*',
701
+ '- `/settings` - view bridge settings',
702
+ '- `/settings root /path` - set projects root folder',
703
+ '- `/settings schedule list` - list active schedules',
704
+ '- `/settings schedule cancel <id>` - cancel a schedule',
705
+ ];
706
+ await client.chat.postMessage({
707
+ channel: channelId,
708
+ text: lines.join('\n'),
709
+ });
710
+ return;
711
+ }
712
+ // /project list
713
+ if (subcommand === 'list') {
714
+ const projects = await this.store.listProjects();
715
+ if (projects.length === 0) {
716
+ const root = await this.store.getSetting('projects_root');
717
+ const hint = root
718
+ ? 'Use `/project connect` to pick one, or `/project connect <path-under-root>`.'
719
+ : 'Set sandbox root first with `/settings root /absolute/path`.';
720
+ await client.chat.postMessage({
721
+ channel: channelId,
722
+ text: `No projects connected to any channels yet. ${hint}`,
723
+ });
724
+ return;
725
+ }
726
+ let text = '*Connected Projects:*\n';
727
+ for (const p of projects) {
728
+ const channelLabel = p.platform === 'slack'
729
+ ? `<#${p.channel_id}>`
730
+ : `${p.platform} channel`;
731
+ text += `- ${channelLabel} ->\`${p.project_dir}\` (${p.backend_name})\n`;
732
+ }
733
+ await client.chat.postMessage({ channel: channelId, text });
734
+ return;
735
+ }
736
+ // /project disconnect
737
+ if (subcommand === 'disconnect') {
738
+ const project = await this.store.getProjectByChannelId(channelId);
739
+ if (!project) {
740
+ await client.chat.postMessage({
741
+ channel: channelId,
742
+ text: 'This channel is not connected to a project.',
743
+ });
744
+ }
745
+ else {
746
+ await this.store.deleteProject(project.id);
747
+ await client.chat.postMessage({
748
+ channel: channelId,
749
+ text: `Disconnected this channel from \`${project.project_dir}\`.`,
750
+ });
751
+ }
752
+ return;
753
+ }
754
+ // /project new <name|path-under-root|absolute-path-inside-root>
755
+ if (subcommand === 'new') {
756
+ const name = subArg;
757
+ if (!name) {
758
+ await client.chat.postMessage({
759
+ channel: channelId,
760
+ text: ':warning: Usage: `/project new my-app` or `/project new team/my-app`',
761
+ });
762
+ return;
763
+ }
764
+ let sandboxRoot;
765
+ try {
766
+ sandboxRoot = await requireSandboxRoot(this.store);
767
+ }
768
+ catch (err) {
769
+ await client.chat.postMessage({
770
+ channel: channelId,
771
+ text: `:warning: ${err.message}`,
772
+ });
773
+ return;
774
+ }
775
+ let targetDir;
776
+ try {
777
+ targetDir = resolveWithinSandbox(name, sandboxRoot);
778
+ }
779
+ catch (err) {
780
+ await client.chat.postMessage({
781
+ channel: channelId,
782
+ text: `:warning: ${err.message}`,
783
+ });
784
+ return;
785
+ }
786
+ if (fs.existsSync(targetDir)) {
787
+ await client.chat.postMessage({
788
+ channel: channelId,
789
+ text: `:warning: Directory already exists: \`${targetDir}\`\nUse \`/project connect ${targetDir}\` to connect to it instead.`,
790
+ });
791
+ return;
792
+ }
793
+ try {
794
+ fs.mkdirSync(targetDir, { recursive: true });
795
+ }
796
+ catch (err) {
797
+ await client.chat.postMessage({
798
+ channel: channelId,
799
+ text: `:warning: Failed to create directory: ${err.message}`,
800
+ });
801
+ return;
802
+ }
803
+ // Bind to this channel (or create a new channel if already bound)
804
+ await this.handleProjectConnect(channelId, targetDir, command, client);
805
+ return;
806
+ }
807
+ // /project connect [path-under-root]
808
+ if (subcommand === 'connect') {
809
+ const projectDir = subArg;
810
+ if (!projectDir) {
811
+ // No path given - show picker if sandbox root is configured
812
+ try {
813
+ const root = await requireSandboxRoot(this.store);
814
+ if (!fs.existsSync(root)) {
815
+ throw new Error(`Sandbox root does not exist: ${root}`);
816
+ }
817
+ await this.postProjectPicker(channelId, 0, client);
818
+ }
819
+ catch (err) {
820
+ await client.chat.postMessage({
821
+ channel: channelId,
822
+ text: `:warning: ${err.message}`,
823
+ });
824
+ }
825
+ return;
826
+ }
827
+ let sandboxRoot;
828
+ try {
829
+ sandboxRoot = await requireSandboxRoot(this.store);
830
+ }
831
+ catch (err) {
832
+ await client.chat.postMessage({
833
+ channel: channelId,
834
+ text: `:warning: ${err.message}`,
835
+ });
836
+ return;
837
+ }
838
+ let resolvedProjectDir;
839
+ try {
840
+ resolvedProjectDir = resolveWithinSandbox(projectDir, sandboxRoot);
841
+ }
842
+ catch (err) {
843
+ await client.chat.postMessage({
844
+ channel: channelId,
845
+ text: `:warning: ${err.message}`,
846
+ });
847
+ return;
848
+ }
849
+ if (!fs.existsSync(resolvedProjectDir) || !fs.statSync(resolvedProjectDir).isDirectory()) {
850
+ await client.chat.postMessage({
851
+ channel: channelId,
852
+ text: `:warning: Directory not found in sandbox root: \`${resolvedProjectDir}\``,
853
+ });
854
+ return;
855
+ }
856
+ await this.handleProjectConnect(channelId, resolvedProjectDir, command, client);
857
+ return;
858
+ }
859
+ // /project backend [claude|codex]
860
+ if (subcommand === 'backend') {
861
+ const project = await this.store.getProjectByChannelId(channelId);
862
+ if (!project) {
863
+ await client.chat.postMessage({
864
+ channel: channelId,
865
+ text: 'This channel is not connected to a project. Use `/project connect` first.',
866
+ });
867
+ return;
868
+ }
869
+ if (!subArg) {
870
+ await client.chat.postMessage({
871
+ channel: channelId,
872
+ text: `Current backend: \`${project.backend_name}\`. Use \`/project backend claude\` or \`/project backend codex\` to switch.`,
873
+ });
874
+ return;
875
+ }
876
+ if (!['claude', 'codex'].includes(subArg)) {
877
+ await client.chat.postMessage({
878
+ channel: channelId,
879
+ text: `:warning: Unknown backend \`${subArg}\`. Valid options: \`claude\`, \`codex\``,
880
+ });
881
+ return;
882
+ }
883
+ await this.store.updateProjectBackend(project.id, subArg);
884
+ await client.chat.postMessage({
885
+ channel: channelId,
886
+ text: `Backend changed to \`${subArg}\` for this project.`,
887
+ });
888
+ return;
889
+ }
890
+ // /project info
891
+ if (subcommand === 'info') {
892
+ const project = await this.store.getProjectByChannelId(channelId);
893
+ if (!project) {
894
+ await client.chat.postMessage({
895
+ channel: channelId,
896
+ text: 'No project connected to this channel. Use `/project connect` to set one up.',
897
+ });
898
+ return;
899
+ }
900
+ const backendLabel = project.backend_name === 'claude' ? 'Claude Code' : 'Codex';
901
+ const text = [
902
+ '*Project Info*',
903
+ `- Path: \`${project.project_dir}\``,
904
+ `- Backend: ${backendLabel}`,
905
+ `- Permissions: ${project.permission_mode}`,
906
+ ].join('\n');
907
+ await client.chat.postMessage({ channel: channelId, text });
908
+ return;
909
+ }
910
+ // Backwards compat: /project /absolute/path still works
911
+ if (path.isAbsolute(subcommand + (subArg ? ' ' + subArg : ''))) {
912
+ const projectDir = rawArgs;
913
+ await this.handleProjectConnect(channelId, projectDir, command, client);
914
+ return;
915
+ }
916
+ await client.chat.postMessage({
917
+ channel: channelId,
918
+ text: [
919
+ `:warning: Unsupported command \`${subcommand}\`. Try one of these:`,
920
+ ...(await this.getProjectCommandLines()),
921
+ ].join('\n'),
922
+ });
923
+ }
924
+ /** Handle project connect flow - bind a directory to a channel. */
925
+ async handleProjectConnect(channelId, projectDir, command, client) {
926
+ let resolvedProjectDir;
927
+ try {
928
+ const sandboxRoot = await requireSandboxRoot(this.store);
929
+ resolvedProjectDir = resolveWithinSandbox(projectDir, sandboxRoot);
930
+ }
931
+ catch (err) {
932
+ await client.chat.postMessage({
933
+ channel: channelId,
934
+ text: `:warning: ${err.message}`,
935
+ });
936
+ return;
937
+ }
938
+ if (!fs.existsSync(resolvedProjectDir) || !fs.statSync(resolvedProjectDir).isDirectory()) {
939
+ await client.chat.postMessage({
940
+ channel: channelId,
941
+ text: `:warning: Directory not found: \`${resolvedProjectDir}\``,
942
+ });
943
+ return;
944
+ }
945
+ const existing = await this.store.getProjectByChannelId(channelId);
946
+ if (existing) {
947
+ // Channel already bound - create a new channel for this project
948
+ await this.createChannelAndBind(channelId, resolvedProjectDir, existing.backend_name, command.user_id, client);
949
+ }
950
+ else {
951
+ // Channel is unbound - offer bind options
952
+ const channelName = path.basename(resolvedProjectDir);
953
+ await client.chat.postMessage({
954
+ channel: channelId,
955
+ text: `Connect project \`${resolvedProjectDir}\` to this channel?`,
956
+ blocks: [
957
+ {
958
+ type: 'section',
959
+ text: {
960
+ type: 'mrkdwn',
961
+ text: `Connect project directory \`${resolvedProjectDir}\`?`,
962
+ },
963
+ },
964
+ {
965
+ type: 'actions',
966
+ elements: [
967
+ {
968
+ type: 'button',
969
+ text: { type: 'plain_text', text: 'Use this channel' },
970
+ action_id: 'project_bind_here',
971
+ value: resolvedProjectDir,
972
+ },
973
+ {
974
+ type: 'button',
975
+ text: { type: 'plain_text', text: `Create #${channelName}` },
976
+ action_id: 'project_create_new',
977
+ value: resolvedProjectDir,
978
+ },
979
+ ],
980
+ },
981
+ ],
982
+ });
983
+ }
984
+ }
985
+ /** Post a project picker with pagination support. */
986
+ async postProjectPicker(channelId, offset, client) {
987
+ const PAGE_SIZE = Math.min(PICKER_PAGE_SIZE, 20);
988
+ let root;
989
+ try {
990
+ root = await requireSandboxRoot(this.store);
991
+ }
992
+ catch {
993
+ return;
994
+ }
995
+ const entries = fs.readdirSync(root, { withFileTypes: true })
996
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
997
+ .map(e => e.name)
998
+ .sort();
999
+ if (entries.length === 0) {
1000
+ await client.chat.postMessage({
1001
+ channel: channelId,
1002
+ text: `:warning: No subdirectories found in \`${root}\`.`,
1003
+ });
1004
+ return;
1005
+ }
1006
+ const page = entries.slice(offset, offset + PAGE_SIZE);
1007
+ const hasMore = offset + PAGE_SIZE < entries.length;
1008
+ const buttons = page.map((name) => ({
1009
+ type: 'button',
1010
+ text: { type: 'plain_text', text: name },
1011
+ action_id: `project_pick_${name}`,
1012
+ value: path.join(root, name),
1013
+ }));
1014
+ const actionBlocks = [];
1015
+ for (let i = 0; i < buttons.length; i += 5) {
1016
+ actionBlocks.push({
1017
+ type: 'actions',
1018
+ elements: buttons.slice(i, i + 5),
1019
+ });
1020
+ }
1021
+ // Add "Show more" button if there are more entries
1022
+ if (hasMore) {
1023
+ actionBlocks.push({
1024
+ type: 'actions',
1025
+ elements: [{
1026
+ type: 'button',
1027
+ text: { type: 'plain_text', text: `Show more (${entries.length - offset - PAGE_SIZE} remaining)` },
1028
+ action_id: 'project_picker_more',
1029
+ value: String(offset + PAGE_SIZE),
1030
+ }],
1031
+ });
1032
+ }
1033
+ const rangeLabel = offset > 0
1034
+ ? `*Projects from* \`${root}\` *(${offset + 1}-${offset + page.length} of ${entries.length}):*`
1035
+ : `*Pick a project from* \`${root}\`:`;
1036
+ await client.chat.postMessage({
1037
+ channel: channelId,
1038
+ text: `Pick a project from \`${root}\`:`,
1039
+ blocks: [
1040
+ {
1041
+ type: 'section',
1042
+ text: { type: 'mrkdwn', text: rangeLabel },
1043
+ },
1044
+ ...actionBlocks,
1045
+ {
1046
+ type: 'context',
1047
+ elements: [{ type: 'mrkdwn', text: '_Use `/project connect <path-under-root>` for nested directories._' }],
1048
+ },
1049
+ ],
1050
+ });
1051
+ }
1052
+ /** Get the default backend, or throw if not configured. */
1053
+ async getDefaultBackend() {
1054
+ const backend = await this.store.getSetting('default_backend');
1055
+ if (!backend) {
1056
+ throw new Error('No default backend configured. Run the setup wizard first.');
1057
+ }
1058
+ return backend;
1059
+ }
1060
+ /** Handle permission mode button action. */
1061
+ async handlePermModeAction(body, mode, client) {
1062
+ const action = body.actions?.[0];
1063
+ const channelId = body.channel?.id;
1064
+ if (!action?.value || !channelId)
1065
+ return;
1066
+ const projectId = parseInt(action.value.split(':')[1], 10);
1067
+ await this.store.updatePermissionMode(projectId, 'supervised');
1068
+ await client.chat.postMessage({
1069
+ channel: channelId,
1070
+ text: 'Permission mode is fixed to *supervised* for sandbox isolation.',
1071
+ });
1072
+ }
1073
+ /** Post a sandbox denial notice (no upgrade path). */
1074
+ async postSandboxDeniedNotice(channelId, threadTs, context, client) {
1075
+ const detail = context ? `\n\`\`\`${context.slice(0, 500)}\`\`\`` : '';
1076
+ await client.chat.postMessage({
1077
+ channel: channelId,
1078
+ thread_ts: threadTs,
1079
+ text: `Sandbox denied this action.${detail}`,
1080
+ });
1081
+ }
1082
+ /** Post permission mode selection prompt after project creation. */
1083
+ async postPermissionModePrompt(channelId, projectId, client) {
1084
+ await client.chat.postMessage({
1085
+ channel: channelId,
1086
+ text: 'Permission mode for this project:',
1087
+ blocks: [
1088
+ {
1089
+ type: 'section',
1090
+ text: {
1091
+ type: 'mrkdwn',
1092
+ text: '*Permission mode:* This deployment always uses *Supervised* for sandbox isolation.',
1093
+ },
1094
+ },
1095
+ {
1096
+ type: 'actions',
1097
+ elements: [
1098
+ {
1099
+ type: 'button',
1100
+ text: { type: 'plain_text', text: 'Supervised' },
1101
+ action_id: 'perm_mode_supervised',
1102
+ value: `supervised:${projectId}`,
1103
+ style: 'primary',
1104
+ },
1105
+ ],
1106
+ },
1107
+ ],
1108
+ });
1109
+ }
1110
+ /** Bind a project to a channel and post confirmation. */
1111
+ async bindProjectToChannel(channelId, projectDir, client, notifyChannelId) {
1112
+ const backend = await this.getDefaultBackend();
1113
+ const notify = notifyChannelId ?? channelId;
1114
+ try {
1115
+ const project = await this.store.createProject(channelId, projectDir, backend, 'slack');
1116
+ const label = notifyChannelId ? `<#${channelId}>` : 'this channel';
1117
+ await client.chat.postMessage({
1118
+ channel: notify,
1119
+ text: `Connected ${label} to project \`${projectDir}\` (${backend})`,
1120
+ });
1121
+ await this.postPermissionModePrompt(notify, project.id, client);
1122
+ }
1123
+ catch (err) {
1124
+ await client.chat.postMessage({
1125
+ channel: notify,
1126
+ text: `:warning: Failed to connect: ${err.message}`,
1127
+ });
1128
+ }
1129
+ }
1130
+ /** Create a new Slack channel, bind a project to it, and invite the user. */
1131
+ async createChannelAndBind(sourceChannelId, projectDir, backend, userId, client) {
1132
+ const channelName = path.basename(projectDir);
1133
+ try {
1134
+ const result = await client.conversations.create({ name: channelName });
1135
+ const newChannelId = result.channel?.id;
1136
+ if (newChannelId) {
1137
+ const project = await this.store.createProject(newChannelId, projectDir, backend, 'slack');
1138
+ if (userId) {
1139
+ await client.conversations.invite({ channel: newChannelId, users: userId }).catch((err) => {
1140
+ console.error(`[slack] failed to invite user ${userId} to channel ${newChannelId}: ${err.message}`);
1141
+ });
1142
+ }
1143
+ await client.chat.postMessage({
1144
+ channel: sourceChannelId,
1145
+ text: `Created and connected <#${newChannelId}> to project \`${projectDir}\` (${backend})`,
1146
+ });
1147
+ await this.postPermissionModePrompt(newChannelId, project.id, client);
1148
+ }
1149
+ }
1150
+ catch (err) {
1151
+ if (err.data?.error === 'name_taken') {
1152
+ await this.handleNameTaken(sourceChannelId, channelName, projectDir, client);
1153
+ }
1154
+ else {
1155
+ await client.chat.postMessage({
1156
+ channel: sourceChannelId,
1157
+ text: `:warning: Failed to create channel: ${err.message}`,
1158
+ });
1159
+ }
1160
+ }
1161
+ }
1162
+ /** Handle name_taken error when creating a channel - offer to bind existing. */
1163
+ async handleNameTaken(channelId, channelName, projectDir, client) {
1164
+ try {
1165
+ const listResult = await client.conversations.list({ types: 'public_channel,private_channel', limit: 1000 });
1166
+ const existingChannel = listResult.channels?.find((c) => c.name === channelName);
1167
+ if (existingChannel) {
1168
+ const boundProject = await this.store.getProjectByChannelId(existingChannel.id);
1169
+ if (boundProject) {
1170
+ await client.chat.postMessage({
1171
+ channel: channelId,
1172
+ text: `:warning: Channel <#${existingChannel.id}> is already connected to project \`${boundProject.project_dir}\``,
1173
+ });
1174
+ }
1175
+ else {
1176
+ await client.chat.postMessage({
1177
+ channel: channelId,
1178
+ text: `Channel <#${existingChannel.id}> already exists. Connect project \`${projectDir}\` to it?`,
1179
+ blocks: [
1180
+ {
1181
+ type: 'section',
1182
+ text: {
1183
+ type: 'mrkdwn',
1184
+ text: `Channel <#${existingChannel.id}> already exists. Connect project directory \`${projectDir}\` to it?`,
1185
+ },
1186
+ },
1187
+ {
1188
+ type: 'actions',
1189
+ elements: [
1190
+ {
1191
+ type: 'button',
1192
+ text: { type: 'plain_text', text: `Connect to #${channelName}` },
1193
+ action_id: 'project_bind_existing',
1194
+ value: JSON.stringify({ channelId: existingChannel.id, projectDir }),
1195
+ },
1196
+ ],
1197
+ },
1198
+ ],
1199
+ });
1200
+ }
1201
+ }
1202
+ else {
1203
+ await client.chat.postMessage({
1204
+ channel: channelId,
1205
+ text: `:warning: Channel #${channelName} exists but the bot can't see it (likely a private channel). Go to #${channelName} and run \`/project connect ${projectDir}\` to connect it.`,
1206
+ });
1207
+ }
1208
+ }
1209
+ catch {
1210
+ await client.chat.postMessage({
1211
+ channel: channelId,
1212
+ text: `:warning: Channel #${channelName} already exists. Go to that channel and run \`/project connect ${projectDir}\` to connect it.`,
1213
+ });
1214
+ }
1215
+ }
1216
+ /** Handle /settings slash command. */
1217
+ async handleSettingsCommand(command, client) {
1218
+ const channelId = command.channel_id;
1219
+ const args = (command.text || '').trim();
1220
+ if (!args) {
1221
+ // Show current settings
1222
+ const root = await this.store.getSetting('projects_root');
1223
+ const project = await this.store.getProjectByChannelId(channelId);
1224
+ let text = '*Bridge settings:*';
1225
+ if (root) {
1226
+ text += `\n- Projects root: \`${root}\``;
1227
+ }
1228
+ else {
1229
+ text += '\n- Projects root: _(not set)_';
1230
+ }
1231
+ if (project) {
1232
+ text += `\n\n*This channel's project:*\n- Backend: \`${project.backend_name}\` - change with \`/project backend\`\n- Directory: \`${project.project_dir}\``;
1233
+ }
1234
+ text += '\n\n_Commands:_';
1235
+ text += '\n- `/settings root /path` - set sandbox root folder';
1236
+ text += '\n- `/settings schedule list` - list active schedules';
1237
+ text += '\n- `/settings schedule cancel <id>` - cancel a schedule';
1238
+ await client.chat.postMessage({
1239
+ channel: channelId,
1240
+ text,
1241
+ });
1242
+ return;
1243
+ }
1244
+ const parts = args.split(/\s+/);
1245
+ if (parts[0] === 'root' && parts[1]) {
1246
+ const rootPath = parts.slice(1).join(' ');
1247
+ let resolvedRoot;
1248
+ try {
1249
+ resolvedRoot = validateSandboxRoot(rootPath);
1250
+ }
1251
+ catch (err) {
1252
+ await client.chat.postMessage({
1253
+ channel: channelId,
1254
+ text: `:warning: ${err.message}`,
1255
+ });
1256
+ return;
1257
+ }
1258
+ await this.store.setSetting('projects_root', resolvedRoot);
1259
+ await client.chat.postMessage({
1260
+ channel: channelId,
1261
+ text: `Sandbox root set to \`${resolvedRoot}\`. Use \`/project connect\` to pick from subdirectories.`,
1262
+ });
1263
+ }
1264
+ else if (parts[0] === 'schedule') {
1265
+ const sub = parts[1] || '';
1266
+ if (!sub || sub === 'help') {
1267
+ await client.chat.postMessage({
1268
+ channel: channelId,
1269
+ text: [
1270
+ '*Schedule commands:*',
1271
+ '- `/settings schedule list` - list active schedules for this channel',
1272
+ '- `/settings schedule cancel <id>` - cancel a schedule by ID',
1273
+ ].join('\n'),
1274
+ });
1275
+ return;
1276
+ }
1277
+ if (sub === 'list') {
1278
+ const schedules = await this.store.getSchedulesByChannelId(channelId);
1279
+ if (schedules.length === 0) {
1280
+ await client.chat.postMessage({
1281
+ channel: channelId,
1282
+ text: 'No scheduled sessions for this channel.',
1283
+ });
1284
+ return;
1285
+ }
1286
+ const fmt = (iso) => iso ? new Date(iso).toLocaleString() : '-';
1287
+ const lines = schedules.map((s, i) => {
1288
+ const typeLabel = s.is_recurring ? `recurring` : `one-time`;
1289
+ return `${i + 1}. "${s.original_request}" - ${typeLabel}, next: ${fmt(s.next_run_at)} [ID: ${s.id}]`;
1290
+ });
1291
+ await client.chat.postMessage({
1292
+ channel: channelId,
1293
+ text: `*Scheduled Sessions*\n${lines.join('\n')}`,
1294
+ });
1295
+ return;
1296
+ }
1297
+ if (sub === 'cancel') {
1298
+ const idStr = parts[2];
1299
+ if (!idStr) {
1300
+ await client.chat.postMessage({
1301
+ channel: channelId,
1302
+ text: 'Usage: `/settings schedule cancel <id>` - get IDs from `/settings schedule list`',
1303
+ });
1304
+ return;
1305
+ }
1306
+ const id = parseInt(idStr, 10);
1307
+ if (isNaN(id)) {
1308
+ await client.chat.postMessage({
1309
+ channel: channelId,
1310
+ text: `Invalid schedule ID: \`${idStr}\``,
1311
+ });
1312
+ return;
1313
+ }
1314
+ const schedule = await this.store.getScheduleById(id);
1315
+ if (!schedule || schedule.channel_id !== channelId || !schedule.is_active) {
1316
+ await client.chat.postMessage({
1317
+ channel: channelId,
1318
+ text: `No active schedule with ID ${id} found in this channel.`,
1319
+ });
1320
+ return;
1321
+ }
1322
+ await this.store.deactivateSchedule(id);
1323
+ await client.chat.postMessage({
1324
+ channel: channelId,
1325
+ text: `Cancelled schedule: "${schedule.original_request}"`,
1326
+ });
1327
+ return;
1328
+ }
1329
+ await client.chat.postMessage({
1330
+ channel: channelId,
1331
+ text: `Unknown schedule subcommand: \`${sub}\`. Try \`/settings schedule help\`.`,
1332
+ });
1333
+ }
1334
+ else {
1335
+ await client.chat.postMessage({
1336
+ channel: channelId,
1337
+ text: '*Usage:*\n- `/settings root /path` - set projects root folder\n- `/settings schedule list` - list active schedules\n- `/settings schedule cancel <id>` - cancel a schedule',
1338
+ });
1339
+ }
1340
+ }
1341
+ /** Handle file uploads in messages - downloads all file types for backend passthrough. */
1342
+ async handleFileUpload(channelId, threadTs, files, text, client, messageTs) {
1343
+ const project = await this.store.getProjectByChannelId(channelId);
1344
+ if (!project)
1345
+ return;
1346
+ const attachments = [];
1347
+ const textInclusions = [];
1348
+ // Get the bot token for authenticated downloads from Slack
1349
+ const botToken = this.app.client?.token
1350
+ ?? this.app.token;
1351
+ for (const file of files) {
1352
+ const fileName = file.name || 'unknown';
1353
+ const mimeType = file.mimetype || '';
1354
+ const fileUrl = file.url_private_download || file.url_private;
1355
+ if (!fileUrl)
1356
+ continue;
1357
+ const attachment = await downloadAndStageFile(fileUrl, fileName, mimeType, {
1358
+ Authorization: `Bearer ${botToken}`,
1359
+ });
1360
+ if (attachment) {
1361
+ attachments.push(attachment);
1362
+ // Include text file contents inline so the AI can read them
1363
+ if (attachment.kind === 'text' && attachment.stagingPath) {
1364
+ try {
1365
+ const content = fs.readFileSync(attachment.stagingPath, 'utf8');
1366
+ textInclusions.push(`\`\`\`${attachment.filename}\n${content}\n\`\`\``);
1367
+ }
1368
+ catch (err) {
1369
+ console.error(`[slack] failed to read staged text file ${attachment.stagingPath}: ${err.message}`);
1370
+ }
1371
+ }
1372
+ console.log(`[slack] downloaded ${attachment.kind} file ${fileName} (${attachment.mediaType}, staged as ${attachment.uploadId})`);
1373
+ }
1374
+ else {
1375
+ textInclusions.push(`[Uploaded file: ${fileName} (download failed)]`);
1376
+ }
1377
+ }
1378
+ // Combine text inclusions with the message text
1379
+ const combinedText = textInclusions.length > 0
1380
+ ? `${text}\n\n${textInclusions.join('\n\n')}`
1381
+ : text;
1382
+ // Route with all file attachments
1383
+ clearPostMessageFlag(threadTs);
1384
+ let result;
1385
+ try {
1386
+ result = await this.router.send(channelId, threadTs, combinedText, attachments.length > 0 ? attachments : undefined);
1387
+ }
1388
+ catch (err) {
1389
+ await this.postError(channelId, threadTs, err.message, client);
1390
+ return;
1391
+ }
1392
+ await this.renderEvents(channelId, threadTs, result.events, client);
1393
+ if (messageTs)
1394
+ await this.removeReactSeen(channelId, messageTs, client);
1395
+ await this.cleanupPermissionAcks(threadTs, client);
1396
+ }
1397
+ /** Upload a file to a Slack thread. Called by MCP callback handler. */
1398
+ async uploadFile(channelId, threadId, filePath) {
1399
+ const filename = path.basename(filePath);
1400
+ await this.app.client.filesUploadV2({
1401
+ channel_id: channelId,
1402
+ thread_ts: threadId,
1403
+ file: filePath,
1404
+ filename,
1405
+ });
1406
+ console.log(`[slack] uploaded file ${filename} to ${channelId}/${threadId}`);
1407
+ }
1408
+ /** Post a plain text message to a Slack thread. Called by MCP callback handler. */
1409
+ async sendMessage(channelId, threadId, text) {
1410
+ const converted = markdownToSlackMrkdwn(text);
1411
+ // Slack message limit is 40000 chars - truncate as a failsafe
1412
+ const MAX = 40000;
1413
+ const truncated = converted.length > MAX ? converted.slice(0, MAX - 15) + '\n... (truncated)' : converted;
1414
+ await this.app.client.chat.postMessage({
1415
+ channel: channelId,
1416
+ thread_ts: threadId,
1417
+ text: truncated,
1418
+ });
1419
+ }
1420
+ async createThread(channelId, title) {
1421
+ const result = await this.app.client.chat.postMessage({
1422
+ channel: channelId,
1423
+ text: title,
1424
+ });
1425
+ if (!result.ts) {
1426
+ throw new Error('Failed to create thread - no message timestamp returned');
1427
+ }
1428
+ return result.ts;
1429
+ }
1430
+ /** Format todos as Slack mrkdwn checklist. */
1431
+ formatTodosSlack(todos) {
1432
+ if (todos.length === 0)
1433
+ return '_No tasks_';
1434
+ return todos.map((t) => {
1435
+ switch (t.status) {
1436
+ case 'completed':
1437
+ return `~${t.content}~`;
1438
+ case 'in_progress':
1439
+ return `> *${t.activeForm}...*`;
1440
+ default:
1441
+ return t.content;
1442
+ }
1443
+ }).join('\n');
1444
+ }
1445
+ async renderTodoList(channelId, threadId, todos) {
1446
+ const text = this.formatTodosSlack(todos);
1447
+ const blocks = [{ type: 'section', text: { type: 'mrkdwn', text } }];
1448
+ const existing = this.todoMessages.get(threadId);
1449
+ if (existing) {
1450
+ try {
1451
+ await this.app.client.chat.update({
1452
+ channel: existing.channel,
1453
+ ts: existing.ts,
1454
+ text: 'Task list',
1455
+ blocks,
1456
+ });
1457
+ }
1458
+ catch (err) {
1459
+ console.error(`[slack] failed to update todo message for thread ${threadId}: ${err.message}`);
1460
+ this.todoMessages.delete(threadId);
1461
+ await this.renderTodoList(channelId, threadId, todos);
1462
+ }
1463
+ }
1464
+ else {
1465
+ try {
1466
+ const result = await this.app.client.chat.postMessage({
1467
+ channel: channelId,
1468
+ thread_ts: threadId,
1469
+ text: 'Task list',
1470
+ blocks,
1471
+ });
1472
+ if (result.ts) {
1473
+ this.todoMessages.set(threadId, { channel: channelId, ts: result.ts });
1474
+ }
1475
+ }
1476
+ catch (err) {
1477
+ console.error(`[slack] failed to post todo message for thread ${threadId}: ${err.message}`);
1478
+ }
1479
+ }
1480
+ }
1481
+ }
1482
+ // splitText is imported from ../utils.js
1483
+ export { splitText } from '../utils.js';
1484
+ //# sourceMappingURL=slack.js.map