mattermost-claude-code 0.2.3 → 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.
@@ -1,19 +1,63 @@
1
+ import { ClaudeCli } from './cli.js';
1
2
  import { MattermostClient } from '../mattermost/client.js';
3
+ interface QuestionOption {
4
+ label: string;
5
+ description: string;
6
+ }
7
+ interface PendingQuestionSet {
8
+ toolUseId: string;
9
+ currentIndex: number;
10
+ currentPostId: string | null;
11
+ questions: Array<{
12
+ header: string;
13
+ question: string;
14
+ options: QuestionOption[];
15
+ answer: string | null;
16
+ }>;
17
+ }
18
+ interface PendingApproval {
19
+ postId: string;
20
+ type: 'plan' | 'action';
21
+ }
22
+ /**
23
+ * Represents a single Claude Code session tied to a Mattermost thread.
24
+ * Each session has its own Claude CLI process and state.
25
+ */
26
+ interface Session {
27
+ threadId: string;
28
+ startedBy: string;
29
+ startedAt: Date;
30
+ lastActivityAt: Date;
31
+ claude: ClaudeCli;
32
+ currentPostId: string | null;
33
+ pendingContent: string;
34
+ pendingApproval: PendingApproval | null;
35
+ pendingQuestionSet: PendingQuestionSet | null;
36
+ planApproved: boolean;
37
+ tasksPostId: string | null;
38
+ activeSubagents: Map<string, string>;
39
+ updateTimer: ReturnType<typeof setTimeout> | null;
40
+ typingTimer: ReturnType<typeof setInterval> | null;
41
+ }
2
42
  export declare class SessionManager {
3
- private claude;
4
43
  private mattermost;
5
44
  private workingDir;
6
45
  private skipPermissions;
7
- private session;
8
- private updateTimer;
9
- private typingTimer;
10
- private pendingQuestionSet;
11
- private pendingApproval;
12
- private planApproved;
13
- private tasksPostId;
14
- private activeSubagents;
15
46
  private debug;
47
+ private sessions;
48
+ private postIndex;
49
+ private cleanupTimer;
16
50
  constructor(mattermost: MattermostClient, workingDir: string, skipPermissions?: boolean);
51
+ /** Get a session by thread ID */
52
+ getSession(threadId: string): Session | undefined;
53
+ /** Check if a session exists for this thread */
54
+ hasSession(threadId: string): boolean;
55
+ /** Get the number of active sessions */
56
+ getSessionCount(): number;
57
+ /** Register a post for reaction routing */
58
+ private registerPost;
59
+ /** Find session by post ID (for reaction routing) */
60
+ private getSessionByPost;
17
61
  startSession(options: {
18
62
  prompt: string;
19
63
  }, username: string, replyToPostId?: string): Promise<void>;
@@ -25,6 +69,7 @@ export declare class SessionManager {
25
69
  private handleAskUserQuestion;
26
70
  private postCurrentQuestion;
27
71
  private handleReaction;
72
+ private handleQuestionReaction;
28
73
  private handleApprovalReaction;
29
74
  private formatEvent;
30
75
  private formatToolUse;
@@ -34,8 +79,17 @@ export declare class SessionManager {
34
79
  private stopTyping;
35
80
  private flush;
36
81
  private handleExit;
82
+ /** Check if any sessions are active */
37
83
  isSessionActive(): boolean;
38
- isInCurrentSessionThread(threadRoot: string): boolean;
39
- sendFollowUp(message: string): Promise<void>;
40
- killSession(): void;
84
+ /** Check if a session exists for this thread */
85
+ isInSessionThread(threadRoot: string): boolean;
86
+ /** Send a follow-up message to an existing session */
87
+ sendFollowUp(threadId: string, message: string): Promise<void>;
88
+ /** Kill a specific session */
89
+ killSession(threadId: string): void;
90
+ /** Kill all active sessions (for graceful shutdown) */
91
+ killAllSessions(): void;
92
+ /** Cleanup idle sessions that have exceeded timeout */
93
+ private cleanupIdleSessions;
41
94
  }
95
+ export {};
@@ -6,20 +6,25 @@ const EMOJI_TO_INDEX = {
6
6
  'three': 2, '3️⃣': 2,
7
7
  'four': 3, '4️⃣': 3,
8
8
  };
9
+ // =============================================================================
10
+ // Configuration
11
+ // =============================================================================
12
+ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '5', 10);
13
+ const SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS || '1800000', 10); // 30 min
14
+ // =============================================================================
15
+ // SessionManager - Manages multiple concurrent Claude Code sessions
16
+ // =============================================================================
9
17
  export class SessionManager {
10
- claude = null;
18
+ // Shared state
11
19
  mattermost;
12
20
  workingDir;
13
21
  skipPermissions;
14
- session = null;
15
- updateTimer = null;
16
- typingTimer = null;
17
- pendingQuestionSet = null;
18
- pendingApproval = null;
19
- planApproved = false; // Track if we already approved this session
20
- tasksPostId = null; // Track the tasks display post
21
- activeSubagents = new Map(); // taskId -> postId for subagent status
22
22
  debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
23
+ // Multi-session storage
24
+ sessions = new Map(); // threadId -> Session
25
+ postIndex = new Map(); // postId -> threadId (for reaction routing)
26
+ // Cleanup timer
27
+ cleanupTimer = null;
23
28
  constructor(mattermost, workingDir, skipPermissions = false) {
24
29
  this.mattermost = mattermost;
25
30
  this.workingDir = workingDir;
@@ -28,41 +33,103 @@ export class SessionManager {
28
33
  this.mattermost.on('reaction', (reaction, user) => {
29
34
  this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
30
35
  });
36
+ // Start periodic cleanup of idle sessions
37
+ this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
31
38
  }
39
+ // ---------------------------------------------------------------------------
40
+ // Session Lookup Methods
41
+ // ---------------------------------------------------------------------------
42
+ /** Get a session by thread ID */
43
+ getSession(threadId) {
44
+ return this.sessions.get(threadId);
45
+ }
46
+ /** Check if a session exists for this thread */
47
+ hasSession(threadId) {
48
+ return this.sessions.has(threadId);
49
+ }
50
+ /** Get the number of active sessions */
51
+ getSessionCount() {
52
+ return this.sessions.size;
53
+ }
54
+ /** Register a post for reaction routing */
55
+ registerPost(postId, threadId) {
56
+ this.postIndex.set(postId, threadId);
57
+ }
58
+ /** Find session by post ID (for reaction routing) */
59
+ getSessionByPost(postId) {
60
+ const threadId = this.postIndex.get(postId);
61
+ return threadId ? this.sessions.get(threadId) : undefined;
62
+ }
63
+ // ---------------------------------------------------------------------------
64
+ // Session Lifecycle
65
+ // ---------------------------------------------------------------------------
32
66
  async startSession(options, username, replyToPostId) {
33
- // Start Claude if not running
34
- if (!this.claude?.isRunning()) {
35
- const msg = `🚀 **Session started**\n> Working directory: \`${this.workingDir}\``;
36
- const post = await this.mattermost.createPost(msg, replyToPostId);
37
- const threadId = replyToPostId || post.id;
38
- this.session = { threadId, postId: null, content: '' };
39
- this.planApproved = false; // Reset for new session
40
- this.tasksPostId = null; // Reset tasks display
41
- this.activeSubagents.clear(); // Clear subagent tracking
42
- // Create Claude CLI with options (including threadId for permissions)
43
- const cliOptions = {
44
- workingDir: this.workingDir,
45
- threadId: threadId,
46
- skipPermissions: this.skipPermissions,
47
- };
48
- this.claude = new ClaudeCli(cliOptions);
49
- this.claude.on('event', (e) => this.handleEvent(e));
50
- this.claude.on('exit', (code) => this.handleExit(code));
51
- try {
52
- this.claude.start();
53
- }
54
- catch (err) {
55
- console.error('[Session] Start error:', err);
56
- await this.mattermost.createPost(`❌ ${err}`, threadId);
57
- this.session = null;
58
- return;
59
- }
67
+ const threadId = replyToPostId || '';
68
+ // Check if session already exists for this thread
69
+ const existingSession = this.sessions.get(threadId);
70
+ if (existingSession && existingSession.claude.isRunning()) {
71
+ // Send as follow-up instead
72
+ await this.sendFollowUp(threadId, options.prompt);
73
+ return;
74
+ }
75
+ // Check max sessions limit
76
+ if (this.sessions.size >= MAX_SESSIONS) {
77
+ await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
78
+ return;
79
+ }
80
+ // Post session start message
81
+ const msg = `🚀 **Session started**\n> Working directory: \`${this.workingDir}\``;
82
+ const post = await this.mattermost.createPost(msg, replyToPostId);
83
+ const actualThreadId = replyToPostId || post.id;
84
+ // Create Claude CLI with options
85
+ const cliOptions = {
86
+ workingDir: this.workingDir,
87
+ threadId: actualThreadId,
88
+ skipPermissions: this.skipPermissions,
89
+ };
90
+ const claude = new ClaudeCli(cliOptions);
91
+ // Create the session object
92
+ const session = {
93
+ threadId: actualThreadId,
94
+ startedBy: username,
95
+ startedAt: new Date(),
96
+ lastActivityAt: new Date(),
97
+ claude,
98
+ currentPostId: null,
99
+ pendingContent: '',
100
+ pendingApproval: null,
101
+ pendingQuestionSet: null,
102
+ planApproved: false,
103
+ tasksPostId: null,
104
+ activeSubagents: new Map(),
105
+ updateTimer: null,
106
+ typingTimer: null,
107
+ };
108
+ // Register session
109
+ this.sessions.set(actualThreadId, session);
110
+ console.log(`[Sessions] Started session for thread ${actualThreadId} by ${username} (active: ${this.sessions.size})`);
111
+ // Bind event handlers with closure over threadId
112
+ claude.on('event', (e) => this.handleEvent(actualThreadId, e));
113
+ claude.on('exit', (code) => this.handleExit(actualThreadId, code));
114
+ try {
115
+ claude.start();
116
+ }
117
+ catch (err) {
118
+ console.error('[Session] Start error:', err);
119
+ await this.mattermost.createPost(`❌ ${err}`, actualThreadId);
120
+ this.sessions.delete(actualThreadId);
121
+ return;
60
122
  }
61
123
  // Send the message and start typing indicator
62
- this.claude.sendMessage(options.prompt);
63
- this.startTyping();
124
+ claude.sendMessage(options.prompt);
125
+ this.startTyping(session);
64
126
  }
65
- handleEvent(event) {
127
+ handleEvent(threadId, event) {
128
+ const session = this.sessions.get(threadId);
129
+ if (!session)
130
+ return;
131
+ // Update last activity
132
+ session.lastActivityAt = new Date();
66
133
  // Check for special tool uses that need custom handling
67
134
  if (event.type === 'assistant') {
68
135
  const msg = event.message;
@@ -70,24 +137,21 @@ export class SessionManager {
70
137
  for (const block of msg?.content || []) {
71
138
  if (block.type === 'tool_use') {
72
139
  if (block.name === 'ExitPlanMode') {
73
- this.handleExitPlanMode();
140
+ this.handleExitPlanMode(session);
74
141
  hasSpecialTool = true;
75
142
  }
76
143
  else if (block.name === 'TodoWrite') {
77
- this.handleTodoWrite(block.input);
78
- // Don't set hasSpecialTool - let other content through
144
+ this.handleTodoWrite(session, block.input);
79
145
  }
80
146
  else if (block.name === 'Task') {
81
- this.handleTaskStart(block.id, block.input);
82
- // Don't set hasSpecialTool - let other content through
147
+ this.handleTaskStart(session, block.id, block.input);
83
148
  }
84
149
  else if (block.name === 'AskUserQuestion') {
85
- this.handleAskUserQuestion(block.id, block.input);
150
+ this.handleAskUserQuestion(session, block.id, block.input);
86
151
  hasSpecialTool = true;
87
152
  }
88
153
  }
89
154
  }
90
- // Skip normal output if we handled a special tool (we post it ourselves)
91
155
  if (hasSpecialTool)
92
156
  return;
93
157
  }
@@ -96,58 +160,58 @@ export class SessionManager {
96
160
  const msg = event.message;
97
161
  for (const block of msg?.content || []) {
98
162
  if (block.type === 'tool_result' && block.tool_use_id) {
99
- const postId = this.activeSubagents.get(block.tool_use_id);
163
+ const postId = session.activeSubagents.get(block.tool_use_id);
100
164
  if (postId) {
101
- this.handleTaskComplete(block.tool_use_id, postId);
165
+ this.handleTaskComplete(session, block.tool_use_id, postId);
102
166
  }
103
167
  }
104
168
  }
105
169
  }
106
- const formatted = this.formatEvent(event);
170
+ const formatted = this.formatEvent(session, event);
107
171
  if (this.debug) {
108
- console.log(`[DEBUG] handleEvent: ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
172
+ console.log(`[DEBUG] handleEvent(${threadId}): ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
109
173
  }
110
174
  if (formatted)
111
- this.appendContent(formatted);
175
+ this.appendContent(session, formatted);
112
176
  }
113
- async handleTaskComplete(toolUseId, postId) {
177
+ async handleTaskComplete(session, toolUseId, postId) {
114
178
  try {
115
- await this.mattermost.updatePost(postId, this.activeSubagents.has(toolUseId)
179
+ await this.mattermost.updatePost(postId, session.activeSubagents.has(toolUseId)
116
180
  ? `🤖 **Subagent** ✅ *completed*`
117
181
  : `🤖 **Subagent** ✅`);
118
- this.activeSubagents.delete(toolUseId);
182
+ session.activeSubagents.delete(toolUseId);
119
183
  }
120
184
  catch (err) {
121
185
  console.error('[Session] Failed to update subagent completion:', err);
122
186
  }
123
187
  }
124
- async handleExitPlanMode() {
125
- if (!this.session)
126
- return;
188
+ async handleExitPlanMode(session) {
127
189
  // If already approved in this session, auto-continue
128
- if (this.planApproved) {
190
+ if (session.planApproved) {
129
191
  console.log('[Session] Plan already approved, auto-continuing...');
130
- if (this.claude?.isRunning()) {
131
- this.claude.sendMessage('Continue with the implementation.');
132
- this.startTyping();
192
+ if (session.claude.isRunning()) {
193
+ session.claude.sendMessage('Continue with the implementation.');
194
+ this.startTyping(session);
133
195
  }
134
196
  return;
135
197
  }
136
198
  // If we already have a pending approval, don't post another one
137
- if (this.pendingApproval && this.pendingApproval.type === 'plan') {
199
+ if (session.pendingApproval && session.pendingApproval.type === 'plan') {
138
200
  console.log('[Session] Plan approval already pending, waiting...');
139
201
  return;
140
202
  }
141
203
  // Flush any pending content first
142
- await this.flush();
143
- this.session.postId = null;
144
- this.session.content = '';
204
+ await this.flush(session);
205
+ session.currentPostId = null;
206
+ session.pendingContent = '';
145
207
  // Post approval message with reactions
146
208
  const message = `✅ **Plan ready for approval**\n\n` +
147
209
  `👍 Approve and start building\n` +
148
210
  `👎 Request changes\n\n` +
149
211
  `*React to respond*`;
150
- const post = await this.mattermost.createPost(message, this.session.threadId);
212
+ const post = await this.mattermost.createPost(message, session.threadId);
213
+ // Register post for reaction routing
214
+ this.registerPost(post.id, session.threadId);
151
215
  // Add approval reactions
152
216
  try {
153
217
  await this.mattermost.addReaction(post.id, '+1');
@@ -157,19 +221,17 @@ export class SessionManager {
157
221
  console.error('[Session] Failed to add approval reactions:', err);
158
222
  }
159
223
  // Track this for reaction handling
160
- this.pendingApproval = { postId: post.id, type: 'plan' };
224
+ session.pendingApproval = { postId: post.id, type: 'plan' };
161
225
  // Stop typing while waiting
162
- this.stopTyping();
226
+ this.stopTyping(session);
163
227
  }
164
- async handleTodoWrite(input) {
165
- if (!this.session)
166
- return;
228
+ async handleTodoWrite(session, input) {
167
229
  const todos = input.todos;
168
230
  if (!todos || todos.length === 0) {
169
231
  // Clear tasks display if empty
170
- if (this.tasksPostId) {
232
+ if (session.tasksPostId) {
171
233
  try {
172
- await this.mattermost.updatePost(this.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
234
+ await this.mattermost.updatePost(session.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
173
235
  }
174
236
  catch (err) {
175
237
  console.error('[Session] Failed to update tasks:', err);
@@ -199,21 +261,19 @@ export class SessionManager {
199
261
  }
200
262
  // Update or create tasks post
201
263
  try {
202
- if (this.tasksPostId) {
203
- await this.mattermost.updatePost(this.tasksPostId, message);
264
+ if (session.tasksPostId) {
265
+ await this.mattermost.updatePost(session.tasksPostId, message);
204
266
  }
205
267
  else {
206
- const post = await this.mattermost.createPost(message, this.session.threadId);
207
- this.tasksPostId = post.id;
268
+ const post = await this.mattermost.createPost(message, session.threadId);
269
+ session.tasksPostId = post.id;
208
270
  }
209
271
  }
210
272
  catch (err) {
211
273
  console.error('[Session] Failed to update tasks:', err);
212
274
  }
213
275
  }
214
- async handleTaskStart(toolUseId, input) {
215
- if (!this.session)
216
- return;
276
+ async handleTaskStart(session, toolUseId, input) {
217
277
  const description = input.description || 'Working...';
218
278
  const subagentType = input.subagent_type || 'general';
219
279
  // Post subagent status
@@ -221,30 +281,28 @@ export class SessionManager {
221
281
  `> ${description}\n` +
222
282
  `⏳ Running...`;
223
283
  try {
224
- const post = await this.mattermost.createPost(message, this.session.threadId);
225
- this.activeSubagents.set(toolUseId, post.id);
284
+ const post = await this.mattermost.createPost(message, session.threadId);
285
+ session.activeSubagents.set(toolUseId, post.id);
226
286
  }
227
287
  catch (err) {
228
288
  console.error('[Session] Failed to post subagent status:', err);
229
289
  }
230
290
  }
231
- async handleAskUserQuestion(toolUseId, input) {
232
- if (!this.session)
233
- return;
291
+ async handleAskUserQuestion(session, toolUseId, input) {
234
292
  // If we already have pending questions, don't start another set
235
- if (this.pendingQuestionSet) {
293
+ if (session.pendingQuestionSet) {
236
294
  console.log('[Session] Questions already pending, waiting...');
237
295
  return;
238
296
  }
239
297
  // Flush any pending content first
240
- await this.flush();
241
- this.session.postId = null;
242
- this.session.content = '';
298
+ await this.flush(session);
299
+ session.currentPostId = null;
300
+ session.pendingContent = '';
243
301
  const questions = input.questions;
244
302
  if (!questions || questions.length === 0)
245
303
  return;
246
304
  // Create a new question set - we'll ask one at a time
247
- this.pendingQuestionSet = {
305
+ session.pendingQuestionSet = {
248
306
  toolUseId,
249
307
  currentIndex: 0,
250
308
  currentPostId: null,
@@ -256,19 +314,19 @@ export class SessionManager {
256
314
  })),
257
315
  };
258
316
  // Post the first question
259
- await this.postCurrentQuestion();
317
+ await this.postCurrentQuestion(session);
260
318
  // Stop typing while waiting for answer
261
- this.stopTyping();
319
+ this.stopTyping(session);
262
320
  }
263
- async postCurrentQuestion() {
264
- if (!this.session || !this.pendingQuestionSet)
321
+ async postCurrentQuestion(session) {
322
+ if (!session.pendingQuestionSet)
265
323
  return;
266
- const { currentIndex, questions } = this.pendingQuestionSet;
324
+ const { currentIndex, questions } = session.pendingQuestionSet;
267
325
  if (currentIndex >= questions.length)
268
326
  return;
269
327
  const q = questions[currentIndex];
270
328
  const total = questions.length;
271
- // Format the question message - show "Question (1/3)" not the header
329
+ // Format the question message
272
330
  let message = `❓ **Question** *(${currentIndex + 1}/${total})*\n`;
273
331
  message += `**${q.header}:** ${q.question}\n\n`;
274
332
  for (let i = 0; i < q.options.length && i < 4; i++) {
@@ -280,8 +338,10 @@ export class SessionManager {
280
338
  message += '\n';
281
339
  }
282
340
  // Post the question
283
- const post = await this.mattermost.createPost(message, this.session.threadId);
284
- this.pendingQuestionSet.currentPostId = post.id;
341
+ const post = await this.mattermost.createPost(message, session.threadId);
342
+ session.pendingQuestionSet.currentPostId = post.id;
343
+ // Register post for reaction routing
344
+ this.registerPost(post.id, session.threadId);
285
345
  // Add reaction emojis
286
346
  for (let i = 0; i < q.options.length && i < 4; i++) {
287
347
  try {
@@ -292,19 +352,32 @@ export class SessionManager {
292
352
  }
293
353
  }
294
354
  }
355
+ // ---------------------------------------------------------------------------
356
+ // Reaction Handling
357
+ // ---------------------------------------------------------------------------
295
358
  async handleReaction(postId, emojiName, username) {
296
359
  // Check if user is allowed
297
360
  if (!this.mattermost.isUserAllowed(username))
298
361
  return;
362
+ // Find the session this post belongs to
363
+ const session = this.getSessionByPost(postId);
364
+ if (!session)
365
+ return;
299
366
  // Handle approval reactions
300
- if (this.pendingApproval && this.pendingApproval.postId === postId) {
301
- await this.handleApprovalReaction(emojiName, username);
367
+ if (session.pendingApproval && session.pendingApproval.postId === postId) {
368
+ await this.handleApprovalReaction(session, emojiName, username);
302
369
  return;
303
370
  }
304
- // Handle question reactions - must be for current question
305
- if (!this.pendingQuestionSet || this.pendingQuestionSet.currentPostId !== postId)
371
+ // Handle question reactions
372
+ if (session.pendingQuestionSet && session.pendingQuestionSet.currentPostId === postId) {
373
+ await this.handleQuestionReaction(session, postId, emojiName, username);
306
374
  return;
307
- const { currentIndex, questions } = this.pendingQuestionSet;
375
+ }
376
+ }
377
+ async handleQuestionReaction(session, postId, emojiName, username) {
378
+ if (!session.pendingQuestionSet)
379
+ return;
380
+ const { currentIndex, questions } = session.pendingQuestionSet;
308
381
  const question = questions[currentIndex];
309
382
  if (!question)
310
383
  return;
@@ -322,35 +395,34 @@ export class SessionManager {
322
395
  console.error('[Session] Failed to update answered question:', err);
323
396
  }
324
397
  // Move to next question or finish
325
- this.pendingQuestionSet.currentIndex++;
326
- if (this.pendingQuestionSet.currentIndex < questions.length) {
398
+ session.pendingQuestionSet.currentIndex++;
399
+ if (session.pendingQuestionSet.currentIndex < questions.length) {
327
400
  // Post next question
328
- await this.postCurrentQuestion();
401
+ await this.postCurrentQuestion(session);
329
402
  }
330
403
  else {
331
404
  // All questions answered - send as follow-up message
332
- // (CLI auto-responds with error to AskUserQuestion, so tool_result won't work)
333
405
  let answersText = 'Here are my answers:\n';
334
406
  for (const q of questions) {
335
407
  answersText += `- **${q.header}**: ${q.answer}\n`;
336
408
  }
337
409
  console.log(`[Session] All questions answered, sending as message:`, answersText);
338
410
  // Clear and send as regular message
339
- this.pendingQuestionSet = null;
340
- if (this.claude?.isRunning()) {
341
- this.claude.sendMessage(answersText);
342
- this.startTyping();
411
+ session.pendingQuestionSet = null;
412
+ if (session.claude.isRunning()) {
413
+ session.claude.sendMessage(answersText);
414
+ this.startTyping(session);
343
415
  }
344
416
  }
345
417
  }
346
- async handleApprovalReaction(emojiName, username) {
347
- if (!this.pendingApproval)
418
+ async handleApprovalReaction(session, emojiName, username) {
419
+ if (!session.pendingApproval)
348
420
  return;
349
421
  const isApprove = emojiName === '+1' || emojiName === 'thumbsup';
350
422
  const isReject = emojiName === '-1' || emojiName === 'thumbsdown';
351
423
  if (!isApprove && !isReject)
352
424
  return;
353
- const postId = this.pendingApproval.postId;
425
+ const postId = session.pendingApproval.postId;
354
426
  console.log(`[Session] User ${username} ${isApprove ? 'approved' : 'rejected'} the plan`);
355
427
  // Update the post to show the decision
356
428
  try {
@@ -363,20 +435,20 @@ export class SessionManager {
363
435
  console.error('[Session] Failed to update approval post:', err);
364
436
  }
365
437
  // Clear pending approval and mark as approved
366
- this.pendingApproval = null;
438
+ session.pendingApproval = null;
367
439
  if (isApprove) {
368
- this.planApproved = true;
440
+ session.planApproved = true;
369
441
  }
370
442
  // Send response to Claude
371
- if (this.claude?.isRunning()) {
443
+ if (session.claude.isRunning()) {
372
444
  const response = isApprove
373
445
  ? 'Approved. Please proceed with the implementation.'
374
446
  : 'Please revise the plan. I would like some changes.';
375
- this.claude.sendMessage(response);
376
- this.startTyping();
447
+ session.claude.sendMessage(response);
448
+ this.startTyping(session);
377
449
  }
378
450
  }
379
- formatEvent(e) {
451
+ formatEvent(session, e) {
380
452
  switch (e.type) {
381
453
  case 'assistant': {
382
454
  const msg = e.message;
@@ -415,12 +487,10 @@ export class SessionManager {
415
487
  }
416
488
  case 'result': {
417
489
  // Response complete - stop typing and start new post for next message
418
- this.stopTyping();
419
- if (this.session) {
420
- this.flush();
421
- this.session.postId = null;
422
- this.session.content = '';
423
- }
490
+ this.stopTyping(session);
491
+ this.flush(session);
492
+ session.currentPostId = null;
493
+ session.pendingContent = '';
424
494
  return null;
425
495
  }
426
496
  case 'system':
@@ -508,75 +578,134 @@ export class SessionManager {
508
578
  }
509
579
  }
510
580
  }
511
- appendContent(text) {
512
- if (!this.session || !text)
581
+ appendContent(session, text) {
582
+ if (!text)
513
583
  return;
514
- this.session.content += text + '\n';
515
- this.scheduleUpdate();
584
+ session.pendingContent += text + '\n';
585
+ this.scheduleUpdate(session);
516
586
  }
517
- scheduleUpdate() {
518
- if (this.updateTimer)
587
+ scheduleUpdate(session) {
588
+ if (session.updateTimer)
519
589
  return;
520
- this.updateTimer = setTimeout(() => {
521
- this.updateTimer = null;
522
- this.flush();
590
+ session.updateTimer = setTimeout(() => {
591
+ session.updateTimer = null;
592
+ this.flush(session);
523
593
  }, 500);
524
594
  }
525
- startTyping() {
526
- if (this.typingTimer)
595
+ startTyping(session) {
596
+ if (session.typingTimer)
527
597
  return;
528
598
  // Send typing immediately, then every 3 seconds
529
- this.mattermost.sendTyping(this.session?.threadId);
530
- this.typingTimer = setInterval(() => {
531
- this.mattermost.sendTyping(this.session?.threadId);
599
+ this.mattermost.sendTyping(session.threadId);
600
+ session.typingTimer = setInterval(() => {
601
+ this.mattermost.sendTyping(session.threadId);
532
602
  }, 3000);
533
603
  }
534
- stopTyping() {
535
- if (this.typingTimer) {
536
- clearInterval(this.typingTimer);
537
- this.typingTimer = null;
604
+ stopTyping(session) {
605
+ if (session.typingTimer) {
606
+ clearInterval(session.typingTimer);
607
+ session.typingTimer = null;
538
608
  }
539
609
  }
540
- async flush() {
541
- if (!this.session || !this.session.content.trim())
610
+ async flush(session) {
611
+ if (!session.pendingContent.trim())
542
612
  return;
543
- const content = this.session.content.replace(/\n{3,}/g, '\n\n').trim();
544
- if (this.session.postId) {
545
- await this.mattermost.updatePost(this.session.postId, content);
613
+ const content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
614
+ if (session.currentPostId) {
615
+ await this.mattermost.updatePost(session.currentPostId, content);
546
616
  }
547
617
  else {
548
- const post = await this.mattermost.createPost(content, this.session.threadId);
549
- this.session.postId = post.id;
618
+ const post = await this.mattermost.createPost(content, session.threadId);
619
+ session.currentPostId = post.id;
620
+ // Register post for reaction routing
621
+ this.registerPost(post.id, session.threadId);
550
622
  }
551
623
  }
552
- async handleExit(code) {
553
- this.stopTyping();
554
- if (this.updateTimer) {
555
- clearTimeout(this.updateTimer);
556
- this.updateTimer = null;
557
- }
558
- await this.flush();
559
- if (code !== 0 && this.session) {
560
- await this.mattermost.createPost(`**[Exited: ${code}]**`, this.session.threadId);
624
+ async handleExit(threadId, code) {
625
+ const session = this.sessions.get(threadId);
626
+ if (!session)
627
+ return;
628
+ this.stopTyping(session);
629
+ if (session.updateTimer) {
630
+ clearTimeout(session.updateTimer);
631
+ session.updateTimer = null;
632
+ }
633
+ await this.flush(session);
634
+ if (code !== 0) {
635
+ await this.mattermost.createPost(`**[Exited: ${code}]**`, session.threadId);
636
+ }
637
+ // Clean up session from maps
638
+ this.sessions.delete(threadId);
639
+ // Clean up post index entries for this session
640
+ for (const [postId, tid] of this.postIndex.entries()) {
641
+ if (tid === threadId) {
642
+ this.postIndex.delete(postId);
643
+ }
561
644
  }
562
- this.session = null;
645
+ console.log(`[Sessions] Session ended for thread ${threadId} (remaining: ${this.sessions.size})`);
563
646
  }
647
+ // ---------------------------------------------------------------------------
648
+ // Public Session API
649
+ // ---------------------------------------------------------------------------
650
+ /** Check if any sessions are active */
564
651
  isSessionActive() {
565
- return this.session !== null;
652
+ return this.sessions.size > 0;
566
653
  }
567
- isInCurrentSessionThread(threadRoot) {
568
- return this.session?.threadId === threadRoot;
654
+ /** Check if a session exists for this thread */
655
+ isInSessionThread(threadRoot) {
656
+ const session = this.sessions.get(threadRoot);
657
+ return session !== undefined && session.claude.isRunning();
658
+ }
659
+ /** Send a follow-up message to an existing session */
660
+ async sendFollowUp(threadId, message) {
661
+ const session = this.sessions.get(threadId);
662
+ if (!session || !session.claude.isRunning())
663
+ return;
664
+ session.claude.sendMessage(message);
665
+ session.lastActivityAt = new Date();
666
+ this.startTyping(session);
569
667
  }
570
- async sendFollowUp(message) {
571
- if (!this.claude?.isRunning() || !this.session)
668
+ /** Kill a specific session */
669
+ killSession(threadId) {
670
+ const session = this.sessions.get(threadId);
671
+ if (!session)
572
672
  return;
573
- this.claude.sendMessage(message);
574
- this.startTyping();
673
+ this.stopTyping(session);
674
+ session.claude.kill();
675
+ // Clean up session from maps
676
+ this.sessions.delete(threadId);
677
+ for (const [postId, tid] of this.postIndex.entries()) {
678
+ if (tid === threadId) {
679
+ this.postIndex.delete(postId);
680
+ }
681
+ }
682
+ console.log(`[Sessions] Session killed for thread ${threadId} (remaining: ${this.sessions.size})`);
683
+ }
684
+ /** Kill all active sessions (for graceful shutdown) */
685
+ killAllSessions() {
686
+ for (const [threadId, session] of this.sessions.entries()) {
687
+ this.stopTyping(session);
688
+ session.claude.kill();
689
+ console.log(`[Sessions] Killed session for thread ${threadId}`);
690
+ }
691
+ this.sessions.clear();
692
+ this.postIndex.clear();
693
+ if (this.cleanupTimer) {
694
+ clearInterval(this.cleanupTimer);
695
+ this.cleanupTimer = null;
696
+ }
697
+ console.log(`[Sessions] All sessions killed`);
575
698
  }
576
- killSession() {
577
- this.stopTyping();
578
- this.claude?.kill();
579
- this.claude = null;
580
- this.session = null;
699
+ /** Cleanup idle sessions that have exceeded timeout */
700
+ cleanupIdleSessions() {
701
+ const now = Date.now();
702
+ for (const [threadId, session] of this.sessions.entries()) {
703
+ const idleTime = now - session.lastActivityAt.getTime();
704
+ if (idleTime > SESSION_TIMEOUT_MS) {
705
+ console.log(`[Sessions] Session ${threadId} timed out after ${Math.round(idleTime / 60000)} minutes`);
706
+ this.mattermost.createPost(`⏰ **Session timed out** - no activity for ${Math.round(idleTime / 60000)} minutes`, session.threadId).catch(err => console.error('[Sessions] Failed to post timeout message:', err));
707
+ this.killSession(threadId);
708
+ }
709
+ }
581
710
  }
582
711
  }
package/dist/index.js CHANGED
@@ -36,14 +36,14 @@ Usage: cd /your/project && mm-claude`);
36
36
  const message = post.message;
37
37
  const threadRoot = post.root_id || post.id;
38
38
  // Follow-up in active thread
39
- if (session.isInCurrentSessionThread(threadRoot)) {
39
+ if (session.isInSessionThread(threadRoot)) {
40
40
  if (!mattermost.isUserAllowed(username))
41
41
  return;
42
42
  const content = mattermost.isBotMentioned(message)
43
43
  ? mattermost.extractPrompt(message)
44
44
  : message.trim();
45
45
  if (content)
46
- await session.sendFollowUp(content);
46
+ await session.sendFollowUp(threadRoot, content);
47
47
  return;
48
48
  }
49
49
  // New session requires @mention
@@ -66,7 +66,7 @@ Usage: cd /your/project && mm-claude`);
66
66
  console.log(`🎉 Ready! @${config.mattermost.botName}`);
67
67
  const shutdown = () => {
68
68
  console.log('\n👋 Bye');
69
- session.killSession();
69
+ session.killAllSessions();
70
70
  mattermost.disconnect();
71
71
  process.exit(0);
72
72
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mattermost-claude-code",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",