claude-nonstop 0.3.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,548 @@
1
+ /**
2
+ * Slack Channel Manager
3
+ * Manages Slack channel lifecycle for per-session mode.
4
+ * Each Claude Code session gets a dedicated Slack channel.
5
+ */
6
+
7
+ const { WebClient } = require('@slack/web-api');
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const { CHANNEL_MAP_PATH, PROGRESS_DIR } = require('./paths.cjs');
11
+
12
+ const PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
13
+
14
+ /**
15
+ * Convert GitHub-flavored Markdown to Slack mrkdwn.
16
+ * Handles bold, links, headers, and horizontal rules.
17
+ */
18
+ function markdownToMrkdwn(text) {
19
+ if (!text) return '';
20
+ return text
21
+ .replace(/\*\*(.+?)\*\*/g, '*$1*') // **bold** → *bold*
22
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>') // [text](url) → <url|text>
23
+ .replace(/^#{1,6}\s+(.+)$/gm, '*$1*') // ## Header → *Header*
24
+ .replace(/^---+$/gm, ''); // --- → remove
25
+ }
26
+
27
+ class SlackChannelManager {
28
+ constructor(config = {}) {
29
+ this.client = new WebClient(config.botToken);
30
+ this.inviteUserId = config.inviteUserId || process.env.SLACK_INVITE_USER_ID;
31
+ this.channelPrefix = config.channelPrefix || process.env.SLACK_CHANNEL_PREFIX || 'cn';
32
+ this.channelMapPath = config.channelMapPath || CHANNEL_MAP_PATH;
33
+
34
+ // One-time migration from legacy location
35
+ const legacyPath = path.join(__dirname, 'data/channel-map.json');
36
+ if (!fs.existsSync(this.channelMapPath) && fs.existsSync(legacyPath)) {
37
+ try {
38
+ const dir = path.dirname(this.channelMapPath);
39
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
40
+ fs.copyFileSync(legacyPath, this.channelMapPath);
41
+ } catch (err) {
42
+ console.warn('Failed to migrate channel-map.json:', err.message);
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Generate a Slack-safe channel name.
49
+ * Slack requires: lowercase, numbers, hyphens, underscores. Max 80 chars.
50
+ */
51
+ _generateChannelName(project, sessionId) {
52
+ const shortId = sessionId.substring(0, 8).toLowerCase();
53
+ const safeProject = project
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9_-]/g, '-')
56
+ .replace(/-+/g, '-')
57
+ .replace(/^-|-$/g, '');
58
+ const name = `${this.channelPrefix}-${safeProject}-${shortId}`;
59
+ return name.substring(0, 80);
60
+ }
61
+
62
+ _readChannelMap() {
63
+ try {
64
+ if (!fs.existsSync(this.channelMapPath)) {
65
+ return {};
66
+ }
67
+ const raw = fs.readFileSync(this.channelMapPath, 'utf8');
68
+ if (!raw.trim()) return {};
69
+ const parsed = JSON.parse(raw);
70
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
71
+ return {};
72
+ }
73
+ return parsed;
74
+ } catch (error) {
75
+ console.error('Error reading channel-map.json:', error.message);
76
+ return {};
77
+ }
78
+ }
79
+
80
+ _pruneStaleEntries(map) {
81
+ const now = Date.now();
82
+ const pruned = {};
83
+ for (const [sessionId, entry] of Object.entries(map)) {
84
+ if (entry.active) {
85
+ pruned[sessionId] = entry;
86
+ continue;
87
+ }
88
+ const archivedAt = entry.archivedAt ? new Date(entry.archivedAt).getTime() : 0;
89
+ const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : 0;
90
+ const refTime = archivedAt || createdAt;
91
+ if (refTime && (now - refTime) < PRUNE_AGE_MS) {
92
+ pruned[sessionId] = entry;
93
+ }
94
+ }
95
+ return pruned;
96
+ }
97
+
98
+ _writeChannelMap(map) {
99
+ try {
100
+ const prunedMap = this._pruneStaleEntries(map);
101
+ const dir = path.dirname(this.channelMapPath);
102
+ if (!fs.existsSync(dir)) {
103
+ fs.mkdirSync(dir, { recursive: true });
104
+ }
105
+ // Atomic write: temp file + rename
106
+ const content = JSON.stringify(prunedMap, null, 2);
107
+ const tmpFile = path.join(dir, `.channel-map.${process.pid}.${Date.now()}.tmp`);
108
+ fs.writeFileSync(tmpFile, content, { mode: 0o600 });
109
+ fs.renameSync(tmpFile, this.channelMapPath);
110
+ } catch (error) {
111
+ console.error('Error writing channel-map.json:', error.message);
112
+ }
113
+ }
114
+
115
+ getChannelMapping(sessionId) {
116
+ const map = this._readChannelMap();
117
+ const entry = map[sessionId];
118
+ if (entry && entry.active) {
119
+ return entry;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Look up an active session by its cwd (working directory).
126
+ * Used as fallback when sessionId is not known (fresh sessions).
127
+ */
128
+ getSessionByCwd(cwd) {
129
+ const map = this._readChannelMap();
130
+ for (const [sessionId, entry] of Object.entries(map)) {
131
+ if (entry.cwd === cwd && entry.active) {
132
+ return { sessionId, ...entry };
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ getSessionByChannelId(channelId) {
139
+ const map = this._readChannelMap();
140
+ for (const [sessionId, entry] of Object.entries(map)) {
141
+ if (entry.channelId === channelId && entry.active) {
142
+ return { sessionId, ...entry };
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Reuse an existing channel for a new session on the same tmux session.
150
+ * Remaps the channel from the old session to the new one and posts a divider.
151
+ *
152
+ * @param {string} newSessionId - The new Claude session ID
153
+ * @param {string} tmuxSession - The tmux session name to match
154
+ * @returns {object|null} The remapped channel entry, or null if no reusable channel exists
155
+ */
156
+ reuseChannelForTmuxSession(newSessionId, tmuxSession) {
157
+ if (!tmuxSession) return null;
158
+
159
+ const map = this._readChannelMap();
160
+
161
+ // Already have a mapping for this session
162
+ if (map[newSessionId]?.active) return map[newSessionId];
163
+
164
+ // Find an active channel on the same tmux session
165
+ let oldSessionId = null;
166
+ let oldEntry = null;
167
+ for (const [sid, entry] of Object.entries(map)) {
168
+ if (entry.tmuxSession === tmuxSession && entry.active) {
169
+ oldSessionId = sid;
170
+ oldEntry = entry;
171
+ break;
172
+ }
173
+ }
174
+ if (!oldEntry) return null;
175
+
176
+ // Remap: create new entry, deactivate old
177
+ map[newSessionId] = { ...oldEntry, pendingMessageTs: null, progressMessageTs: undefined };
178
+ map[oldSessionId].active = false;
179
+ this._writeChannelMap(map);
180
+
181
+ return map[newSessionId];
182
+ }
183
+
184
+ async getOrCreateChannel(sessionId, project, cwd, tmuxSession) {
185
+ const existing = this.getChannelMapping(sessionId);
186
+ if (existing) {
187
+ return existing;
188
+ }
189
+
190
+ const channelName = this._generateChannelName(project, sessionId);
191
+
192
+ let channelId;
193
+ let finalName = channelName;
194
+ try {
195
+ const result = await this.client.conversations.create({
196
+ name: channelName,
197
+ is_private: false
198
+ });
199
+ channelId = result.channel.id;
200
+ finalName = result.channel.name;
201
+ } catch (error) {
202
+ if (error.data?.error === 'name_taken') {
203
+ const suffix = Date.now().toString(36).slice(-4);
204
+ finalName = `${channelName}-${suffix}`.substring(0, 80);
205
+ try {
206
+ const result = await this.client.conversations.create({
207
+ name: finalName,
208
+ is_private: false
209
+ });
210
+ channelId = result.channel.id;
211
+ finalName = result.channel.name;
212
+ } catch (retryError) {
213
+ console.error('Failed to create channel on retry:', retryError.message);
214
+ throw retryError;
215
+ }
216
+ } else {
217
+ console.error('Failed to create Slack channel:', error.message);
218
+ throw error;
219
+ }
220
+ }
221
+
222
+ try {
223
+ await this.client.conversations.setTopic({
224
+ channel: channelId,
225
+ topic: `Claude Code | Project: ${project} | tmux: ${tmuxSession || 'N/A'}`
226
+ });
227
+ } catch (error) {
228
+ console.warn('Failed to set channel topic:', error.message);
229
+ }
230
+
231
+ if (this.inviteUserId) {
232
+ try {
233
+ await this.client.conversations.invite({
234
+ channel: channelId,
235
+ users: this.inviteUserId
236
+ });
237
+ } catch (error) {
238
+ if (error.data?.error !== 'already_in_channel') {
239
+ console.warn('Failed to invite user to channel:', error.message);
240
+ }
241
+ }
242
+ }
243
+
244
+ try {
245
+ await this.client.chat.postMessage({
246
+ channel: channelId,
247
+
248
+ text: `Session started for *${project}*`,
249
+ blocks: [
250
+ {
251
+ type: 'header',
252
+ text: { type: 'plain_text', text: 'Claude Code Session', emoji: true }
253
+ },
254
+ {
255
+ type: 'section',
256
+ fields: [
257
+ { type: 'mrkdwn', text: `*Project:*\n${project}` },
258
+ { type: 'mrkdwn', text: `*tmux:*\n\`${tmuxSession || 'N/A'}\`` }
259
+ ]
260
+ },
261
+ {
262
+ type: 'section',
263
+ text: { type: 'mrkdwn', text: `*Directory:*\n\`${cwd}\`` }
264
+ },
265
+ { type: 'divider' },
266
+ {
267
+ type: 'context',
268
+ elements: [
269
+ { type: 'mrkdwn', text: 'Reply in this channel to send commands to Claude. Type `!help` for available commands.' }
270
+ ]
271
+ }
272
+ ]
273
+ });
274
+ } catch (error) {
275
+ console.warn('Failed to post welcome message:', error.message);
276
+ }
277
+
278
+ const mapping = {
279
+ channelId,
280
+ channelName: finalName,
281
+ tmuxSession: tmuxSession || null,
282
+ project,
283
+ cwd,
284
+ createdAt: new Date().toISOString(),
285
+ active: true
286
+ };
287
+
288
+ const map = this._readChannelMap();
289
+ map[sessionId] = mapping;
290
+ this._writeChannelMap(map);
291
+
292
+ console.log(`Created Slack channel #${finalName} for session ${sessionId}`);
293
+ return mapping;
294
+ }
295
+
296
+ async postToSessionChannel(sessionId, text, blocks) {
297
+ const mapping = this.getChannelMapping(sessionId);
298
+ if (!mapping) {
299
+ console.warn(`No channel mapping found for session ${sessionId}`);
300
+ return false;
301
+ }
302
+
303
+ try {
304
+ if (blocks) {
305
+ await this.client.chat.postMessage({
306
+ channel: mapping.channelId,
307
+
308
+ text,
309
+ blocks
310
+ });
311
+ } else {
312
+ const MAX_LEN = 39500;
313
+ const chunks = [];
314
+ let remaining = text;
315
+ while (remaining.length > MAX_LEN) {
316
+ let splitAt = remaining.lastIndexOf('\n', MAX_LEN);
317
+ if (splitAt < MAX_LEN * 0.5) {
318
+ splitAt = MAX_LEN;
319
+ }
320
+ chunks.push(remaining.substring(0, splitAt));
321
+ remaining = remaining.substring(splitAt).replace(/^\n/, '');
322
+ }
323
+ chunks.push(remaining);
324
+
325
+ for (const chunk of chunks) {
326
+ await this.client.chat.postMessage({
327
+ channel: mapping.channelId,
328
+
329
+ text: chunk
330
+ });
331
+ }
332
+ }
333
+ return true;
334
+ } catch (error) {
335
+ if (error.data?.error === 'channel_not_found' || error.data?.error === 'is_archived') {
336
+ console.warn(`Channel ${mapping.channelId} not found, removing stale mapping`);
337
+ const map = this._readChannelMap();
338
+ if (map[sessionId]) {
339
+ map[sessionId].active = false;
340
+ this._writeChannelMap(map);
341
+ }
342
+ return false;
343
+ }
344
+ console.error('Failed to post to session channel:', error.message);
345
+ return false;
346
+ }
347
+ }
348
+
349
+ async setTypingIndicator(channelId, messageTs) {
350
+ // Clear previous typing indicator before setting new one
351
+ const map = this._readChannelMap();
352
+ for (const [, entry] of Object.entries(map)) {
353
+ if (entry.channelId === channelId && entry.active && entry.pendingMessageTs) {
354
+ try {
355
+ await this.client.reactions.remove({
356
+ channel: channelId,
357
+ timestamp: entry.pendingMessageTs,
358
+ name: 'hourglass_flowing_sand'
359
+ });
360
+ } catch (error) {
361
+ if (error.data?.error !== 'no_reaction') {
362
+ console.warn('Failed to remove previous typing reaction:', error.message);
363
+ }
364
+ }
365
+ break;
366
+ }
367
+ }
368
+
369
+ try {
370
+ await this.client.reactions.add({
371
+ channel: channelId,
372
+ timestamp: messageTs,
373
+ name: 'hourglass_flowing_sand'
374
+ });
375
+ } catch (error) {
376
+ if (error.data?.error !== 'already_reacted') {
377
+ console.warn('Failed to add typing reaction:', error.message);
378
+ }
379
+ }
380
+
381
+ for (const [, entry] of Object.entries(map)) {
382
+ if (entry.channelId === channelId && entry.active) {
383
+ entry.pendingMessageTs = messageTs;
384
+ break;
385
+ }
386
+ }
387
+ this._writeChannelMap(map);
388
+ }
389
+
390
+ async clearTypingIndicator(sessionId) {
391
+ const map = this._readChannelMap();
392
+ const entry = map[sessionId];
393
+ if (!entry || !entry.pendingMessageTs) {
394
+ return;
395
+ }
396
+
397
+ try {
398
+ await this.client.reactions.remove({
399
+ channel: entry.channelId,
400
+ timestamp: entry.pendingMessageTs,
401
+ name: 'hourglass_flowing_sand'
402
+ });
403
+ } catch (error) {
404
+ if (error.data?.error !== 'no_reaction') {
405
+ console.warn('Failed to remove typing reaction:', error.message);
406
+ }
407
+ }
408
+
409
+ // Don't clear pendingMessageTs — it's reused for threading progress messages.
410
+ // setTypingIndicator will overwrite it on the next Slack message.
411
+ }
412
+
413
+ /**
414
+ * Post or update a progress message in a session's channel.
415
+ * Creates a new message if none exists, otherwise updates it.
416
+ * @returns {boolean} true if successful
417
+ */
418
+ async updateProgressMessage(sessionId, text) {
419
+ const map = this._readChannelMap();
420
+ const entry = map[sessionId];
421
+ if (!entry || !entry.active) return false;
422
+
423
+ try {
424
+ if (entry.progressMessageTs) {
425
+ await this.client.chat.update({
426
+ channel: entry.channelId,
427
+ ts: entry.progressMessageTs,
428
+ text
429
+ });
430
+ } else {
431
+ const opts = { channel: entry.channelId, text };
432
+ if (entry.pendingMessageTs) {
433
+ opts.thread_ts = entry.pendingMessageTs;
434
+ }
435
+ const result = await this.client.chat.postMessage(opts);
436
+ entry.progressMessageTs = result.ts;
437
+ this._writeChannelMap(map);
438
+ }
439
+ return true;
440
+ } catch (error) {
441
+ if (error.data?.error === 'message_not_found') {
442
+ // Message was deleted; post a new one
443
+ try {
444
+ const opts = { channel: entry.channelId, text };
445
+ if (entry.pendingMessageTs) {
446
+ opts.thread_ts = entry.pendingMessageTs;
447
+ }
448
+ const result = await this.client.chat.postMessage(opts);
449
+ entry.progressMessageTs = result.ts;
450
+ this._writeChannelMap(map);
451
+ return true;
452
+ } catch { /* fall through */ }
453
+ }
454
+ console.warn('Failed to update progress message:', error.message);
455
+ return false;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Delete the progress Slack message and clean up buffer file.
461
+ */
462
+ async clearProgressMessage(sessionId) {
463
+ const map = this._readChannelMap();
464
+ const entry = map[sessionId];
465
+ if (!entry) return;
466
+
467
+ // Delete the Slack message if it exists
468
+ if (entry.progressMessageTs && entry.channelId) {
469
+ try {
470
+ await this.client.chat.delete({
471
+ channel: entry.channelId,
472
+ ts: entry.progressMessageTs
473
+ });
474
+ } catch (error) {
475
+ if (error.data?.error !== 'message_not_found') {
476
+ console.warn('Failed to delete progress message:', error.message);
477
+ }
478
+ }
479
+ }
480
+
481
+ // Re-read map after API call to avoid clobbering concurrent writes
482
+ // (e.g. setTypingIndicator setting pendingMessageTs from a new Slack message)
483
+ const freshMap = this._readChannelMap();
484
+ const freshEntry = freshMap[sessionId];
485
+ if (!freshEntry) return;
486
+
487
+ delete freshEntry.progressMessageTs;
488
+ this._writeChannelMap(freshMap);
489
+
490
+ // Clean up progress buffer file
491
+ const bufPath = path.join(PROGRESS_DIR, `progress-${sessionId}.json`);
492
+ try { fs.unlinkSync(bufPath); } catch {}
493
+ }
494
+
495
+ /**
496
+ * Post a thread reply to a specific parent message in a session's channel.
497
+ * @param {string} sessionId
498
+ * @param {string} parentTs - timestamp of the parent message
499
+ * @param {string} text
500
+ * @returns {boolean}
501
+ */
502
+ async postToThread(sessionId, parentTs, text) {
503
+ const mapping = this.getChannelMapping(sessionId);
504
+ if (!mapping) return false;
505
+
506
+ try {
507
+ await this.client.chat.postMessage({
508
+ channel: mapping.channelId,
509
+
510
+ text,
511
+ thread_ts: parentTs
512
+ });
513
+ return true;
514
+ } catch (error) {
515
+ console.warn('Failed to post thread reply:', error.message);
516
+ return false;
517
+ }
518
+ }
519
+
520
+ async archiveChannel(channelId) {
521
+ try {
522
+ await this.client.conversations.archive({ channel: channelId });
523
+ } catch (error) {
524
+ if (error.data?.error !== 'already_archived') {
525
+ console.error('Failed to archive channel:', error.message);
526
+ throw error;
527
+ }
528
+ }
529
+
530
+ const map = this._readChannelMap();
531
+ for (const [sessionId, entry] of Object.entries(map)) {
532
+ if (entry.channelId === channelId) {
533
+ map[sessionId].active = false;
534
+ map[sessionId].archivedAt = new Date().toISOString();
535
+ // Clean up progress buffer file
536
+ const bufPath = path.join(PROGRESS_DIR, `progress-${sessionId}.json`);
537
+ try { fs.unlinkSync(bufPath); } catch {}
538
+ break;
539
+ }
540
+ }
541
+ this._writeChannelMap(map);
542
+
543
+ console.log(`Archived Slack channel ${channelId}`);
544
+ }
545
+ }
546
+
547
+ module.exports = SlackChannelManager;
548
+ module.exports.markdownToMrkdwn = markdownToMrkdwn;