claude-threads 0.13.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +78 -28
  3. package/dist/claude/cli.d.ts +8 -0
  4. package/dist/claude/cli.js +16 -8
  5. package/dist/config/migration.d.ts +45 -0
  6. package/dist/config/migration.js +35 -0
  7. package/dist/config.d.ts +12 -18
  8. package/dist/config.js +7 -94
  9. package/dist/git/worktree.d.ts +0 -4
  10. package/dist/git/worktree.js +1 -1
  11. package/dist/index.js +31 -13
  12. package/dist/logo.d.ts +3 -20
  13. package/dist/logo.js +7 -23
  14. package/dist/mcp/permission-server.js +61 -112
  15. package/dist/onboarding.js +262 -137
  16. package/dist/persistence/session-store.d.ts +8 -2
  17. package/dist/persistence/session-store.js +41 -16
  18. package/dist/platform/client.d.ts +140 -0
  19. package/dist/platform/formatter.d.ts +74 -0
  20. package/dist/platform/index.d.ts +11 -0
  21. package/dist/platform/index.js +8 -0
  22. package/dist/platform/mattermost/client.d.ts +70 -0
  23. package/dist/{mattermost → platform/mattermost}/client.js +117 -34
  24. package/dist/platform/mattermost/formatter.d.ts +20 -0
  25. package/dist/platform/mattermost/formatter.js +46 -0
  26. package/dist/platform/mattermost/permission-api.d.ts +10 -0
  27. package/dist/platform/mattermost/permission-api.js +139 -0
  28. package/dist/platform/mattermost/types.js +1 -0
  29. package/dist/platform/permission-api-factory.d.ts +11 -0
  30. package/dist/platform/permission-api-factory.js +21 -0
  31. package/dist/platform/permission-api.d.ts +67 -0
  32. package/dist/platform/permission-api.js +8 -0
  33. package/dist/platform/types.d.ts +70 -0
  34. package/dist/platform/types.js +7 -0
  35. package/dist/session/commands.d.ts +52 -0
  36. package/dist/session/commands.js +323 -0
  37. package/dist/session/events.d.ts +25 -0
  38. package/dist/session/events.js +368 -0
  39. package/dist/session/index.d.ts +7 -0
  40. package/dist/session/index.js +6 -0
  41. package/dist/session/lifecycle.d.ts +70 -0
  42. package/dist/session/lifecycle.js +456 -0
  43. package/dist/session/manager.d.ts +96 -0
  44. package/dist/session/manager.js +537 -0
  45. package/dist/session/reactions.d.ts +25 -0
  46. package/dist/session/reactions.js +151 -0
  47. package/dist/session/streaming.d.ts +47 -0
  48. package/dist/session/streaming.js +152 -0
  49. package/dist/session/types.d.ts +78 -0
  50. package/dist/session/types.js +9 -0
  51. package/dist/session/worktree.d.ts +56 -0
  52. package/dist/session/worktree.js +339 -0
  53. package/dist/update-notifier.js +10 -0
  54. package/dist/{mattermost → utils}/emoji.d.ts +3 -3
  55. package/dist/{mattermost → utils}/emoji.js +3 -3
  56. package/dist/utils/emoji.test.d.ts +1 -0
  57. package/dist/utils/tool-formatter.d.ts +10 -13
  58. package/dist/utils/tool-formatter.js +48 -43
  59. package/dist/utils/tool-formatter.test.js +67 -52
  60. package/package.json +4 -3
  61. package/dist/claude/session.d.ts +0 -256
  62. package/dist/claude/session.js +0 -1964
  63. package/dist/mattermost/client.d.ts +0 -56
  64. /package/dist/{mattermost/emoji.test.d.ts → platform/client.js} +0 -0
  65. /package/dist/{mattermost/types.js → platform/formatter.js} +0 -0
  66. /package/dist/{mattermost → platform/mattermost}/types.d.ts +0 -0
  67. /package/dist/{mattermost → utils}/emoji.test.js +0 -0
@@ -1,1964 +0,0 @@
1
- import { ClaudeCli } from './cli.js';
2
- import { isApprovalEmoji, isDenialEmoji, isAllowAllEmoji, isCancelEmoji, isEscapeEmoji, getNumberEmojiIndex, NUMBER_EMOJIS, APPROVAL_EMOJIS, DENIAL_EMOJIS, ALLOW_ALL_EMOJIS, } from '../mattermost/emoji.js';
3
- import { formatToolUse as sharedFormatToolUse } from '../utils/tool-formatter.js';
4
- import { getUpdateInfo } from '../update-notifier.js';
5
- import { getReleaseNotes, getWhatsNewSummary } from '../changelog.js';
6
- import { SessionStore } from '../persistence/session-store.js';
7
- import { getMattermostLogo } from '../logo.js';
8
- import { isGitRepository, getRepositoryRoot, hasUncommittedChanges, listWorktrees, createWorktree, removeWorktree as removeGitWorktree, getWorktreeDir, findWorktreeByBranch, isValidBranchName, } from '../git/worktree.js';
9
- import { randomUUID } from 'crypto';
10
- import { readFileSync } from 'fs';
11
- import { dirname, resolve } from 'path';
12
- import { fileURLToPath } from 'url';
13
- const __dirname = dirname(fileURLToPath(import.meta.url));
14
- const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', '..', 'package.json'), 'utf-8'));
15
- // =============================================================================
16
- // Configuration
17
- // =============================================================================
18
- const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '5', 10);
19
- const SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS || '1800000', 10); // 30 min
20
- const SESSION_WARNING_MS = 5 * 60 * 1000; // Warn 5 minutes before timeout
21
- // =============================================================================
22
- // SessionManager - Manages multiple concurrent Claude Code sessions
23
- // =============================================================================
24
- export class SessionManager {
25
- // Shared state
26
- mattermost;
27
- workingDir;
28
- skipPermissions;
29
- chromeEnabled;
30
- worktreeMode;
31
- debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
32
- // Multi-session storage
33
- sessions = new Map(); // threadId -> Session
34
- postIndex = new Map(); // postId -> threadId (for reaction routing)
35
- // Persistence
36
- sessionStore = new SessionStore();
37
- // Cleanup timer
38
- cleanupTimer = null;
39
- // Shutdown flag to suppress exit messages during graceful shutdown
40
- isShuttingDown = false;
41
- constructor(mattermost, workingDir, skipPermissions = false, chromeEnabled = false, worktreeMode = 'prompt') {
42
- this.mattermost = mattermost;
43
- this.workingDir = workingDir;
44
- this.skipPermissions = skipPermissions;
45
- this.chromeEnabled = chromeEnabled;
46
- this.worktreeMode = worktreeMode;
47
- // Listen for reactions to answer questions
48
- this.mattermost.on('reaction', async (reaction, user) => {
49
- try {
50
- await this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
51
- }
52
- catch (err) {
53
- console.error(' ❌ Error handling reaction:', err);
54
- }
55
- });
56
- // Start periodic cleanup of idle sessions
57
- this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
58
- }
59
- // ---------------------------------------------------------------------------
60
- // Session Initialization (Resume)
61
- // ---------------------------------------------------------------------------
62
- /**
63
- * Initialize session manager by resuming any persisted sessions.
64
- * Should be called before starting to listen for new messages.
65
- */
66
- async initialize() {
67
- // Load persisted sessions FIRST (before cleaning stale ones)
68
- // This way we can resume sessions that were active when the bot stopped,
69
- // even if the bot was down for longer than SESSION_TIMEOUT_MS
70
- const persisted = this.sessionStore.load();
71
- if (this.debug) {
72
- console.log(` [persist] Found ${persisted.size} persisted session(s)`);
73
- for (const [threadId, state] of persisted) {
74
- const age = Date.now() - new Date(state.lastActivityAt).getTime();
75
- const ageMins = Math.round(age / 60000);
76
- console.log(` [persist] - ${threadId.substring(0, 8)}... by @${state.startedBy}, age: ${ageMins}m`);
77
- }
78
- }
79
- // Note: We intentionally do NOT clean stale sessions on startup anymore.
80
- // Sessions are cleaned during normal operation by cleanupIdleSessions().
81
- // This allows sessions to survive bot restarts even if the bot was down
82
- // for longer than SESSION_TIMEOUT_MS.
83
- if (persisted.size === 0) {
84
- if (this.debug)
85
- console.log(' [resume] No sessions to resume');
86
- return;
87
- }
88
- console.log(` 📂 Found ${persisted.size} session(s) to resume...`);
89
- // Resume each session
90
- for (const [_threadId, state] of persisted) {
91
- await this.resumeSession(state);
92
- }
93
- console.log(` ✅ Resumed ${this.sessions.size} session(s)`);
94
- }
95
- /**
96
- * Resume a single session from persisted state
97
- */
98
- async resumeSession(state) {
99
- const shortId = state.threadId.substring(0, 8);
100
- // Verify thread still exists
101
- const post = await this.mattermost.getPost(state.threadId);
102
- if (!post) {
103
- console.log(` ⚠️ Thread ${shortId}... deleted, skipping resume`);
104
- this.sessionStore.remove(state.threadId);
105
- return;
106
- }
107
- // Check max sessions limit
108
- if (this.sessions.size >= MAX_SESSIONS) {
109
- console.log(` ⚠️ Max sessions reached, skipping resume for ${shortId}...`);
110
- return;
111
- }
112
- // Create Claude CLI with resume flag
113
- const skipPerms = this.skipPermissions && !state.forceInteractivePermissions;
114
- const cliOptions = {
115
- workingDir: state.workingDir,
116
- threadId: state.threadId,
117
- skipPermissions: skipPerms,
118
- sessionId: state.claudeSessionId,
119
- resume: true,
120
- chrome: this.chromeEnabled,
121
- };
122
- const claude = new ClaudeCli(cliOptions);
123
- // Rebuild Session object from persisted state
124
- const session = {
125
- threadId: state.threadId,
126
- claudeSessionId: state.claudeSessionId,
127
- startedBy: state.startedBy,
128
- startedAt: new Date(state.startedAt),
129
- lastActivityAt: new Date(),
130
- sessionNumber: state.sessionNumber,
131
- workingDir: state.workingDir,
132
- claude,
133
- currentPostId: null,
134
- pendingContent: '',
135
- pendingApproval: null,
136
- pendingQuestionSet: null,
137
- pendingMessageApproval: null,
138
- planApproved: state.planApproved,
139
- sessionAllowedUsers: new Set(state.sessionAllowedUsers),
140
- forceInteractivePermissions: state.forceInteractivePermissions,
141
- sessionStartPostId: state.sessionStartPostId,
142
- tasksPostId: state.tasksPostId,
143
- activeSubagents: new Map(),
144
- updateTimer: null,
145
- typingTimer: null,
146
- timeoutWarningPosted: false,
147
- isRestarting: false,
148
- isResumed: true,
149
- wasInterrupted: false,
150
- inProgressTaskStart: null,
151
- activeToolStarts: new Map(),
152
- // Worktree state from persistence
153
- worktreeInfo: state.worktreeInfo,
154
- pendingWorktreePrompt: state.pendingWorktreePrompt,
155
- worktreePromptDisabled: state.worktreePromptDisabled,
156
- queuedPrompt: state.queuedPrompt,
157
- };
158
- // Register session
159
- this.sessions.set(state.threadId, session);
160
- if (state.sessionStartPostId) {
161
- this.registerPost(state.sessionStartPostId, state.threadId);
162
- }
163
- // Bind event handlers
164
- claude.on('event', (e) => this.handleEvent(state.threadId, e));
165
- claude.on('exit', (code) => this.handleExit(state.threadId, code));
166
- try {
167
- claude.start();
168
- console.log(` 🔄 Resumed session ${shortId}... (@${state.startedBy})`);
169
- // Post resume message
170
- await this.mattermost.createPost(`🔄 **Session resumed** after bot restart (v${pkg.version})\n*Reconnected to Claude session. You can continue where you left off.*`, state.threadId);
171
- // Update session header
172
- await this.updateSessionHeader(session);
173
- // Update persistence with new activity time
174
- this.persistSession(session);
175
- }
176
- catch (err) {
177
- console.error(` ❌ Failed to resume session ${shortId}...:`, err);
178
- this.sessions.delete(state.threadId);
179
- this.sessionStore.remove(state.threadId);
180
- // Try to notify user
181
- try {
182
- await this.mattermost.createPost(`⚠️ **Could not resume previous session.** Starting fresh.\n*Your previous conversation context is preserved, but Claude needs to re-read it.*`, state.threadId);
183
- }
184
- catch {
185
- // Ignore if we can't post
186
- }
187
- }
188
- }
189
- /**
190
- * Persist a session to disk
191
- */
192
- persistSession(session) {
193
- const shortId = session.threadId.substring(0, 8);
194
- console.log(` [persist] Saving session ${shortId}...`);
195
- const state = {
196
- threadId: session.threadId,
197
- claudeSessionId: session.claudeSessionId,
198
- startedBy: session.startedBy,
199
- startedAt: session.startedAt.toISOString(),
200
- sessionNumber: session.sessionNumber,
201
- workingDir: session.workingDir,
202
- sessionAllowedUsers: [...session.sessionAllowedUsers],
203
- forceInteractivePermissions: session.forceInteractivePermissions,
204
- sessionStartPostId: session.sessionStartPostId,
205
- tasksPostId: session.tasksPostId,
206
- lastActivityAt: session.lastActivityAt.toISOString(),
207
- planApproved: session.planApproved,
208
- // Worktree state
209
- worktreeInfo: session.worktreeInfo,
210
- pendingWorktreePrompt: session.pendingWorktreePrompt,
211
- worktreePromptDisabled: session.worktreePromptDisabled,
212
- queuedPrompt: session.queuedPrompt,
213
- };
214
- this.sessionStore.save(session.threadId, state);
215
- console.log(` [persist] Saved session ${shortId}... (claudeId: ${session.claudeSessionId.substring(0, 8)}...)`);
216
- }
217
- /**
218
- * Remove a session from persistence
219
- */
220
- unpersistSession(threadId) {
221
- const shortId = threadId.substring(0, 8);
222
- console.log(` [persist] REMOVING session ${shortId}... (this should NOT happen during shutdown!)`);
223
- this.sessionStore.remove(threadId);
224
- }
225
- // ---------------------------------------------------------------------------
226
- // Session Lookup Methods
227
- // ---------------------------------------------------------------------------
228
- /** Get a session by thread ID */
229
- getSession(threadId) {
230
- return this.sessions.get(threadId);
231
- }
232
- /** Check if a session exists for this thread */
233
- hasSession(threadId) {
234
- return this.sessions.has(threadId);
235
- }
236
- /** Get the number of active sessions */
237
- getSessionCount() {
238
- return this.sessions.size;
239
- }
240
- /** Get all active session thread IDs */
241
- getActiveThreadIds() {
242
- return [...this.sessions.keys()];
243
- }
244
- /** Mark that we're shutting down (prevents cleanup of persisted sessions) */
245
- setShuttingDown() {
246
- console.log(' [shutdown] Setting isShuttingDown = true');
247
- this.isShuttingDown = true;
248
- }
249
- /** Register a post for reaction routing */
250
- registerPost(postId, threadId) {
251
- this.postIndex.set(postId, threadId);
252
- }
253
- /** Find session by post ID (for reaction routing) */
254
- getSessionByPost(postId) {
255
- const threadId = this.postIndex.get(postId);
256
- return threadId ? this.sessions.get(threadId) : undefined;
257
- }
258
- /**
259
- * Check if a user is allowed in a specific session.
260
- * Checks global allowlist first, then session-specific allowlist.
261
- */
262
- isUserAllowedInSession(threadId, username) {
263
- // Check global allowlist first
264
- if (this.mattermost.isUserAllowed(username))
265
- return true;
266
- // Check session-specific allowlist
267
- const session = this.sessions.get(threadId);
268
- if (session?.sessionAllowedUsers.has(username))
269
- return true;
270
- return false;
271
- }
272
- // ---------------------------------------------------------------------------
273
- // Session Lifecycle
274
- // ---------------------------------------------------------------------------
275
- async startSession(options, username, replyToPostId) {
276
- const threadId = replyToPostId || '';
277
- // Check if session already exists for this thread
278
- const existingSession = this.sessions.get(threadId);
279
- if (existingSession && existingSession.claude.isRunning()) {
280
- // Send as follow-up instead
281
- await this.sendFollowUp(threadId, options.prompt, options.files);
282
- return;
283
- }
284
- // Check max sessions limit
285
- if (this.sessions.size >= MAX_SESSIONS) {
286
- await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
287
- return;
288
- }
289
- // Post initial session message (will be updated by updateSessionHeader)
290
- let post;
291
- try {
292
- post = await this.mattermost.createPost(`${getMattermostLogo(pkg.version)}\n\n*Starting session...*`, replyToPostId);
293
- }
294
- catch (err) {
295
- console.error(` ❌ Failed to create session post:`, err);
296
- // If we can't post to the thread, we can't start a session
297
- return;
298
- }
299
- const actualThreadId = replyToPostId || post.id;
300
- // Generate a unique session ID for this Claude session
301
- const claudeSessionId = randomUUID();
302
- // Create Claude CLI with options
303
- const cliOptions = {
304
- workingDir: this.workingDir,
305
- threadId: actualThreadId,
306
- skipPermissions: this.skipPermissions,
307
- sessionId: claudeSessionId,
308
- resume: false,
309
- chrome: this.chromeEnabled,
310
- };
311
- const claude = new ClaudeCli(cliOptions);
312
- // Create the session object
313
- const session = {
314
- threadId: actualThreadId,
315
- claudeSessionId,
316
- startedBy: username,
317
- startedAt: new Date(),
318
- lastActivityAt: new Date(),
319
- sessionNumber: this.sessions.size + 1,
320
- workingDir: this.workingDir,
321
- claude,
322
- currentPostId: null,
323
- pendingContent: '',
324
- pendingApproval: null,
325
- pendingQuestionSet: null,
326
- pendingMessageApproval: null,
327
- planApproved: false,
328
- sessionAllowedUsers: new Set([username]), // Owner is always allowed
329
- forceInteractivePermissions: false, // Can be enabled via /permissions interactive
330
- sessionStartPostId: post.id, // Track for updating participants
331
- tasksPostId: null,
332
- activeSubagents: new Map(),
333
- updateTimer: null,
334
- typingTimer: null,
335
- timeoutWarningPosted: false,
336
- isRestarting: false,
337
- isResumed: false,
338
- wasInterrupted: false,
339
- inProgressTaskStart: null,
340
- activeToolStarts: new Map(),
341
- };
342
- // Register session
343
- this.sessions.set(actualThreadId, session);
344
- this.registerPost(post.id, actualThreadId); // For cancel reactions on session start post
345
- const shortId = actualThreadId.substring(0, 8);
346
- console.log(` ▶ Session #${this.sessions.size} started (${shortId}…) by @${username}`);
347
- // Update the header with full session info
348
- await this.updateSessionHeader(session);
349
- // Start typing indicator immediately so user sees activity
350
- this.startTyping(session);
351
- // Bind event handlers with closure over threadId
352
- claude.on('event', (e) => this.handleEvent(actualThreadId, e));
353
- claude.on('exit', (code) => this.handleExit(actualThreadId, code));
354
- try {
355
- claude.start();
356
- }
357
- catch (err) {
358
- console.error(' ❌ Failed to start Claude:', err);
359
- this.stopTyping(session);
360
- await this.mattermost.createPost(`❌ ${err}`, actualThreadId);
361
- this.sessions.delete(actualThreadId);
362
- return;
363
- }
364
- // Check if we should prompt for worktree
365
- const shouldPrompt = await this.shouldPromptForWorktree(session);
366
- if (shouldPrompt) {
367
- // Queue the original message and prompt for branch name
368
- session.queuedPrompt = options.prompt;
369
- session.pendingWorktreePrompt = true;
370
- await this.postWorktreePrompt(session, shouldPrompt);
371
- // Persist session with pending state
372
- this.persistSession(session);
373
- return; // Don't send message to Claude yet
374
- }
375
- // Send the message to Claude (with images if present)
376
- const content = await this.buildMessageContent(options.prompt, options.files);
377
- claude.sendMessage(content);
378
- // Persist session for resume after restart
379
- this.persistSession(session);
380
- }
381
- /**
382
- * Start a session with an initial worktree specified.
383
- * Used when user specifies "on branch X" or "!worktree X" in their initial message.
384
- */
385
- async startSessionWithWorktree(options, branch, username, replyToPostId) {
386
- // Start the session normally first
387
- await this.startSession(options, username, replyToPostId);
388
- // Get the thread ID
389
- const threadId = replyToPostId || '';
390
- const session = this.sessions.get(threadId);
391
- if (!session)
392
- return;
393
- // If session has a pending worktree prompt (from startSession), skip it
394
- if (session.pendingWorktreePrompt) {
395
- session.pendingWorktreePrompt = false;
396
- if (session.worktreePromptPostId) {
397
- try {
398
- await this.mattermost.updatePost(session.worktreePromptPostId, `✅ Using branch \`${branch}\` (specified in message)`);
399
- }
400
- catch (err) {
401
- console.error(' ⚠️ Failed to update worktree prompt:', err);
402
- }
403
- session.worktreePromptPostId = undefined;
404
- }
405
- }
406
- // Create the worktree
407
- await this.createAndSwitchToWorktree(threadId, branch, username);
408
- }
409
- /**
410
- * Check if we should prompt for a worktree before starting work.
411
- * Returns the reason string if we should prompt, or null if not.
412
- */
413
- async shouldPromptForWorktree(session) {
414
- // Skip if worktree mode is off
415
- if (this.worktreeMode === 'off')
416
- return null;
417
- // Skip if user disabled prompts for this session
418
- if (session.worktreePromptDisabled)
419
- return null;
420
- // Skip if already in a worktree
421
- if (session.worktreeInfo)
422
- return null;
423
- // Check if we're in a git repository
424
- const isRepo = await isGitRepository(session.workingDir);
425
- if (!isRepo)
426
- return null;
427
- // For 'require' mode, always prompt
428
- if (this.worktreeMode === 'require') {
429
- return 'require';
430
- }
431
- // For 'prompt' mode, check conditions
432
- // Condition 1: uncommitted changes
433
- const hasChanges = await hasUncommittedChanges(session.workingDir);
434
- if (hasChanges)
435
- return 'uncommitted';
436
- // Condition 2: another session using the same repo
437
- const repoRoot = await getRepositoryRoot(session.workingDir);
438
- const hasConcurrent = this.hasOtherSessionInRepo(repoRoot, session.threadId);
439
- if (hasConcurrent)
440
- return 'concurrent';
441
- return null;
442
- }
443
- /**
444
- * Check if another session is using the same repository
445
- */
446
- hasOtherSessionInRepo(repoRoot, excludeThreadId) {
447
- for (const [threadId, session] of this.sessions) {
448
- if (threadId === excludeThreadId)
449
- continue;
450
- // Check if session's working directory is in the same repo
451
- // (either the repo root or a worktree of the same repo)
452
- if (session.workingDir === repoRoot)
453
- return true;
454
- if (session.worktreeInfo?.repoRoot === repoRoot)
455
- return true;
456
- }
457
- return false;
458
- }
459
- /**
460
- * Post the worktree prompt message
461
- */
462
- async postWorktreePrompt(session, reason) {
463
- let message;
464
- switch (reason) {
465
- case 'uncommitted':
466
- message = `🌿 **This repo has uncommitted changes.**\n` +
467
- `Reply with a branch name to work in an isolated worktree, or react with ❌ to continue in the main repo.`;
468
- break;
469
- case 'concurrent':
470
- message = `⚠️ **Another session is already using this repo.**\n` +
471
- `Reply with a branch name to work in an isolated worktree, or react with ❌ to continue anyway.`;
472
- break;
473
- case 'require':
474
- message = `🌿 **This deployment requires working in a worktree.**\n` +
475
- `Please reply with a branch name to continue.`;
476
- break;
477
- default:
478
- message = `🌿 **Would you like to work in an isolated worktree?**\n` +
479
- `Reply with a branch name, or react with ❌ to continue in the main repo.`;
480
- }
481
- // Create post with ❌ reaction option (except for 'require' mode)
482
- // Use 'x' emoji name, not Unicode ❌ character
483
- const reactionOptions = reason === 'require' ? [] : ['x'];
484
- const post = await this.mattermost.createInteractivePost(message, reactionOptions, session.threadId);
485
- // Track the post for reaction handling
486
- session.worktreePromptPostId = post.id;
487
- this.registerPost(post.id, session.threadId);
488
- // Stop typing while waiting for response
489
- this.stopTyping(session);
490
- }
491
- handleEvent(threadId, event) {
492
- const session = this.sessions.get(threadId);
493
- if (!session)
494
- return;
495
- // Update last activity and reset timeout warning
496
- session.lastActivityAt = new Date();
497
- session.timeoutWarningPosted = false;
498
- // Check for special tool uses that need custom handling
499
- if (event.type === 'assistant') {
500
- const msg = event.message;
501
- let hasSpecialTool = false;
502
- for (const block of msg?.content || []) {
503
- if (block.type === 'tool_use') {
504
- if (block.name === 'ExitPlanMode') {
505
- this.handleExitPlanMode(session, block.id);
506
- hasSpecialTool = true;
507
- }
508
- else if (block.name === 'TodoWrite') {
509
- this.handleTodoWrite(session, block.input);
510
- }
511
- else if (block.name === 'Task') {
512
- this.handleTaskStart(session, block.id, block.input);
513
- }
514
- else if (block.name === 'AskUserQuestion') {
515
- this.handleAskUserQuestion(session, block.id, block.input);
516
- hasSpecialTool = true;
517
- }
518
- }
519
- }
520
- if (hasSpecialTool)
521
- return;
522
- }
523
- // Check for tool_result to update subagent status
524
- if (event.type === 'user') {
525
- const msg = event.message;
526
- for (const block of msg?.content || []) {
527
- if (block.type === 'tool_result' && block.tool_use_id) {
528
- const postId = session.activeSubagents.get(block.tool_use_id);
529
- if (postId) {
530
- this.handleTaskComplete(session, block.tool_use_id, postId);
531
- }
532
- }
533
- }
534
- }
535
- const formatted = this.formatEvent(session, event);
536
- if (this.debug) {
537
- console.log(`[DEBUG] handleEvent(${threadId}): ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
538
- }
539
- if (formatted)
540
- this.appendContent(session, formatted);
541
- }
542
- async handleTaskComplete(session, toolUseId, postId) {
543
- try {
544
- await this.mattermost.updatePost(postId, session.activeSubagents.has(toolUseId)
545
- ? `🤖 **Subagent** ✅ *completed*`
546
- : `🤖 **Subagent** ✅`);
547
- session.activeSubagents.delete(toolUseId);
548
- }
549
- catch (err) {
550
- console.error(' ⚠️ Failed to update subagent completion:', err);
551
- }
552
- }
553
- async handleExitPlanMode(session, toolUseId) {
554
- // If already approved in this session, send empty tool result to acknowledge
555
- // (Claude needs a response to continue)
556
- if (session.planApproved) {
557
- if (this.debug)
558
- console.log(' ↪ Plan already approved, sending acknowledgment');
559
- if (session.claude.isRunning()) {
560
- session.claude.sendToolResult(toolUseId, 'Plan already approved. Proceeding.');
561
- }
562
- return;
563
- }
564
- // If we already have a pending approval, don't post another one
565
- if (session.pendingApproval && session.pendingApproval.type === 'plan') {
566
- if (this.debug)
567
- console.log(' ↪ Plan approval already pending, waiting');
568
- return;
569
- }
570
- // Flush any pending content first
571
- await this.flush(session);
572
- session.currentPostId = null;
573
- session.pendingContent = '';
574
- // Post approval message with reactions
575
- const message = `✅ **Plan ready for approval**\n\n` +
576
- `👍 Approve and start building\n` +
577
- `👎 Request changes\n\n` +
578
- `*React to respond*`;
579
- const post = await this.mattermost.createInteractivePost(message, [APPROVAL_EMOJIS[0], DENIAL_EMOJIS[0]], session.threadId);
580
- // Register post for reaction routing
581
- this.registerPost(post.id, session.threadId);
582
- // Track this for reaction handling - include toolUseId for proper response
583
- session.pendingApproval = { postId: post.id, type: 'plan', toolUseId };
584
- // Stop typing while waiting
585
- this.stopTyping(session);
586
- }
587
- async handleTodoWrite(session, input) {
588
- const todos = input.todos;
589
- if (!todos || todos.length === 0) {
590
- // Clear tasks display if empty
591
- if (session.tasksPostId) {
592
- try {
593
- await this.mattermost.updatePost(session.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
594
- }
595
- catch (err) {
596
- console.error(' ⚠️ Failed to update tasks:', err);
597
- }
598
- }
599
- return;
600
- }
601
- // Count progress
602
- const completed = todos.filter(t => t.status === 'completed').length;
603
- const total = todos.length;
604
- const pct = Math.round((completed / total) * 100);
605
- // Check if there's an in_progress task and track timing
606
- const hasInProgress = todos.some(t => t.status === 'in_progress');
607
- if (hasInProgress && !session.inProgressTaskStart) {
608
- session.inProgressTaskStart = Date.now();
609
- }
610
- else if (!hasInProgress) {
611
- session.inProgressTaskStart = null;
612
- }
613
- // Format tasks nicely with progress header
614
- let message = `📋 **Tasks** (${completed}/${total} · ${pct}%)\n\n`;
615
- for (const todo of todos) {
616
- let icon;
617
- let text;
618
- switch (todo.status) {
619
- case 'completed':
620
- icon = '✅';
621
- text = `~~${todo.content}~~`;
622
- break;
623
- case 'in_progress': {
624
- icon = '🔄';
625
- // Add elapsed time if we have a start time
626
- let elapsed = '';
627
- if (session.inProgressTaskStart) {
628
- const secs = Math.round((Date.now() - session.inProgressTaskStart) / 1000);
629
- if (secs >= 5) { // Only show if >= 5 seconds
630
- elapsed = ` (${secs}s)`;
631
- }
632
- }
633
- text = `**${todo.activeForm}**${elapsed}`;
634
- break;
635
- }
636
- default: // pending
637
- icon = '○';
638
- text = todo.content;
639
- }
640
- message += `${icon} ${text}\n`;
641
- }
642
- // Update or create tasks post
643
- try {
644
- if (session.tasksPostId) {
645
- await this.mattermost.updatePost(session.tasksPostId, message);
646
- }
647
- else {
648
- const post = await this.mattermost.createPost(message, session.threadId);
649
- session.tasksPostId = post.id;
650
- }
651
- }
652
- catch (err) {
653
- console.error(' ⚠️ Failed to update tasks:', err);
654
- }
655
- }
656
- async handleTaskStart(session, toolUseId, input) {
657
- const description = input.description || 'Working...';
658
- const subagentType = input.subagent_type || 'general';
659
- // Post subagent status
660
- const message = `🤖 **Subagent** *(${subagentType})*\n` +
661
- `> ${description}\n` +
662
- `⏳ Running...`;
663
- try {
664
- const post = await this.mattermost.createPost(message, session.threadId);
665
- session.activeSubagents.set(toolUseId, post.id);
666
- }
667
- catch (err) {
668
- console.error(' ⚠️ Failed to post subagent status:', err);
669
- }
670
- }
671
- async handleAskUserQuestion(session, toolUseId, input) {
672
- // If we already have pending questions, don't start another set
673
- if (session.pendingQuestionSet) {
674
- if (this.debug)
675
- console.log(' ↪ Questions already pending, waiting');
676
- return;
677
- }
678
- // Flush any pending content first
679
- await this.flush(session);
680
- session.currentPostId = null;
681
- session.pendingContent = '';
682
- const questions = input.questions;
683
- if (!questions || questions.length === 0)
684
- return;
685
- // Create a new question set - we'll ask one at a time
686
- session.pendingQuestionSet = {
687
- toolUseId,
688
- currentIndex: 0,
689
- currentPostId: null,
690
- questions: questions.map(q => ({
691
- header: q.header,
692
- question: q.question,
693
- options: q.options,
694
- answer: null,
695
- })),
696
- };
697
- // Post the first question
698
- await this.postCurrentQuestion(session);
699
- // Stop typing while waiting for answer
700
- this.stopTyping(session);
701
- }
702
- async postCurrentQuestion(session) {
703
- if (!session.pendingQuestionSet)
704
- return;
705
- const { currentIndex, questions } = session.pendingQuestionSet;
706
- if (currentIndex >= questions.length)
707
- return;
708
- const q = questions[currentIndex];
709
- const total = questions.length;
710
- // Format the question message
711
- let message = `❓ **Question** *(${currentIndex + 1}/${total})*\n`;
712
- message += `**${q.header}:** ${q.question}\n\n`;
713
- for (let i = 0; i < q.options.length && i < 4; i++) {
714
- const emoji = ['1️⃣', '2️⃣', '3️⃣', '4️⃣'][i];
715
- message += `${emoji} **${q.options[i].label}**`;
716
- if (q.options[i].description) {
717
- message += ` - ${q.options[i].description}`;
718
- }
719
- message += '\n';
720
- }
721
- // Post the question with reaction options
722
- const reactionOptions = NUMBER_EMOJIS.slice(0, q.options.length);
723
- const post = await this.mattermost.createInteractivePost(message, reactionOptions, session.threadId);
724
- session.pendingQuestionSet.currentPostId = post.id;
725
- // Register post for reaction routing
726
- this.registerPost(post.id, session.threadId);
727
- }
728
- // ---------------------------------------------------------------------------
729
- // Reaction Handling
730
- // ---------------------------------------------------------------------------
731
- async handleReaction(postId, emojiName, username) {
732
- // Check if user is allowed
733
- if (!this.mattermost.isUserAllowed(username))
734
- return;
735
- // Find the session this post belongs to
736
- const session = this.getSessionByPost(postId);
737
- if (!session)
738
- return;
739
- // Handle ❌ on worktree prompt (skip worktree, continue in main repo)
740
- // Must be checked BEFORE cancel reaction handler since ❌ is also a cancel emoji
741
- if (session.worktreePromptPostId === postId && emojiName === 'x') {
742
- await this.handleWorktreeSkip(session.threadId, username);
743
- return;
744
- }
745
- // Handle cancel reactions (❌ or 🛑) on any post in the session
746
- if (isCancelEmoji(emojiName)) {
747
- await this.cancelSession(session.threadId, username);
748
- return;
749
- }
750
- // Handle interrupt reactions (⏸️) on any post in the session
751
- if (isEscapeEmoji(emojiName)) {
752
- await this.interruptSession(session.threadId, username);
753
- return;
754
- }
755
- // Handle approval reactions
756
- if (session.pendingApproval && session.pendingApproval.postId === postId) {
757
- await this.handleApprovalReaction(session, emojiName, username);
758
- return;
759
- }
760
- // Handle question reactions
761
- if (session.pendingQuestionSet && session.pendingQuestionSet.currentPostId === postId) {
762
- await this.handleQuestionReaction(session, postId, emojiName, username);
763
- return;
764
- }
765
- // Handle message approval reactions
766
- if (session.pendingMessageApproval && session.pendingMessageApproval.postId === postId) {
767
- await this.handleMessageApprovalReaction(session, emojiName, username);
768
- return;
769
- }
770
- }
771
- async handleQuestionReaction(session, postId, emojiName, username) {
772
- if (!session.pendingQuestionSet)
773
- return;
774
- const { currentIndex, questions } = session.pendingQuestionSet;
775
- const question = questions[currentIndex];
776
- if (!question)
777
- return;
778
- const optionIndex = getNumberEmojiIndex(emojiName);
779
- if (optionIndex < 0 || optionIndex >= question.options.length)
780
- return;
781
- const selectedOption = question.options[optionIndex];
782
- question.answer = selectedOption.label;
783
- if (this.debug)
784
- console.log(` 💬 @${username} answered "${question.header}": ${selectedOption.label}`);
785
- // Update the post to show answer
786
- try {
787
- await this.mattermost.updatePost(postId, `✅ **${question.header}**: ${selectedOption.label}`);
788
- }
789
- catch (err) {
790
- console.error(' ⚠️ Failed to update answered question:', err);
791
- }
792
- // Move to next question or finish
793
- session.pendingQuestionSet.currentIndex++;
794
- if (session.pendingQuestionSet.currentIndex < questions.length) {
795
- // Post next question
796
- await this.postCurrentQuestion(session);
797
- }
798
- else {
799
- // All questions answered - send tool result
800
- let answersText = 'Here are my answers:\n';
801
- for (const q of questions) {
802
- answersText += `- **${q.header}**: ${q.answer}\n`;
803
- }
804
- if (this.debug)
805
- console.log(' ✅ All questions answered');
806
- // Get the toolUseId before clearing
807
- const toolUseId = session.pendingQuestionSet.toolUseId;
808
- // Clear pending questions
809
- session.pendingQuestionSet = null;
810
- // Send tool result to Claude (AskUserQuestion expects a tool_result, not a user message)
811
- if (session.claude.isRunning()) {
812
- session.claude.sendToolResult(toolUseId, answersText);
813
- this.startTyping(session);
814
- }
815
- }
816
- }
817
- async handleApprovalReaction(session, emojiName, username) {
818
- if (!session.pendingApproval)
819
- return;
820
- const isApprove = isApprovalEmoji(emojiName);
821
- const isReject = isDenialEmoji(emojiName);
822
- if (!isApprove && !isReject)
823
- return;
824
- const { postId, toolUseId } = session.pendingApproval;
825
- const shortId = session.threadId.substring(0, 8);
826
- console.log(` ${isApprove ? '✅' : '❌'} Plan ${isApprove ? 'approved' : 'rejected'} (${shortId}…) by @${username}`);
827
- // Update the post to show the decision
828
- try {
829
- const statusMessage = isApprove
830
- ? `✅ **Plan approved** by @${username} - starting implementation...`
831
- : `❌ **Changes requested** by @${username}`;
832
- await this.mattermost.updatePost(postId, statusMessage);
833
- }
834
- catch (err) {
835
- console.error(' ⚠️ Failed to update approval post:', err);
836
- }
837
- // Clear pending approval and mark as approved
838
- session.pendingApproval = null;
839
- if (isApprove) {
840
- session.planApproved = true;
841
- }
842
- // Send tool result to Claude (ExitPlanMode expects a tool_result, not a user message)
843
- if (session.claude.isRunning()) {
844
- const response = isApprove
845
- ? 'Approved. Please proceed with the implementation.'
846
- : 'Please revise the plan. I would like some changes.';
847
- session.claude.sendToolResult(toolUseId, response);
848
- this.startTyping(session);
849
- }
850
- }
851
- async handleMessageApprovalReaction(session, emoji, approver) {
852
- const pending = session.pendingMessageApproval;
853
- if (!pending)
854
- return;
855
- // Only session owner or globally allowed users can approve
856
- if (session.startedBy !== approver && !this.mattermost.isUserAllowed(approver)) {
857
- return;
858
- }
859
- const isAllow = isApprovalEmoji(emoji);
860
- const isInvite = isAllowAllEmoji(emoji);
861
- const isDeny = isDenialEmoji(emoji);
862
- if (!isAllow && !isInvite && !isDeny)
863
- return;
864
- if (isAllow) {
865
- // Allow this single message
866
- await this.mattermost.updatePost(pending.postId, `✅ Message from @${pending.fromUser} approved by @${approver}`);
867
- session.claude.sendMessage(pending.originalMessage);
868
- session.lastActivityAt = new Date();
869
- this.startTyping(session);
870
- console.log(` ✅ Message from @${pending.fromUser} approved by @${approver}`);
871
- }
872
- else if (isInvite) {
873
- // Invite user to session
874
- session.sessionAllowedUsers.add(pending.fromUser);
875
- await this.mattermost.updatePost(pending.postId, `✅ @${pending.fromUser} invited to session by @${approver}`);
876
- await this.updateSessionHeader(session);
877
- session.claude.sendMessage(pending.originalMessage);
878
- session.lastActivityAt = new Date();
879
- this.startTyping(session);
880
- console.log(` 👋 @${pending.fromUser} invited to session by @${approver}`);
881
- }
882
- else if (isDeny) {
883
- // Deny
884
- await this.mattermost.updatePost(pending.postId, `❌ Message from @${pending.fromUser} denied by @${approver}`);
885
- console.log(` ❌ Message from @${pending.fromUser} denied by @${approver}`);
886
- }
887
- session.pendingMessageApproval = null;
888
- }
889
- formatEvent(session, e) {
890
- switch (e.type) {
891
- case 'assistant': {
892
- const msg = e.message;
893
- const parts = [];
894
- for (const block of msg?.content || []) {
895
- if (block.type === 'text' && block.text) {
896
- // Filter out <thinking> tags that may appear in text content
897
- const text = block.text.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim();
898
- if (text)
899
- parts.push(text);
900
- }
901
- else if (block.type === 'tool_use' && block.name) {
902
- const formatted = sharedFormatToolUse(block.name, block.input || {}, { detailed: true });
903
- if (formatted)
904
- parts.push(formatted);
905
- }
906
- else if (block.type === 'thinking' && block.thinking) {
907
- // Extended thinking - show abbreviated version
908
- const thinking = block.thinking;
909
- const preview = thinking.length > 100 ? thinking.substring(0, 100) + '...' : thinking;
910
- parts.push(`💭 *Thinking: ${preview}*`);
911
- }
912
- else if (block.type === 'server_tool_use' && block.name) {
913
- // Server-managed tools like web search
914
- parts.push(`🌐 **${block.name}** ${block.input ? JSON.stringify(block.input).substring(0, 50) : ''}`);
915
- }
916
- }
917
- return parts.length > 0 ? parts.join('\n') : null;
918
- }
919
- case 'tool_use': {
920
- const tool = e.tool_use;
921
- // Track tool start time for elapsed display
922
- if (tool.id) {
923
- session.activeToolStarts.set(tool.id, Date.now());
924
- }
925
- return sharedFormatToolUse(tool.name, tool.input || {}, { detailed: true }) || null;
926
- }
927
- case 'tool_result': {
928
- const result = e.tool_result;
929
- // Calculate elapsed time
930
- let elapsed = '';
931
- if (result.tool_use_id) {
932
- const startTime = session.activeToolStarts.get(result.tool_use_id);
933
- if (startTime) {
934
- const secs = Math.round((Date.now() - startTime) / 1000);
935
- if (secs >= 3) { // Only show if >= 3 seconds
936
- elapsed = ` (${secs}s)`;
937
- }
938
- session.activeToolStarts.delete(result.tool_use_id);
939
- }
940
- }
941
- if (result.is_error)
942
- return ` ↳ ❌ Error${elapsed}`;
943
- if (elapsed)
944
- return ` ↳ ✓${elapsed}`;
945
- return null;
946
- }
947
- case 'result': {
948
- // Response complete - stop typing and start new post for next message
949
- this.stopTyping(session);
950
- this.flush(session);
951
- session.currentPostId = null;
952
- session.pendingContent = '';
953
- return null;
954
- }
955
- case 'system':
956
- if (e.subtype === 'error')
957
- return `❌ ${e.error}`;
958
- return null;
959
- case 'user': {
960
- // Handle local command output (e.g., /context, /cost responses)
961
- const msg = e.message;
962
- if (typeof msg?.content === 'string') {
963
- // Extract content from <local-command-stdout> tags
964
- const match = msg.content.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
965
- if (match) {
966
- return match[1].trim();
967
- }
968
- }
969
- return null;
970
- }
971
- default:
972
- return null;
973
- }
974
- }
975
- appendContent(session, text) {
976
- if (!text)
977
- return;
978
- session.pendingContent += text + '\n';
979
- this.scheduleUpdate(session);
980
- }
981
- scheduleUpdate(session) {
982
- if (session.updateTimer)
983
- return;
984
- session.updateTimer = setTimeout(() => {
985
- session.updateTimer = null;
986
- this.flush(session);
987
- }, 500);
988
- }
989
- /**
990
- * Build message content for Claude, including images if present.
991
- * Returns either a string or an array of content blocks.
992
- */
993
- async buildMessageContent(text, files) {
994
- // Filter to only image files
995
- const imageFiles = files?.filter(f => f.mime_type.startsWith('image/') &&
996
- ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(f.mime_type)) || [];
997
- // If no images, return plain text
998
- if (imageFiles.length === 0) {
999
- return text;
1000
- }
1001
- // Build content blocks with images
1002
- const blocks = [];
1003
- // Download and add each image
1004
- for (const file of imageFiles) {
1005
- try {
1006
- const buffer = await this.mattermost.downloadFile(file.id);
1007
- const base64 = buffer.toString('base64');
1008
- blocks.push({
1009
- type: 'image',
1010
- source: {
1011
- type: 'base64',
1012
- media_type: file.mime_type,
1013
- data: base64,
1014
- },
1015
- });
1016
- if (this.debug) {
1017
- console.log(` 📷 Attached image: ${file.name} (${file.mime_type}, ${Math.round(buffer.length / 1024)}KB)`);
1018
- }
1019
- }
1020
- catch (err) {
1021
- console.error(` ⚠️ Failed to download image ${file.name}:`, err);
1022
- }
1023
- }
1024
- // Add the text message
1025
- if (text) {
1026
- blocks.push({
1027
- type: 'text',
1028
- text,
1029
- });
1030
- }
1031
- return blocks;
1032
- }
1033
- startTyping(session) {
1034
- if (session.typingTimer)
1035
- return;
1036
- // Send typing immediately, then every 3 seconds
1037
- this.mattermost.sendTyping(session.threadId);
1038
- session.typingTimer = setInterval(() => {
1039
- this.mattermost.sendTyping(session.threadId);
1040
- }, 3000);
1041
- }
1042
- stopTyping(session) {
1043
- if (session.typingTimer) {
1044
- clearInterval(session.typingTimer);
1045
- session.typingTimer = null;
1046
- }
1047
- }
1048
- async flush(session) {
1049
- if (!session.pendingContent.trim())
1050
- return;
1051
- let content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
1052
- // Mattermost has a 16,383 character limit for posts
1053
- const MAX_POST_LENGTH = 16000; // Leave some margin
1054
- const CONTINUATION_THRESHOLD = 14000; // Start new message before we hit the limit
1055
- // Check if we need to start a new message due to length
1056
- if (session.currentPostId && content.length > CONTINUATION_THRESHOLD) {
1057
- // Finalize the current post with what we have up to the threshold
1058
- // Find a good break point (end of line) near the threshold
1059
- let breakPoint = content.lastIndexOf('\n', CONTINUATION_THRESHOLD);
1060
- if (breakPoint < CONTINUATION_THRESHOLD * 0.7) {
1061
- // If we can't find a good line break, just break at the threshold
1062
- breakPoint = CONTINUATION_THRESHOLD;
1063
- }
1064
- const firstPart = content.substring(0, breakPoint).trim() + '\n\n*... (continued below)*';
1065
- const remainder = content.substring(breakPoint).trim();
1066
- // Update the current post with the first part
1067
- await this.mattermost.updatePost(session.currentPostId, firstPart);
1068
- // Start a new post for the continuation
1069
- session.currentPostId = null;
1070
- session.pendingContent = remainder;
1071
- // Create the continuation post if there's content
1072
- if (remainder) {
1073
- const post = await this.mattermost.createPost('*(continued)*\n\n' + remainder, session.threadId);
1074
- session.currentPostId = post.id;
1075
- this.registerPost(post.id, session.threadId);
1076
- }
1077
- return;
1078
- }
1079
- // Normal case: content fits in current post
1080
- if (content.length > MAX_POST_LENGTH) {
1081
- // Safety truncation if we somehow got content that's still too long
1082
- content = content.substring(0, MAX_POST_LENGTH - 50) + '\n\n*... (truncated)*';
1083
- }
1084
- if (session.currentPostId) {
1085
- await this.mattermost.updatePost(session.currentPostId, content);
1086
- }
1087
- else {
1088
- const post = await this.mattermost.createPost(content, session.threadId);
1089
- session.currentPostId = post.id;
1090
- // Register post for reaction routing
1091
- this.registerPost(post.id, session.threadId);
1092
- }
1093
- }
1094
- async handleExit(threadId, code) {
1095
- const session = this.sessions.get(threadId);
1096
- const shortId = threadId.substring(0, 8);
1097
- // Always log exit events to trace the flow
1098
- console.log(` [exit] handleExit called for ${shortId}... code=${code} isShuttingDown=${this.isShuttingDown}`);
1099
- if (!session) {
1100
- console.log(` [exit] Session ${shortId}... not found (already cleaned up)`);
1101
- return;
1102
- }
1103
- // If we're intentionally restarting (e.g., !cd), don't clean up or post exit message
1104
- if (session.isRestarting) {
1105
- console.log(` [exit] Session ${shortId}... restarting, skipping cleanup`);
1106
- session.isRestarting = false; // Reset flag here, after the exit event fires
1107
- return;
1108
- }
1109
- // If bot is shutting down, suppress exit messages (shutdown message already sent)
1110
- // IMPORTANT: Check this flag FIRST before any cleanup. The session should remain
1111
- // persisted so it can be resumed after restart.
1112
- if (this.isShuttingDown) {
1113
- console.log(` [exit] Session ${shortId}... bot shutting down, preserving persistence`);
1114
- // Still clean up from in-memory maps since we're shutting down anyway
1115
- this.stopTyping(session);
1116
- if (session.updateTimer) {
1117
- clearTimeout(session.updateTimer);
1118
- session.updateTimer = null;
1119
- }
1120
- this.sessions.delete(threadId);
1121
- return;
1122
- }
1123
- // If session was interrupted (SIGINT sent), preserve for resume
1124
- // Claude CLI exits on SIGINT, but we want to allow resuming the session
1125
- if (session.wasInterrupted) {
1126
- console.log(` [exit] Session ${shortId}... exited after interrupt, preserving for resume`);
1127
- this.stopTyping(session);
1128
- if (session.updateTimer) {
1129
- clearTimeout(session.updateTimer);
1130
- session.updateTimer = null;
1131
- }
1132
- // Update persistence with current state before cleanup
1133
- this.persistSession(session);
1134
- this.sessions.delete(threadId);
1135
- // Clean up post index
1136
- for (const [postId, tid] of this.postIndex.entries()) {
1137
- if (tid === threadId) {
1138
- this.postIndex.delete(postId);
1139
- }
1140
- }
1141
- // Notify user they can send a new message to resume
1142
- try {
1143
- await this.mattermost.createPost(`ℹ️ Session paused. Send a new message to continue.`, session.threadId);
1144
- }
1145
- catch {
1146
- // Ignore if we can't post
1147
- }
1148
- console.log(` ⏸️ Session paused (${shortId}…) — ${this.sessions.size} active`);
1149
- return;
1150
- }
1151
- // For resumed sessions that exit quickly (e.g., Claude --resume fails),
1152
- // don't unpersist immediately - give it a chance to be retried
1153
- if (session.isResumed && code !== 0) {
1154
- console.log(` [exit] Resumed session ${shortId}... failed with code ${code}, preserving for retry`);
1155
- this.stopTyping(session);
1156
- if (session.updateTimer) {
1157
- clearTimeout(session.updateTimer);
1158
- session.updateTimer = null;
1159
- }
1160
- this.sessions.delete(threadId);
1161
- // Post error message but keep persistence
1162
- try {
1163
- await this.mattermost.createPost(`⚠️ **Session resume failed** (exit code ${code}). The session data is preserved - try restarting the bot.`, session.threadId);
1164
- }
1165
- catch {
1166
- // Ignore if we can't post
1167
- }
1168
- return;
1169
- }
1170
- console.log(` [exit] Session ${shortId}... normal exit, cleaning up`);
1171
- this.stopTyping(session);
1172
- if (session.updateTimer) {
1173
- clearTimeout(session.updateTimer);
1174
- session.updateTimer = null;
1175
- }
1176
- await this.flush(session);
1177
- if (code !== 0 && code !== null) {
1178
- await this.mattermost.createPost(`**[Exited: ${code}]**`, session.threadId);
1179
- }
1180
- // Clean up session from maps
1181
- this.sessions.delete(threadId);
1182
- // Clean up post index entries for this session
1183
- for (const [postId, tid] of this.postIndex.entries()) {
1184
- if (tid === threadId) {
1185
- this.postIndex.delete(postId);
1186
- }
1187
- }
1188
- // Only unpersist for normal exits (code 0 or null means graceful completion)
1189
- // Non-zero exits might be recoverable, so we keep the session persisted
1190
- if (code === 0 || code === null) {
1191
- this.unpersistSession(threadId);
1192
- }
1193
- else {
1194
- console.log(` [exit] Session ${shortId}... non-zero exit, preserving for potential retry`);
1195
- }
1196
- console.log(` ■ Session ended (${shortId}…) — ${this.sessions.size} active`);
1197
- }
1198
- // ---------------------------------------------------------------------------
1199
- // Public Session API
1200
- // ---------------------------------------------------------------------------
1201
- /** Check if any sessions are active */
1202
- isSessionActive() {
1203
- return this.sessions.size > 0;
1204
- }
1205
- /** Check if a session exists for this thread */
1206
- isInSessionThread(threadRoot) {
1207
- const session = this.sessions.get(threadRoot);
1208
- return session !== undefined && session.claude.isRunning();
1209
- }
1210
- /** Send a follow-up message to an existing session */
1211
- async sendFollowUp(threadId, message, files) {
1212
- const session = this.sessions.get(threadId);
1213
- if (!session || !session.claude.isRunning())
1214
- return;
1215
- const content = await this.buildMessageContent(message, files);
1216
- session.claude.sendMessage(content);
1217
- session.lastActivityAt = new Date();
1218
- this.startTyping(session);
1219
- }
1220
- /**
1221
- * Check if there's a paused (persisted but not active) session for this thread.
1222
- * This is used to detect when we should resume a session instead of ignoring the message.
1223
- */
1224
- hasPausedSession(threadId) {
1225
- // If there's an active session, it's not paused
1226
- if (this.sessions.has(threadId))
1227
- return false;
1228
- // Check persistence
1229
- const persisted = this.sessionStore.load();
1230
- return persisted.has(threadId);
1231
- }
1232
- /**
1233
- * Resume a paused session and send a message to it.
1234
- * Called when a user sends a message to a thread with a paused session.
1235
- */
1236
- async resumePausedSession(threadId, message, files) {
1237
- const persisted = this.sessionStore.load();
1238
- const state = persisted.get(threadId);
1239
- if (!state) {
1240
- console.log(` [resume] No persisted session found for ${threadId.substring(0, 8)}...`);
1241
- return;
1242
- }
1243
- const shortId = threadId.substring(0, 8);
1244
- console.log(` 🔄 Resuming paused session ${shortId}... for new message`);
1245
- // Resume the session (similar to initialize() but for a single session)
1246
- await this.resumeSession(state);
1247
- // Wait a moment for the session to be ready, then send the message
1248
- const session = this.sessions.get(threadId);
1249
- if (session && session.claude.isRunning()) {
1250
- const content = await this.buildMessageContent(message, files);
1251
- session.claude.sendMessage(content);
1252
- session.lastActivityAt = new Date();
1253
- this.startTyping(session);
1254
- }
1255
- else {
1256
- console.log(` ⚠️ Failed to resume session ${shortId}..., could not send message`);
1257
- }
1258
- }
1259
- /**
1260
- * Get persisted session info for access control checks
1261
- */
1262
- getPersistedSession(threadId) {
1263
- const persisted = this.sessionStore.load();
1264
- return persisted.get(threadId);
1265
- }
1266
- /** Kill a specific session */
1267
- killSession(threadId, unpersist = true) {
1268
- const session = this.sessions.get(threadId);
1269
- if (!session)
1270
- return;
1271
- const shortId = threadId.substring(0, 8);
1272
- // Set restarting flag to prevent handleExit from also unpersisting
1273
- // (we'll do it explicitly here if requested)
1274
- if (!unpersist) {
1275
- session.isRestarting = true; // Reuse this flag to skip cleanup in handleExit
1276
- }
1277
- this.stopTyping(session);
1278
- session.claude.kill();
1279
- // Clean up session from maps
1280
- this.sessions.delete(threadId);
1281
- for (const [postId, tid] of this.postIndex.entries()) {
1282
- if (tid === threadId) {
1283
- this.postIndex.delete(postId);
1284
- }
1285
- }
1286
- // Explicitly unpersist if requested (e.g., for timeout, cancel, etc.)
1287
- if (unpersist) {
1288
- this.unpersistSession(threadId);
1289
- }
1290
- console.log(` ✖ Session killed (${shortId}…) — ${this.sessions.size} active`);
1291
- }
1292
- /** Cancel a session with user feedback */
1293
- async cancelSession(threadId, username) {
1294
- const session = this.sessions.get(threadId);
1295
- if (!session)
1296
- return;
1297
- const shortId = threadId.substring(0, 8);
1298
- console.log(` 🛑 Session (${shortId}…) cancelled by @${username}`);
1299
- await this.mattermost.createPost(`🛑 **Session cancelled** by @${username}`, threadId);
1300
- this.killSession(threadId);
1301
- }
1302
- /** Interrupt current processing but keep session alive (like Escape in CLI) */
1303
- async interruptSession(threadId, username) {
1304
- const session = this.sessions.get(threadId);
1305
- if (!session)
1306
- return;
1307
- if (!session.claude.isRunning()) {
1308
- await this.mattermost.createPost(`ℹ️ Session is idle, nothing to interrupt`, threadId);
1309
- return;
1310
- }
1311
- const shortId = threadId.substring(0, 8);
1312
- // Set flag BEFORE interrupt - if Claude exits due to SIGINT, we won't unpersist
1313
- session.wasInterrupted = true;
1314
- const interrupted = session.claude.interrupt();
1315
- if (interrupted) {
1316
- console.log(` ⏸️ Session (${shortId}…) interrupted by @${username}`);
1317
- await this.mattermost.createPost(`⏸️ **Interrupted** by @${username}`, threadId);
1318
- }
1319
- }
1320
- /** Change working directory for a session (restarts Claude CLI) */
1321
- async changeDirectory(threadId, newDir, username) {
1322
- const session = this.sessions.get(threadId);
1323
- if (!session)
1324
- return;
1325
- // Only session owner or globally allowed users can change directory
1326
- if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
1327
- await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can change the working directory`, threadId);
1328
- return;
1329
- }
1330
- // Expand ~ to home directory
1331
- const expandedDir = newDir.startsWith('~')
1332
- ? newDir.replace('~', process.env.HOME || '')
1333
- : newDir;
1334
- // Resolve to absolute path
1335
- const { resolve } = await import('path');
1336
- const absoluteDir = resolve(expandedDir);
1337
- // Check if directory exists
1338
- const { existsSync, statSync } = await import('fs');
1339
- if (!existsSync(absoluteDir)) {
1340
- await this.mattermost.createPost(`❌ Directory does not exist: \`${newDir}\``, threadId);
1341
- return;
1342
- }
1343
- if (!statSync(absoluteDir).isDirectory()) {
1344
- await this.mattermost.createPost(`❌ Not a directory: \`${newDir}\``, threadId);
1345
- return;
1346
- }
1347
- const shortId = threadId.substring(0, 8);
1348
- const shortDir = absoluteDir.replace(process.env.HOME || '', '~');
1349
- console.log(` 📂 Session (${shortId}…) changing directory to ${shortDir}`);
1350
- // Stop the current Claude CLI
1351
- this.stopTyping(session);
1352
- session.isRestarting = true; // Suppress exit message during restart
1353
- session.claude.kill();
1354
- // Flush any pending content
1355
- await this.flush(session);
1356
- session.currentPostId = null;
1357
- session.pendingContent = '';
1358
- // Update session working directory
1359
- session.workingDir = absoluteDir;
1360
- // Generate new session ID for fresh start in new directory
1361
- // (Claude CLI sessions are tied to working directory, can't resume across directories)
1362
- const newSessionId = randomUUID();
1363
- session.claudeSessionId = newSessionId;
1364
- const cliOptions = {
1365
- workingDir: absoluteDir,
1366
- threadId: threadId,
1367
- skipPermissions: this.skipPermissions || !session.forceInteractivePermissions,
1368
- sessionId: newSessionId,
1369
- resume: false, // Fresh start - can't resume across directories
1370
- chrome: this.chromeEnabled,
1371
- };
1372
- session.claude = new ClaudeCli(cliOptions);
1373
- // Rebind event handlers
1374
- session.claude.on('event', (e) => this.handleEvent(threadId, e));
1375
- session.claude.on('exit', (code) => this.handleExit(threadId, code));
1376
- // Start the new Claude CLI
1377
- try {
1378
- session.claude.start();
1379
- // Note: isRestarting is reset in handleExit when the old process exit event fires
1380
- }
1381
- catch (err) {
1382
- session.isRestarting = false; // Reset flag on failure since exit won't fire
1383
- console.error(' ❌ Failed to restart Claude:', err);
1384
- await this.mattermost.createPost(`❌ Failed to restart Claude: ${err}`, threadId);
1385
- return;
1386
- }
1387
- // Update session header with new directory
1388
- await this.updateSessionHeader(session);
1389
- // Post confirmation
1390
- await this.mattermost.createPost(`📂 **Working directory changed** to \`${shortDir}\`\n*Claude Code restarted in new directory*`, threadId);
1391
- // Update activity
1392
- session.lastActivityAt = new Date();
1393
- session.timeoutWarningPosted = false;
1394
- // Persist the updated session state
1395
- this.persistSession(session);
1396
- }
1397
- /** Invite a user to participate in a specific session */
1398
- async inviteUser(threadId, invitedUser, invitedBy) {
1399
- const session = this.sessions.get(threadId);
1400
- if (!session)
1401
- return;
1402
- // Only session owner or globally allowed users can invite
1403
- if (session.startedBy !== invitedBy && !this.mattermost.isUserAllowed(invitedBy)) {
1404
- await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can invite others`, threadId);
1405
- return;
1406
- }
1407
- session.sessionAllowedUsers.add(invitedUser);
1408
- await this.mattermost.createPost(`✅ @${invitedUser} can now participate in this session (invited by @${invitedBy})`, threadId);
1409
- console.log(` 👋 @${invitedUser} invited to session by @${invitedBy}`);
1410
- await this.updateSessionHeader(session);
1411
- this.persistSession(session); // Persist collaboration change
1412
- }
1413
- /** Kick a user from a specific session */
1414
- async kickUser(threadId, kickedUser, kickedBy) {
1415
- const session = this.sessions.get(threadId);
1416
- if (!session)
1417
- return;
1418
- // Only session owner or globally allowed users can kick
1419
- if (session.startedBy !== kickedBy && !this.mattermost.isUserAllowed(kickedBy)) {
1420
- await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can kick others`, threadId);
1421
- return;
1422
- }
1423
- // Can't kick session owner
1424
- if (kickedUser === session.startedBy) {
1425
- await this.mattermost.createPost(`⚠️ Cannot kick session owner @${session.startedBy}`, threadId);
1426
- return;
1427
- }
1428
- // Can't kick globally allowed users (they'll still have access)
1429
- if (this.mattermost.isUserAllowed(kickedUser)) {
1430
- await this.mattermost.createPost(`⚠️ @${kickedUser} is globally allowed and cannot be kicked from individual sessions`, threadId);
1431
- return;
1432
- }
1433
- if (session.sessionAllowedUsers.delete(kickedUser)) {
1434
- await this.mattermost.createPost(`🚫 @${kickedUser} removed from this session by @${kickedBy}`, threadId);
1435
- console.log(` 🚫 @${kickedUser} kicked from session by @${kickedBy}`);
1436
- await this.updateSessionHeader(session);
1437
- this.persistSession(session); // Persist collaboration change
1438
- }
1439
- else {
1440
- await this.mattermost.createPost(`⚠️ @${kickedUser} was not in this session`, threadId);
1441
- }
1442
- }
1443
- /**
1444
- * Enable interactive permissions for a session.
1445
- * Can only downgrade (skip → interactive), not upgrade.
1446
- */
1447
- async enableInteractivePermissions(threadId, username) {
1448
- const session = this.sessions.get(threadId);
1449
- if (!session)
1450
- return;
1451
- // Only session owner or globally allowed users can change permissions
1452
- if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
1453
- await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can change permissions`, threadId);
1454
- return;
1455
- }
1456
- // Can only downgrade, not upgrade
1457
- if (!this.skipPermissions) {
1458
- await this.mattermost.createPost(`ℹ️ Permissions are already interactive for this session`, threadId);
1459
- return;
1460
- }
1461
- // Already enabled for this session
1462
- if (session.forceInteractivePermissions) {
1463
- await this.mattermost.createPost(`ℹ️ Interactive permissions already enabled for this session`, threadId);
1464
- return;
1465
- }
1466
- // Set the flag
1467
- session.forceInteractivePermissions = true;
1468
- const shortId = threadId.substring(0, 8);
1469
- console.log(` 🔐 Session (${shortId}…) enabling interactive permissions`);
1470
- // Stop the current Claude CLI and restart with new permission setting
1471
- this.stopTyping(session);
1472
- session.isRestarting = true; // Suppress exit message during restart
1473
- session.claude.kill();
1474
- // Flush any pending content
1475
- await this.flush(session);
1476
- session.currentPostId = null;
1477
- session.pendingContent = '';
1478
- // Create new CLI options with interactive permissions (skipPermissions: false)
1479
- const cliOptions = {
1480
- workingDir: session.workingDir,
1481
- threadId: threadId,
1482
- skipPermissions: false, // Force interactive permissions
1483
- sessionId: session.claudeSessionId,
1484
- resume: true, // Resume to keep conversation context
1485
- chrome: this.chromeEnabled,
1486
- };
1487
- session.claude = new ClaudeCli(cliOptions);
1488
- // Rebind event handlers
1489
- session.claude.on('event', (e) => this.handleEvent(threadId, e));
1490
- session.claude.on('exit', (code) => this.handleExit(threadId, code));
1491
- // Start the new Claude CLI
1492
- try {
1493
- session.claude.start();
1494
- // Note: isRestarting is reset in handleExit when the old process exit event fires
1495
- }
1496
- catch (err) {
1497
- session.isRestarting = false; // Reset flag on failure since exit won't fire
1498
- console.error(' ❌ Failed to restart Claude:', err);
1499
- await this.mattermost.createPost(`❌ Failed to enable interactive permissions: ${err}`, threadId);
1500
- return;
1501
- }
1502
- // Update session header with new permission status
1503
- await this.updateSessionHeader(session);
1504
- // Post confirmation
1505
- await this.mattermost.createPost(`🔐 **Interactive permissions enabled** for this session by @${username}\n*Claude Code restarted with permission prompts*`, threadId);
1506
- console.log(` 🔐 Interactive permissions enabled for session by @${username}`);
1507
- // Update activity and persist
1508
- session.lastActivityAt = new Date();
1509
- session.timeoutWarningPosted = false;
1510
- this.persistSession(session);
1511
- }
1512
- /** Check if a session should use interactive permissions */
1513
- isSessionInteractive(threadId) {
1514
- const session = this.sessions.get(threadId);
1515
- if (!session)
1516
- return !this.skipPermissions;
1517
- // If global is interactive, always interactive
1518
- if (!this.skipPermissions)
1519
- return true;
1520
- // If session has forced interactive, use that
1521
- return session.forceInteractivePermissions;
1522
- }
1523
- /** Update the session header post with current participants */
1524
- async updateSessionHeader(session) {
1525
- if (!session.sessionStartPostId)
1526
- return;
1527
- // Use session's working directory (can be changed via !cd)
1528
- const shortDir = session.workingDir.replace(process.env.HOME || '', '~');
1529
- // Check session-level permission override
1530
- const isInteractive = !this.skipPermissions || session.forceInteractivePermissions;
1531
- const permMode = isInteractive ? '🔐 Interactive' : '⚡ Auto';
1532
- // Build participants list (excluding owner who is shown in "Started by")
1533
- const otherParticipants = [...session.sessionAllowedUsers]
1534
- .filter(u => u !== session.startedBy)
1535
- .map(u => `@${u}`)
1536
- .join(', ');
1537
- const rows = [
1538
- `| 📂 **Directory** | \`${shortDir}\` |`,
1539
- `| 👤 **Started by** | @${session.startedBy} |`,
1540
- ];
1541
- // Show worktree info if active
1542
- if (session.worktreeInfo) {
1543
- const shortRepoRoot = session.worktreeInfo.repoRoot.replace(process.env.HOME || '', '~');
1544
- rows.push(`| 🌿 **Worktree** | \`${session.worktreeInfo.branch}\` (from \`${shortRepoRoot}\`) |`);
1545
- }
1546
- if (otherParticipants) {
1547
- rows.push(`| 👥 **Participants** | ${otherParticipants} |`);
1548
- }
1549
- rows.push(`| 🔢 **Session** | #${session.sessionNumber} of ${MAX_SESSIONS} max |`);
1550
- rows.push(`| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`);
1551
- if (this.chromeEnabled) {
1552
- rows.push(`| 🌐 **Chrome** | Enabled |`);
1553
- }
1554
- // Check for available updates
1555
- const updateInfo = getUpdateInfo();
1556
- const updateNotice = updateInfo
1557
- ? `\n> ⚠️ **Update available:** v${updateInfo.current} → v${updateInfo.latest} - Run \`npm install -g claude-threads\`\n`
1558
- : '';
1559
- // Get "What's new" from release notes
1560
- const releaseNotes = getReleaseNotes(pkg.version);
1561
- const whatsNew = releaseNotes ? getWhatsNewSummary(releaseNotes) : '';
1562
- const whatsNewLine = whatsNew ? `\n> ✨ **What's new:** ${whatsNew}\n` : '';
1563
- const msg = [
1564
- getMattermostLogo(pkg.version),
1565
- updateNotice,
1566
- whatsNewLine,
1567
- `| | |`,
1568
- `|:--|:--|`,
1569
- ...rows,
1570
- ].join('\n');
1571
- try {
1572
- await this.mattermost.updatePost(session.sessionStartPostId, msg);
1573
- }
1574
- catch (err) {
1575
- console.error(' ⚠️ Failed to update session header:', err);
1576
- }
1577
- }
1578
- /** Request approval for a message from an unauthorized user */
1579
- async requestMessageApproval(threadId, username, message) {
1580
- const session = this.sessions.get(threadId);
1581
- if (!session)
1582
- return;
1583
- // If there's already a pending message approval, ignore
1584
- if (session.pendingMessageApproval) {
1585
- return;
1586
- }
1587
- const preview = message.length > 100 ? message.substring(0, 100) + '…' : message;
1588
- const post = await this.mattermost.createInteractivePost(`🔒 **@${username}** wants to send a message:\n> ${preview}\n\n` +
1589
- `React 👍 to allow this message, ✅ to invite them to the session, 👎 to deny`, [APPROVAL_EMOJIS[0], ALLOW_ALL_EMOJIS[0], DENIAL_EMOJIS[0]], threadId);
1590
- session.pendingMessageApproval = {
1591
- postId: post.id,
1592
- originalMessage: message,
1593
- fromUser: username,
1594
- };
1595
- this.registerPost(post.id, threadId);
1596
- }
1597
- // ---------------------------------------------------------------------------
1598
- // Worktree Management
1599
- // ---------------------------------------------------------------------------
1600
- /**
1601
- * Handle a worktree branch response from user.
1602
- * Called when user replies with a branch name to the worktree prompt.
1603
- */
1604
- async handleWorktreeBranchResponse(threadId, branchName, username) {
1605
- const session = this.sessions.get(threadId);
1606
- if (!session || !session.pendingWorktreePrompt)
1607
- return false;
1608
- // Only session owner can respond
1609
- if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
1610
- return false;
1611
- }
1612
- // Validate branch name
1613
- if (!isValidBranchName(branchName)) {
1614
- await this.mattermost.createPost(`❌ Invalid branch name: \`${branchName}\`. Please provide a valid git branch name.`, threadId);
1615
- return true; // We handled it, but need another response
1616
- }
1617
- // Create and switch to worktree
1618
- await this.createAndSwitchToWorktree(threadId, branchName, username);
1619
- return true;
1620
- }
1621
- /**
1622
- * Handle ❌ reaction on worktree prompt - skip worktree and continue in main repo.
1623
- */
1624
- async handleWorktreeSkip(threadId, username) {
1625
- const session = this.sessions.get(threadId);
1626
- if (!session || !session.pendingWorktreePrompt)
1627
- return;
1628
- // Only session owner can skip
1629
- if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
1630
- return;
1631
- }
1632
- // Update the prompt post
1633
- if (session.worktreePromptPostId) {
1634
- try {
1635
- await this.mattermost.updatePost(session.worktreePromptPostId, `✅ Continuing in main repo (skipped by @${username})`);
1636
- }
1637
- catch (err) {
1638
- console.error(' ⚠️ Failed to update worktree prompt:', err);
1639
- }
1640
- }
1641
- // Clear pending state
1642
- session.pendingWorktreePrompt = false;
1643
- session.worktreePromptPostId = undefined;
1644
- const queuedPrompt = session.queuedPrompt;
1645
- session.queuedPrompt = undefined;
1646
- // Persist updated state
1647
- this.persistSession(session);
1648
- // Now send the queued message to Claude
1649
- if (queuedPrompt && session.claude.isRunning()) {
1650
- session.claude.sendMessage(queuedPrompt);
1651
- this.startTyping(session);
1652
- }
1653
- }
1654
- /**
1655
- * Create a new worktree and switch the session to it.
1656
- */
1657
- async createAndSwitchToWorktree(threadId, branch, username) {
1658
- const session = this.sessions.get(threadId);
1659
- if (!session)
1660
- return;
1661
- // Only session owner or admins can manage worktrees
1662
- if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
1663
- await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
1664
- return;
1665
- }
1666
- // Check if we're in a git repo
1667
- const isRepo = await isGitRepository(session.workingDir);
1668
- if (!isRepo) {
1669
- await this.mattermost.createPost(`❌ Current directory is not a git repository`, threadId);
1670
- return;
1671
- }
1672
- // Get repo root
1673
- const repoRoot = await getRepositoryRoot(session.workingDir);
1674
- // Check if worktree already exists for this branch
1675
- const existing = await findWorktreeByBranch(repoRoot, branch);
1676
- if (existing && !existing.isMain) {
1677
- await this.mattermost.createPost(`⚠️ Worktree for branch \`${branch}\` already exists at \`${existing.path}\`. Use \`!worktree switch ${branch}\` to switch to it.`, threadId);
1678
- return;
1679
- }
1680
- const shortId = threadId.substring(0, 8);
1681
- console.log(` 🌿 Session (${shortId}…) creating worktree for branch ${branch}`);
1682
- // Generate worktree path
1683
- const worktreePath = getWorktreeDir(repoRoot, branch);
1684
- try {
1685
- // Create the worktree
1686
- await createWorktree(repoRoot, branch, worktreePath);
1687
- // Update the prompt post if it exists
1688
- if (session.worktreePromptPostId) {
1689
- try {
1690
- await this.mattermost.updatePost(session.worktreePromptPostId, `✅ Created worktree for \`${branch}\``);
1691
- }
1692
- catch (err) {
1693
- console.error(' ⚠️ Failed to update worktree prompt:', err);
1694
- }
1695
- }
1696
- // Clear pending state
1697
- const wasPending = session.pendingWorktreePrompt;
1698
- session.pendingWorktreePrompt = false;
1699
- session.worktreePromptPostId = undefined;
1700
- const queuedPrompt = session.queuedPrompt;
1701
- session.queuedPrompt = undefined;
1702
- // Store worktree info
1703
- session.worktreeInfo = {
1704
- repoRoot,
1705
- worktreePath,
1706
- branch,
1707
- };
1708
- // Update working directory
1709
- session.workingDir = worktreePath;
1710
- // If Claude is already running, restart it in the new directory
1711
- if (session.claude.isRunning()) {
1712
- this.stopTyping(session);
1713
- session.isRestarting = true;
1714
- session.claude.kill();
1715
- // Flush any pending content
1716
- await this.flush(session);
1717
- session.currentPostId = null;
1718
- session.pendingContent = '';
1719
- // Generate new session ID for fresh start in new directory
1720
- // (Claude CLI sessions are tied to working directory, can't resume across directories)
1721
- const newSessionId = randomUUID();
1722
- session.claudeSessionId = newSessionId;
1723
- // Create new CLI with new working directory
1724
- const cliOptions = {
1725
- workingDir: worktreePath,
1726
- threadId: threadId,
1727
- skipPermissions: this.skipPermissions || !session.forceInteractivePermissions,
1728
- sessionId: newSessionId,
1729
- resume: false, // Fresh start - can't resume across directories
1730
- chrome: this.chromeEnabled,
1731
- };
1732
- session.claude = new ClaudeCli(cliOptions);
1733
- // Rebind event handlers
1734
- session.claude.on('event', (e) => this.handleEvent(threadId, e));
1735
- session.claude.on('exit', (code) => this.handleExit(threadId, code));
1736
- // Start the new CLI
1737
- session.claude.start();
1738
- }
1739
- // Update session header
1740
- await this.updateSessionHeader(session);
1741
- // Post confirmation
1742
- const shortWorktreePath = worktreePath.replace(process.env.HOME || '', '~');
1743
- await this.mattermost.createPost(`✅ **Created worktree** for branch \`${branch}\`\n📁 Working directory: \`${shortWorktreePath}\`\n*Claude Code restarted in the new worktree*`, threadId);
1744
- // Update activity and persist
1745
- session.lastActivityAt = new Date();
1746
- session.timeoutWarningPosted = false;
1747
- this.persistSession(session);
1748
- // If there was a queued prompt (from initial session start), send it now
1749
- if (wasPending && queuedPrompt && session.claude.isRunning()) {
1750
- session.claude.sendMessage(queuedPrompt);
1751
- this.startTyping(session);
1752
- }
1753
- console.log(` 🌿 Session (${shortId}…) switched to worktree ${branch} at ${shortWorktreePath}`);
1754
- }
1755
- catch (err) {
1756
- console.error(` ❌ Failed to create worktree:`, err);
1757
- await this.mattermost.createPost(`❌ Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`, threadId);
1758
- }
1759
- }
1760
- /**
1761
- * Switch to an existing worktree.
1762
- */
1763
- async switchToWorktree(threadId, branchOrPath, username) {
1764
- const session = this.sessions.get(threadId);
1765
- if (!session)
1766
- return;
1767
- // Only session owner or admins can manage worktrees
1768
- if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
1769
- await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
1770
- return;
1771
- }
1772
- // Get current repo root
1773
- const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
1774
- // Find the worktree
1775
- const worktrees = await listWorktrees(repoRoot);
1776
- const target = worktrees.find(wt => wt.branch === branchOrPath ||
1777
- wt.path === branchOrPath ||
1778
- wt.path.endsWith(branchOrPath));
1779
- if (!target) {
1780
- await this.mattermost.createPost(`❌ Worktree not found: \`${branchOrPath}\`. Use \`!worktree list\` to see available worktrees.`, threadId);
1781
- return;
1782
- }
1783
- // Use changeDirectory logic to switch
1784
- await this.changeDirectory(threadId, target.path, username);
1785
- // Update worktree info
1786
- session.worktreeInfo = {
1787
- repoRoot,
1788
- worktreePath: target.path,
1789
- branch: target.branch,
1790
- };
1791
- // Update session header
1792
- await this.updateSessionHeader(session);
1793
- this.persistSession(session);
1794
- }
1795
- /**
1796
- * List all worktrees for the current repository.
1797
- */
1798
- async listWorktreesCommand(threadId, _username) {
1799
- const session = this.sessions.get(threadId);
1800
- if (!session)
1801
- return;
1802
- // Check if we're in a git repo
1803
- const isRepo = await isGitRepository(session.workingDir);
1804
- if (!isRepo) {
1805
- await this.mattermost.createPost(`❌ Current directory is not a git repository`, threadId);
1806
- return;
1807
- }
1808
- // Get repo root (either from worktree info or current dir)
1809
- const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
1810
- const worktrees = await listWorktrees(repoRoot);
1811
- if (worktrees.length === 0) {
1812
- await this.mattermost.createPost(`📋 No worktrees found for this repository`, threadId);
1813
- return;
1814
- }
1815
- const shortRepoRoot = repoRoot.replace(process.env.HOME || '', '~');
1816
- let message = `📋 **Worktrees for** \`${shortRepoRoot}\`:\n\n`;
1817
- for (const wt of worktrees) {
1818
- const shortPath = wt.path.replace(process.env.HOME || '', '~');
1819
- const isCurrent = session.workingDir === wt.path;
1820
- const marker = isCurrent ? ' ← current' : '';
1821
- const label = wt.isMain ? '(main repository)' : '';
1822
- message += `• \`${wt.branch}\` → \`${shortPath}\` ${label}${marker}\n`;
1823
- }
1824
- await this.mattermost.createPost(message, threadId);
1825
- }
1826
- /**
1827
- * Remove a worktree.
1828
- */
1829
- async removeWorktreeCommand(threadId, branchOrPath, username) {
1830
- const session = this.sessions.get(threadId);
1831
- if (!session)
1832
- return;
1833
- // Only session owner or admins can manage worktrees
1834
- if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
1835
- await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
1836
- return;
1837
- }
1838
- // Get current repo root
1839
- const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
1840
- // Find the worktree
1841
- const worktrees = await listWorktrees(repoRoot);
1842
- const target = worktrees.find(wt => wt.branch === branchOrPath ||
1843
- wt.path === branchOrPath ||
1844
- wt.path.endsWith(branchOrPath));
1845
- if (!target) {
1846
- await this.mattermost.createPost(`❌ Worktree not found: \`${branchOrPath}\`. Use \`!worktree list\` to see available worktrees.`, threadId);
1847
- return;
1848
- }
1849
- // Can't remove the main repository
1850
- if (target.isMain) {
1851
- await this.mattermost.createPost(`❌ Cannot remove the main repository. Use \`!worktree remove\` only for worktrees.`, threadId);
1852
- return;
1853
- }
1854
- // Can't remove the current working directory
1855
- if (session.workingDir === target.path) {
1856
- await this.mattermost.createPost(`❌ Cannot remove the current working directory. Switch to another worktree first.`, threadId);
1857
- return;
1858
- }
1859
- try {
1860
- await removeGitWorktree(repoRoot, target.path);
1861
- const shortPath = target.path.replace(process.env.HOME || '', '~');
1862
- await this.mattermost.createPost(`✅ Removed worktree \`${target.branch}\` at \`${shortPath}\``, threadId);
1863
- console.log(` 🗑️ Removed worktree ${target.branch} at ${shortPath}`);
1864
- }
1865
- catch (err) {
1866
- console.error(` ❌ Failed to remove worktree:`, err);
1867
- await this.mattermost.createPost(`❌ Failed to remove worktree: ${err instanceof Error ? err.message : String(err)}`, threadId);
1868
- }
1869
- }
1870
- /**
1871
- * Disable worktree prompts for a session.
1872
- */
1873
- async disableWorktreePrompt(threadId, username) {
1874
- const session = this.sessions.get(threadId);
1875
- if (!session)
1876
- return;
1877
- // Only session owner or admins can manage worktrees
1878
- if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
1879
- await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
1880
- return;
1881
- }
1882
- session.worktreePromptDisabled = true;
1883
- this.persistSession(session);
1884
- await this.mattermost.createPost(`✅ Worktree prompts disabled for this session`, threadId);
1885
- }
1886
- /**
1887
- * Check if a session has a pending worktree prompt.
1888
- */
1889
- hasPendingWorktreePrompt(threadId) {
1890
- const session = this.sessions.get(threadId);
1891
- return session?.pendingWorktreePrompt === true;
1892
- }
1893
- /**
1894
- * Get the worktree prompt post ID for a session.
1895
- */
1896
- getWorktreePromptPostId(threadId) {
1897
- const session = this.sessions.get(threadId);
1898
- return session?.worktreePromptPostId;
1899
- }
1900
- /** Kill all active sessions (for graceful shutdown) */
1901
- killAllSessions() {
1902
- console.log(` [shutdown] killAllSessions called, isShuttingDown already=${this.isShuttingDown}`);
1903
- // Set shutdown flag to suppress exit messages (should already be true from setShuttingDown)
1904
- this.isShuttingDown = true;
1905
- const count = this.sessions.size;
1906
- console.log(` [shutdown] About to kill ${count} session(s) (preserving persistence for resume)`);
1907
- // Kill each session WITHOUT unpersisting - we want them to resume after restart
1908
- for (const [threadId] of this.sessions.entries()) {
1909
- this.killSession(threadId, false); // false = don't unpersist
1910
- }
1911
- // Maps should already be cleared by killSession, but clear again to be safe
1912
- this.sessions.clear();
1913
- this.postIndex.clear();
1914
- if (this.cleanupTimer) {
1915
- clearInterval(this.cleanupTimer);
1916
- this.cleanupTimer = null;
1917
- }
1918
- if (count > 0) {
1919
- console.log(` ✖ Killed ${count} session${count === 1 ? '' : 's'} (sessions preserved for resume)`);
1920
- }
1921
- }
1922
- /** Kill all sessions AND unpersist them (for emergency shutdown - no resume) */
1923
- killAllSessionsAndUnpersist() {
1924
- this.isShuttingDown = true;
1925
- const count = this.sessions.size;
1926
- // Kill each session WITH unpersisting - emergency shutdown, no resume
1927
- for (const [threadId] of this.sessions.entries()) {
1928
- this.killSession(threadId, true); // true = unpersist
1929
- }
1930
- this.sessions.clear();
1931
- this.postIndex.clear();
1932
- if (this.cleanupTimer) {
1933
- clearInterval(this.cleanupTimer);
1934
- this.cleanupTimer = null;
1935
- }
1936
- if (count > 0) {
1937
- console.log(` 🔴 Emergency killed ${count} session${count === 1 ? '' : 's'} (sessions NOT preserved)`);
1938
- }
1939
- }
1940
- /** Cleanup idle sessions that have exceeded timeout */
1941
- cleanupIdleSessions() {
1942
- const now = Date.now();
1943
- const warningThreshold = SESSION_TIMEOUT_MS - SESSION_WARNING_MS;
1944
- for (const [threadId, session] of this.sessions.entries()) {
1945
- const idleTime = now - session.lastActivityAt.getTime();
1946
- // Check if we should time out
1947
- if (idleTime > SESSION_TIMEOUT_MS) {
1948
- const mins = Math.round(idleTime / 60000);
1949
- const shortId = threadId.substring(0, 8);
1950
- console.log(` ⏰ Session (${shortId}…) timed out after ${mins}m idle`);
1951
- this.mattermost.createPost(`⏰ **Session timed out** — no activity for ${mins} minutes`, session.threadId).catch(() => { });
1952
- this.killSession(threadId);
1953
- }
1954
- // Check if we should show warning (only once)
1955
- else if (idleTime > warningThreshold && !session.timeoutWarningPosted) {
1956
- const remainingMins = Math.round((SESSION_TIMEOUT_MS - idleTime) / 60000);
1957
- const shortId = threadId.substring(0, 8);
1958
- console.log(` ⚠️ Session (${shortId}…) warning: ${remainingMins}m until timeout`);
1959
- this.mattermost.createPost(`⚠️ **Session idle** — will time out in ~${remainingMins} minutes. Send a message to keep it alive.`, session.threadId).catch(() => { });
1960
- session.timeoutWarningPosted = true;
1961
- }
1962
- }
1963
- }
1964
- }