mattermost-claude-code 0.2.3 → 0.3.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.
@@ -47,7 +47,9 @@ export class ClaudeCli extends EventEmitter {
47
47
  args.push('--mcp-config', JSON.stringify(mcpConfig));
48
48
  args.push('--permission-prompt-tool', 'mcp__mm-claude-permissions__permission_prompt');
49
49
  }
50
- console.log(`[Claude] Starting: ${claudePath} ${args.slice(0, 5).join(' ')}...`);
50
+ if (this.debug) {
51
+ console.log(` [claude] Starting: ${claudePath} ${args.slice(0, 5).join(' ')}...`);
52
+ }
51
53
  this.process = spawn(claudePath, args, {
52
54
  cwd: this.options.workingDir,
53
55
  env: process.env,
@@ -57,14 +59,18 @@ export class ClaudeCli extends EventEmitter {
57
59
  this.parseOutput(chunk.toString());
58
60
  });
59
61
  this.process.stderr?.on('data', (chunk) => {
60
- console.error(`[Claude stderr] ${chunk.toString().trim()}`);
62
+ if (this.debug) {
63
+ console.error(` [claude:err] ${chunk.toString().trim()}`);
64
+ }
61
65
  });
62
66
  this.process.on('error', (err) => {
63
- console.error('[Claude] Error:', err);
67
+ console.error('Claude error:', err);
64
68
  this.emit('error', err);
65
69
  });
66
70
  this.process.on('exit', (code) => {
67
- console.log(`[Claude] Exited ${code}`);
71
+ if (this.debug) {
72
+ console.log(` [claude] Exited ${code}`);
73
+ }
68
74
  this.process = null;
69
75
  this.buffer = '';
70
76
  this.emit('exit', code);
@@ -78,7 +84,9 @@ export class ClaudeCli extends EventEmitter {
78
84
  type: 'user',
79
85
  message: { role: 'user', content }
80
86
  }) + '\n';
81
- console.log(`[Claude] Sending: ${content.substring(0, 50)}...`);
87
+ if (this.debug) {
88
+ console.log(` [claude] Sending: ${content.substring(0, 50)}...`);
89
+ }
82
90
  this.process.stdin.write(msg);
83
91
  }
84
92
  // Send a tool result response
@@ -96,7 +104,9 @@ export class ClaudeCli extends EventEmitter {
96
104
  }]
97
105
  }
98
106
  }) + '\n';
99
- console.log(`[Claude] Sending tool_result for ${toolUseId}`);
107
+ if (this.debug) {
108
+ console.log(` [claude] Sending tool_result for ${toolUseId}`);
109
+ }
100
110
  this.process.stdin.write(msg);
101
111
  }
102
112
  parseOutput(data) {
@@ -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 {};
@@ -1,4 +1,9 @@
1
1
  import { ClaudeCli } from './cli.js';
2
+ import { readFileSync } from 'fs';
3
+ import { dirname, resolve } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', '..', 'package.json'), 'utf-8'));
2
7
  const REACTION_EMOJIS = ['one', 'two', 'three', 'four'];
3
8
  const EMOJI_TO_INDEX = {
4
9
  'one': 0, '1️⃣': 0,
@@ -6,20 +11,25 @@ const EMOJI_TO_INDEX = {
6
11
  'three': 2, '3️⃣': 2,
7
12
  'four': 3, '4️⃣': 3,
8
13
  };
14
+ // =============================================================================
15
+ // Configuration
16
+ // =============================================================================
17
+ const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '5', 10);
18
+ const SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS || '1800000', 10); // 30 min
19
+ // =============================================================================
20
+ // SessionManager - Manages multiple concurrent Claude Code sessions
21
+ // =============================================================================
9
22
  export class SessionManager {
10
- claude = null;
23
+ // Shared state
11
24
  mattermost;
12
25
  workingDir;
13
26
  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
27
  debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
28
+ // Multi-session storage
29
+ sessions = new Map(); // threadId -> Session
30
+ postIndex = new Map(); // postId -> threadId (for reaction routing)
31
+ // Cleanup timer
32
+ cleanupTimer = null;
23
33
  constructor(mattermost, workingDir, skipPermissions = false) {
24
34
  this.mattermost = mattermost;
25
35
  this.workingDir = workingDir;
@@ -28,41 +38,123 @@ export class SessionManager {
28
38
  this.mattermost.on('reaction', (reaction, user) => {
29
39
  this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
30
40
  });
41
+ // Start periodic cleanup of idle sessions
42
+ this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
31
43
  }
44
+ // ---------------------------------------------------------------------------
45
+ // Session Lookup Methods
46
+ // ---------------------------------------------------------------------------
47
+ /** Get a session by thread ID */
48
+ getSession(threadId) {
49
+ return this.sessions.get(threadId);
50
+ }
51
+ /** Check if a session exists for this thread */
52
+ hasSession(threadId) {
53
+ return this.sessions.has(threadId);
54
+ }
55
+ /** Get the number of active sessions */
56
+ getSessionCount() {
57
+ return this.sessions.size;
58
+ }
59
+ /** Register a post for reaction routing */
60
+ registerPost(postId, threadId) {
61
+ this.postIndex.set(postId, threadId);
62
+ }
63
+ /** Find session by post ID (for reaction routing) */
64
+ getSessionByPost(postId) {
65
+ const threadId = this.postIndex.get(postId);
66
+ return threadId ? this.sessions.get(threadId) : undefined;
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // Session Lifecycle
70
+ // ---------------------------------------------------------------------------
32
71
  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
- }
72
+ const threadId = replyToPostId || '';
73
+ // Check if session already exists for this thread
74
+ const existingSession = this.sessions.get(threadId);
75
+ if (existingSession && existingSession.claude.isRunning()) {
76
+ // Send as follow-up instead
77
+ await this.sendFollowUp(threadId, options.prompt);
78
+ return;
79
+ }
80
+ // Check max sessions limit
81
+ if (this.sessions.size >= MAX_SESSIONS) {
82
+ await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
83
+ return;
84
+ }
85
+ // Post session start message
86
+ const shortDir = this.workingDir.replace(process.env.HOME || '', '~');
87
+ const sessionNum = this.sessions.size + 1;
88
+ const permMode = this.skipPermissions ? '⚡ Auto' : '🔐 Interactive';
89
+ const promptPreview = options.prompt.length > 60
90
+ ? options.prompt.substring(0, 60) + '…'
91
+ : options.prompt;
92
+ const msg = [
93
+ `### 🤖 Claude Code \`v${pkg.version}\``,
94
+ ``,
95
+ `| | |`,
96
+ `|:--|:--|`,
97
+ `| 📂 **Directory** | \`${shortDir}\` |`,
98
+ `| 👤 **Started by** | @${username} |`,
99
+ `| 🔢 **Session** | #${sessionNum} of ${MAX_SESSIONS} max |`,
100
+ `| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`,
101
+ ``,
102
+ `> ${promptPreview}`,
103
+ ].join('\n');
104
+ const post = await this.mattermost.createPost(msg, replyToPostId);
105
+ const actualThreadId = replyToPostId || post.id;
106
+ // Create Claude CLI with options
107
+ const cliOptions = {
108
+ workingDir: this.workingDir,
109
+ threadId: actualThreadId,
110
+ skipPermissions: this.skipPermissions,
111
+ };
112
+ const claude = new ClaudeCli(cliOptions);
113
+ // Create the session object
114
+ const session = {
115
+ threadId: actualThreadId,
116
+ startedBy: username,
117
+ startedAt: new Date(),
118
+ lastActivityAt: new Date(),
119
+ claude,
120
+ currentPostId: null,
121
+ pendingContent: '',
122
+ pendingApproval: null,
123
+ pendingQuestionSet: null,
124
+ planApproved: false,
125
+ tasksPostId: null,
126
+ activeSubagents: new Map(),
127
+ updateTimer: null,
128
+ typingTimer: null,
129
+ };
130
+ // Register session
131
+ this.sessions.set(actualThreadId, session);
132
+ const shortId = actualThreadId.substring(0, 8);
133
+ console.log(` ▶ Session #${this.sessions.size} started (${shortId}…) by @${username}`);
134
+ // Start typing indicator immediately so user sees activity
135
+ this.startTyping(session);
136
+ // Bind event handlers with closure over threadId
137
+ claude.on('event', (e) => this.handleEvent(actualThreadId, e));
138
+ claude.on('exit', (code) => this.handleExit(actualThreadId, code));
139
+ try {
140
+ claude.start();
141
+ }
142
+ catch (err) {
143
+ console.error(' ❌ Failed to start Claude:', err);
144
+ this.stopTyping(session);
145
+ await this.mattermost.createPost(`❌ ${err}`, actualThreadId);
146
+ this.sessions.delete(actualThreadId);
147
+ return;
60
148
  }
61
- // Send the message and start typing indicator
62
- this.claude.sendMessage(options.prompt);
63
- this.startTyping();
149
+ // Send the message to Claude
150
+ claude.sendMessage(options.prompt);
64
151
  }
65
- handleEvent(event) {
152
+ handleEvent(threadId, event) {
153
+ const session = this.sessions.get(threadId);
154
+ if (!session)
155
+ return;
156
+ // Update last activity
157
+ session.lastActivityAt = new Date();
66
158
  // Check for special tool uses that need custom handling
67
159
  if (event.type === 'assistant') {
68
160
  const msg = event.message;
@@ -70,24 +162,21 @@ export class SessionManager {
70
162
  for (const block of msg?.content || []) {
71
163
  if (block.type === 'tool_use') {
72
164
  if (block.name === 'ExitPlanMode') {
73
- this.handleExitPlanMode();
165
+ this.handleExitPlanMode(session);
74
166
  hasSpecialTool = true;
75
167
  }
76
168
  else if (block.name === 'TodoWrite') {
77
- this.handleTodoWrite(block.input);
78
- // Don't set hasSpecialTool - let other content through
169
+ this.handleTodoWrite(session, block.input);
79
170
  }
80
171
  else if (block.name === 'Task') {
81
- this.handleTaskStart(block.id, block.input);
82
- // Don't set hasSpecialTool - let other content through
172
+ this.handleTaskStart(session, block.id, block.input);
83
173
  }
84
174
  else if (block.name === 'AskUserQuestion') {
85
- this.handleAskUserQuestion(block.id, block.input);
175
+ this.handleAskUserQuestion(session, block.id, block.input);
86
176
  hasSpecialTool = true;
87
177
  }
88
178
  }
89
179
  }
90
- // Skip normal output if we handled a special tool (we post it ourselves)
91
180
  if (hasSpecialTool)
92
181
  return;
93
182
  }
@@ -96,58 +185,58 @@ export class SessionManager {
96
185
  const msg = event.message;
97
186
  for (const block of msg?.content || []) {
98
187
  if (block.type === 'tool_result' && block.tool_use_id) {
99
- const postId = this.activeSubagents.get(block.tool_use_id);
188
+ const postId = session.activeSubagents.get(block.tool_use_id);
100
189
  if (postId) {
101
- this.handleTaskComplete(block.tool_use_id, postId);
190
+ this.handleTaskComplete(session, block.tool_use_id, postId);
102
191
  }
103
192
  }
104
193
  }
105
194
  }
106
- const formatted = this.formatEvent(event);
195
+ const formatted = this.formatEvent(session, event);
107
196
  if (this.debug) {
108
- console.log(`[DEBUG] handleEvent: ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
197
+ console.log(`[DEBUG] handleEvent(${threadId}): ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
109
198
  }
110
199
  if (formatted)
111
- this.appendContent(formatted);
200
+ this.appendContent(session, formatted);
112
201
  }
113
- async handleTaskComplete(toolUseId, postId) {
202
+ async handleTaskComplete(session, toolUseId, postId) {
114
203
  try {
115
- await this.mattermost.updatePost(postId, this.activeSubagents.has(toolUseId)
204
+ await this.mattermost.updatePost(postId, session.activeSubagents.has(toolUseId)
116
205
  ? `🤖 **Subagent** ✅ *completed*`
117
206
  : `🤖 **Subagent** ✅`);
118
- this.activeSubagents.delete(toolUseId);
207
+ session.activeSubagents.delete(toolUseId);
119
208
  }
120
209
  catch (err) {
121
210
  console.error('[Session] Failed to update subagent completion:', err);
122
211
  }
123
212
  }
124
- async handleExitPlanMode() {
125
- if (!this.session)
126
- return;
213
+ async handleExitPlanMode(session) {
127
214
  // If already approved in this session, auto-continue
128
- if (this.planApproved) {
215
+ if (session.planApproved) {
129
216
  console.log('[Session] Plan already approved, auto-continuing...');
130
- if (this.claude?.isRunning()) {
131
- this.claude.sendMessage('Continue with the implementation.');
132
- this.startTyping();
217
+ if (session.claude.isRunning()) {
218
+ session.claude.sendMessage('Continue with the implementation.');
219
+ this.startTyping(session);
133
220
  }
134
221
  return;
135
222
  }
136
223
  // If we already have a pending approval, don't post another one
137
- if (this.pendingApproval && this.pendingApproval.type === 'plan') {
224
+ if (session.pendingApproval && session.pendingApproval.type === 'plan') {
138
225
  console.log('[Session] Plan approval already pending, waiting...');
139
226
  return;
140
227
  }
141
228
  // Flush any pending content first
142
- await this.flush();
143
- this.session.postId = null;
144
- this.session.content = '';
229
+ await this.flush(session);
230
+ session.currentPostId = null;
231
+ session.pendingContent = '';
145
232
  // Post approval message with reactions
146
233
  const message = `✅ **Plan ready for approval**\n\n` +
147
234
  `👍 Approve and start building\n` +
148
235
  `👎 Request changes\n\n` +
149
236
  `*React to respond*`;
150
- const post = await this.mattermost.createPost(message, this.session.threadId);
237
+ const post = await this.mattermost.createPost(message, session.threadId);
238
+ // Register post for reaction routing
239
+ this.registerPost(post.id, session.threadId);
151
240
  // Add approval reactions
152
241
  try {
153
242
  await this.mattermost.addReaction(post.id, '+1');
@@ -157,19 +246,17 @@ export class SessionManager {
157
246
  console.error('[Session] Failed to add approval reactions:', err);
158
247
  }
159
248
  // Track this for reaction handling
160
- this.pendingApproval = { postId: post.id, type: 'plan' };
249
+ session.pendingApproval = { postId: post.id, type: 'plan' };
161
250
  // Stop typing while waiting
162
- this.stopTyping();
251
+ this.stopTyping(session);
163
252
  }
164
- async handleTodoWrite(input) {
165
- if (!this.session)
166
- return;
253
+ async handleTodoWrite(session, input) {
167
254
  const todos = input.todos;
168
255
  if (!todos || todos.length === 0) {
169
256
  // Clear tasks display if empty
170
- if (this.tasksPostId) {
257
+ if (session.tasksPostId) {
171
258
  try {
172
- await this.mattermost.updatePost(this.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
259
+ await this.mattermost.updatePost(session.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
173
260
  }
174
261
  catch (err) {
175
262
  console.error('[Session] Failed to update tasks:', err);
@@ -199,21 +286,19 @@ export class SessionManager {
199
286
  }
200
287
  // Update or create tasks post
201
288
  try {
202
- if (this.tasksPostId) {
203
- await this.mattermost.updatePost(this.tasksPostId, message);
289
+ if (session.tasksPostId) {
290
+ await this.mattermost.updatePost(session.tasksPostId, message);
204
291
  }
205
292
  else {
206
- const post = await this.mattermost.createPost(message, this.session.threadId);
207
- this.tasksPostId = post.id;
293
+ const post = await this.mattermost.createPost(message, session.threadId);
294
+ session.tasksPostId = post.id;
208
295
  }
209
296
  }
210
297
  catch (err) {
211
298
  console.error('[Session] Failed to update tasks:', err);
212
299
  }
213
300
  }
214
- async handleTaskStart(toolUseId, input) {
215
- if (!this.session)
216
- return;
301
+ async handleTaskStart(session, toolUseId, input) {
217
302
  const description = input.description || 'Working...';
218
303
  const subagentType = input.subagent_type || 'general';
219
304
  // Post subagent status
@@ -221,30 +306,28 @@ export class SessionManager {
221
306
  `> ${description}\n` +
222
307
  `⏳ Running...`;
223
308
  try {
224
- const post = await this.mattermost.createPost(message, this.session.threadId);
225
- this.activeSubagents.set(toolUseId, post.id);
309
+ const post = await this.mattermost.createPost(message, session.threadId);
310
+ session.activeSubagents.set(toolUseId, post.id);
226
311
  }
227
312
  catch (err) {
228
313
  console.error('[Session] Failed to post subagent status:', err);
229
314
  }
230
315
  }
231
- async handleAskUserQuestion(toolUseId, input) {
232
- if (!this.session)
233
- return;
316
+ async handleAskUserQuestion(session, toolUseId, input) {
234
317
  // If we already have pending questions, don't start another set
235
- if (this.pendingQuestionSet) {
318
+ if (session.pendingQuestionSet) {
236
319
  console.log('[Session] Questions already pending, waiting...');
237
320
  return;
238
321
  }
239
322
  // Flush any pending content first
240
- await this.flush();
241
- this.session.postId = null;
242
- this.session.content = '';
323
+ await this.flush(session);
324
+ session.currentPostId = null;
325
+ session.pendingContent = '';
243
326
  const questions = input.questions;
244
327
  if (!questions || questions.length === 0)
245
328
  return;
246
329
  // Create a new question set - we'll ask one at a time
247
- this.pendingQuestionSet = {
330
+ session.pendingQuestionSet = {
248
331
  toolUseId,
249
332
  currentIndex: 0,
250
333
  currentPostId: null,
@@ -256,19 +339,19 @@ export class SessionManager {
256
339
  })),
257
340
  };
258
341
  // Post the first question
259
- await this.postCurrentQuestion();
342
+ await this.postCurrentQuestion(session);
260
343
  // Stop typing while waiting for answer
261
- this.stopTyping();
344
+ this.stopTyping(session);
262
345
  }
263
- async postCurrentQuestion() {
264
- if (!this.session || !this.pendingQuestionSet)
346
+ async postCurrentQuestion(session) {
347
+ if (!session.pendingQuestionSet)
265
348
  return;
266
- const { currentIndex, questions } = this.pendingQuestionSet;
349
+ const { currentIndex, questions } = session.pendingQuestionSet;
267
350
  if (currentIndex >= questions.length)
268
351
  return;
269
352
  const q = questions[currentIndex];
270
353
  const total = questions.length;
271
- // Format the question message - show "Question (1/3)" not the header
354
+ // Format the question message
272
355
  let message = `❓ **Question** *(${currentIndex + 1}/${total})*\n`;
273
356
  message += `**${q.header}:** ${q.question}\n\n`;
274
357
  for (let i = 0; i < q.options.length && i < 4; i++) {
@@ -280,8 +363,10 @@ export class SessionManager {
280
363
  message += '\n';
281
364
  }
282
365
  // Post the question
283
- const post = await this.mattermost.createPost(message, this.session.threadId);
284
- this.pendingQuestionSet.currentPostId = post.id;
366
+ const post = await this.mattermost.createPost(message, session.threadId);
367
+ session.pendingQuestionSet.currentPostId = post.id;
368
+ // Register post for reaction routing
369
+ this.registerPost(post.id, session.threadId);
285
370
  // Add reaction emojis
286
371
  for (let i = 0; i < q.options.length && i < 4; i++) {
287
372
  try {
@@ -292,19 +377,32 @@ export class SessionManager {
292
377
  }
293
378
  }
294
379
  }
380
+ // ---------------------------------------------------------------------------
381
+ // Reaction Handling
382
+ // ---------------------------------------------------------------------------
295
383
  async handleReaction(postId, emojiName, username) {
296
384
  // Check if user is allowed
297
385
  if (!this.mattermost.isUserAllowed(username))
298
386
  return;
387
+ // Find the session this post belongs to
388
+ const session = this.getSessionByPost(postId);
389
+ if (!session)
390
+ return;
299
391
  // Handle approval reactions
300
- if (this.pendingApproval && this.pendingApproval.postId === postId) {
301
- await this.handleApprovalReaction(emojiName, username);
392
+ if (session.pendingApproval && session.pendingApproval.postId === postId) {
393
+ await this.handleApprovalReaction(session, emojiName, username);
302
394
  return;
303
395
  }
304
- // Handle question reactions - must be for current question
305
- if (!this.pendingQuestionSet || this.pendingQuestionSet.currentPostId !== postId)
396
+ // Handle question reactions
397
+ if (session.pendingQuestionSet && session.pendingQuestionSet.currentPostId === postId) {
398
+ await this.handleQuestionReaction(session, postId, emojiName, username);
306
399
  return;
307
- const { currentIndex, questions } = this.pendingQuestionSet;
400
+ }
401
+ }
402
+ async handleQuestionReaction(session, postId, emojiName, username) {
403
+ if (!session.pendingQuestionSet)
404
+ return;
405
+ const { currentIndex, questions } = session.pendingQuestionSet;
308
406
  const question = questions[currentIndex];
309
407
  if (!question)
310
408
  return;
@@ -322,35 +420,34 @@ export class SessionManager {
322
420
  console.error('[Session] Failed to update answered question:', err);
323
421
  }
324
422
  // Move to next question or finish
325
- this.pendingQuestionSet.currentIndex++;
326
- if (this.pendingQuestionSet.currentIndex < questions.length) {
423
+ session.pendingQuestionSet.currentIndex++;
424
+ if (session.pendingQuestionSet.currentIndex < questions.length) {
327
425
  // Post next question
328
- await this.postCurrentQuestion();
426
+ await this.postCurrentQuestion(session);
329
427
  }
330
428
  else {
331
429
  // All questions answered - send as follow-up message
332
- // (CLI auto-responds with error to AskUserQuestion, so tool_result won't work)
333
430
  let answersText = 'Here are my answers:\n';
334
431
  for (const q of questions) {
335
432
  answersText += `- **${q.header}**: ${q.answer}\n`;
336
433
  }
337
434
  console.log(`[Session] All questions answered, sending as message:`, answersText);
338
435
  // Clear and send as regular message
339
- this.pendingQuestionSet = null;
340
- if (this.claude?.isRunning()) {
341
- this.claude.sendMessage(answersText);
342
- this.startTyping();
436
+ session.pendingQuestionSet = null;
437
+ if (session.claude.isRunning()) {
438
+ session.claude.sendMessage(answersText);
439
+ this.startTyping(session);
343
440
  }
344
441
  }
345
442
  }
346
- async handleApprovalReaction(emojiName, username) {
347
- if (!this.pendingApproval)
443
+ async handleApprovalReaction(session, emojiName, username) {
444
+ if (!session.pendingApproval)
348
445
  return;
349
446
  const isApprove = emojiName === '+1' || emojiName === 'thumbsup';
350
447
  const isReject = emojiName === '-1' || emojiName === 'thumbsdown';
351
448
  if (!isApprove && !isReject)
352
449
  return;
353
- const postId = this.pendingApproval.postId;
450
+ const postId = session.pendingApproval.postId;
354
451
  console.log(`[Session] User ${username} ${isApprove ? 'approved' : 'rejected'} the plan`);
355
452
  // Update the post to show the decision
356
453
  try {
@@ -363,20 +460,20 @@ export class SessionManager {
363
460
  console.error('[Session] Failed to update approval post:', err);
364
461
  }
365
462
  // Clear pending approval and mark as approved
366
- this.pendingApproval = null;
463
+ session.pendingApproval = null;
367
464
  if (isApprove) {
368
- this.planApproved = true;
465
+ session.planApproved = true;
369
466
  }
370
467
  // Send response to Claude
371
- if (this.claude?.isRunning()) {
468
+ if (session.claude.isRunning()) {
372
469
  const response = isApprove
373
470
  ? 'Approved. Please proceed with the implementation.'
374
471
  : 'Please revise the plan. I would like some changes.';
375
- this.claude.sendMessage(response);
376
- this.startTyping();
472
+ session.claude.sendMessage(response);
473
+ this.startTyping(session);
377
474
  }
378
475
  }
379
- formatEvent(e) {
476
+ formatEvent(session, e) {
380
477
  switch (e.type) {
381
478
  case 'assistant': {
382
479
  const msg = e.message;
@@ -415,12 +512,10 @@ export class SessionManager {
415
512
  }
416
513
  case 'result': {
417
514
  // 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
- }
515
+ this.stopTyping(session);
516
+ this.flush(session);
517
+ session.currentPostId = null;
518
+ session.pendingContent = '';
424
519
  return null;
425
520
  }
426
521
  case 'system':
@@ -508,75 +603,140 @@ export class SessionManager {
508
603
  }
509
604
  }
510
605
  }
511
- appendContent(text) {
512
- if (!this.session || !text)
606
+ appendContent(session, text) {
607
+ if (!text)
513
608
  return;
514
- this.session.content += text + '\n';
515
- this.scheduleUpdate();
609
+ session.pendingContent += text + '\n';
610
+ this.scheduleUpdate(session);
516
611
  }
517
- scheduleUpdate() {
518
- if (this.updateTimer)
612
+ scheduleUpdate(session) {
613
+ if (session.updateTimer)
519
614
  return;
520
- this.updateTimer = setTimeout(() => {
521
- this.updateTimer = null;
522
- this.flush();
615
+ session.updateTimer = setTimeout(() => {
616
+ session.updateTimer = null;
617
+ this.flush(session);
523
618
  }, 500);
524
619
  }
525
- startTyping() {
526
- if (this.typingTimer)
620
+ startTyping(session) {
621
+ if (session.typingTimer)
527
622
  return;
528
623
  // 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);
624
+ this.mattermost.sendTyping(session.threadId);
625
+ session.typingTimer = setInterval(() => {
626
+ this.mattermost.sendTyping(session.threadId);
532
627
  }, 3000);
533
628
  }
534
- stopTyping() {
535
- if (this.typingTimer) {
536
- clearInterval(this.typingTimer);
537
- this.typingTimer = null;
629
+ stopTyping(session) {
630
+ if (session.typingTimer) {
631
+ clearInterval(session.typingTimer);
632
+ session.typingTimer = null;
538
633
  }
539
634
  }
540
- async flush() {
541
- if (!this.session || !this.session.content.trim())
635
+ async flush(session) {
636
+ if (!session.pendingContent.trim())
542
637
  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);
638
+ const content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
639
+ if (session.currentPostId) {
640
+ await this.mattermost.updatePost(session.currentPostId, content);
546
641
  }
547
642
  else {
548
- const post = await this.mattermost.createPost(content, this.session.threadId);
549
- this.session.postId = post.id;
643
+ const post = await this.mattermost.createPost(content, session.threadId);
644
+ session.currentPostId = post.id;
645
+ // Register post for reaction routing
646
+ this.registerPost(post.id, session.threadId);
550
647
  }
551
648
  }
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);
649
+ async handleExit(threadId, code) {
650
+ const session = this.sessions.get(threadId);
651
+ if (!session)
652
+ return;
653
+ this.stopTyping(session);
654
+ if (session.updateTimer) {
655
+ clearTimeout(session.updateTimer);
656
+ session.updateTimer = null;
657
+ }
658
+ await this.flush(session);
659
+ if (code !== 0) {
660
+ await this.mattermost.createPost(`**[Exited: ${code}]**`, session.threadId);
661
+ }
662
+ // Clean up session from maps
663
+ this.sessions.delete(threadId);
664
+ // Clean up post index entries for this session
665
+ for (const [postId, tid] of this.postIndex.entries()) {
666
+ if (tid === threadId) {
667
+ this.postIndex.delete(postId);
668
+ }
561
669
  }
562
- this.session = null;
670
+ const shortId = threadId.substring(0, 8);
671
+ console.log(` ■ Session ended (${shortId}…) — ${this.sessions.size} active`);
563
672
  }
673
+ // ---------------------------------------------------------------------------
674
+ // Public Session API
675
+ // ---------------------------------------------------------------------------
676
+ /** Check if any sessions are active */
564
677
  isSessionActive() {
565
- return this.session !== null;
678
+ return this.sessions.size > 0;
566
679
  }
567
- isInCurrentSessionThread(threadRoot) {
568
- return this.session?.threadId === threadRoot;
680
+ /** Check if a session exists for this thread */
681
+ isInSessionThread(threadRoot) {
682
+ const session = this.sessions.get(threadRoot);
683
+ return session !== undefined && session.claude.isRunning();
569
684
  }
570
- async sendFollowUp(message) {
571
- if (!this.claude?.isRunning() || !this.session)
685
+ /** Send a follow-up message to an existing session */
686
+ async sendFollowUp(threadId, message) {
687
+ const session = this.sessions.get(threadId);
688
+ if (!session || !session.claude.isRunning())
572
689
  return;
573
- this.claude.sendMessage(message);
574
- this.startTyping();
690
+ session.claude.sendMessage(message);
691
+ session.lastActivityAt = new Date();
692
+ this.startTyping(session);
693
+ }
694
+ /** Kill a specific session */
695
+ killSession(threadId) {
696
+ const session = this.sessions.get(threadId);
697
+ if (!session)
698
+ return;
699
+ this.stopTyping(session);
700
+ session.claude.kill();
701
+ // Clean up session from maps
702
+ this.sessions.delete(threadId);
703
+ for (const [postId, tid] of this.postIndex.entries()) {
704
+ if (tid === threadId) {
705
+ this.postIndex.delete(postId);
706
+ }
707
+ }
708
+ const shortId = threadId.substring(0, 8);
709
+ console.log(` ✖ Session killed (${shortId}…) — ${this.sessions.size} active`);
710
+ }
711
+ /** Kill all active sessions (for graceful shutdown) */
712
+ killAllSessions() {
713
+ const count = this.sessions.size;
714
+ for (const [, session] of this.sessions.entries()) {
715
+ this.stopTyping(session);
716
+ session.claude.kill();
717
+ }
718
+ this.sessions.clear();
719
+ this.postIndex.clear();
720
+ if (this.cleanupTimer) {
721
+ clearInterval(this.cleanupTimer);
722
+ this.cleanupTimer = null;
723
+ }
724
+ if (count > 0) {
725
+ console.log(` ✖ Killed ${count} session${count === 1 ? '' : 's'}`);
726
+ }
575
727
  }
576
- killSession() {
577
- this.stopTyping();
578
- this.claude?.kill();
579
- this.claude = null;
580
- this.session = null;
728
+ /** Cleanup idle sessions that have exceeded timeout */
729
+ cleanupIdleSessions() {
730
+ const now = Date.now();
731
+ for (const [threadId, session] of this.sessions.entries()) {
732
+ const idleTime = now - session.lastActivityAt.getTime();
733
+ if (idleTime > SESSION_TIMEOUT_MS) {
734
+ const mins = Math.round(idleTime / 60000);
735
+ const shortId = threadId.substring(0, 8);
736
+ console.log(` ⏰ Session (${shortId}…) timed out after ${mins}m idle`);
737
+ this.mattermost.createPost(`⏰ **Session timed out** — no activity for ${mins} minutes`, session.threadId).catch(() => { });
738
+ this.killSession(threadId);
739
+ }
740
+ }
581
741
  }
582
742
  }
package/dist/config.js CHANGED
@@ -15,7 +15,9 @@ function loadEnv() {
15
15
  ];
16
16
  for (const envPath of envPaths) {
17
17
  if (existsSync(envPath)) {
18
- console.log(`📄 Loading config from: ${envPath}`);
18
+ if (process.env.DEBUG === '1' || process.argv.includes('--debug')) {
19
+ console.log(` [config] Loading from: ${envPath}`);
20
+ }
19
21
  config({ path: envPath });
20
22
  break;
21
23
  }
package/dist/index.js CHANGED
@@ -7,6 +7,9 @@ import { dirname, resolve } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
10
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
11
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
12
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
10
13
  async function main() {
11
14
  if (process.argv.includes('--version') || process.argv.includes('-v')) {
12
15
  console.log(pkg.version);
@@ -20,30 +23,35 @@ Usage: cd /your/project && mm-claude`);
20
23
  }
21
24
  const workingDir = process.cwd();
22
25
  const config = loadConfig();
23
- console.log(`🚀 mm-claude starting...`);
24
- console.log(`📂 ${workingDir}`);
25
- console.log(`📝 @${config.mattermost.botName} on ${config.mattermost.url}`);
26
- const mattermost = new MattermostClient(config);
27
- const session = new SessionManager(mattermost, workingDir, config.skipPermissions);
26
+ // Nice startup banner
27
+ console.log('');
28
+ console.log(bold(` 🤖 mm-claude v${pkg.version}`));
29
+ console.log(dim(' ─────────────────────────────────'));
30
+ console.log(` 📂 ${cyan(workingDir)}`);
31
+ console.log(` 💬 ${cyan('@' + config.mattermost.botName)}`);
32
+ console.log(` 🌐 ${dim(config.mattermost.url)}`);
28
33
  if (config.skipPermissions) {
29
- console.log('⚠️ Permissions skipped (--dangerously-skip-permissions)');
34
+ console.log(` ⚠️ ${dim('Permissions disabled')}`);
30
35
  }
31
36
  else {
32
- console.log('🔐 Interactive permissions enabled');
37
+ console.log(` 🔐 ${dim('Interactive permissions')}`);
33
38
  }
39
+ console.log('');
40
+ const mattermost = new MattermostClient(config);
41
+ const session = new SessionManager(mattermost, workingDir, config.skipPermissions);
34
42
  mattermost.on('message', async (post, user) => {
35
43
  const username = user?.username || 'unknown';
36
44
  const message = post.message;
37
45
  const threadRoot = post.root_id || post.id;
38
46
  // Follow-up in active thread
39
- if (session.isInCurrentSessionThread(threadRoot)) {
47
+ if (session.isInSessionThread(threadRoot)) {
40
48
  if (!mattermost.isUserAllowed(username))
41
49
  return;
42
50
  const content = mattermost.isBotMentioned(message)
43
51
  ? mattermost.extractPrompt(message)
44
52
  : message.trim();
45
53
  if (content)
46
- await session.sendFollowUp(content);
54
+ await session.sendFollowUp(threadRoot, content);
47
55
  return;
48
56
  }
49
57
  // New session requires @mention
@@ -60,13 +68,15 @@ Usage: cd /your/project && mm-claude`);
60
68
  }
61
69
  await session.startSession({ prompt }, username, threadRoot);
62
70
  });
63
- mattermost.on('connected', () => console.log('✅ Connected'));
64
- mattermost.on('error', (e) => console.error('❌', e));
71
+ mattermost.on('connected', () => { });
72
+ mattermost.on('error', (e) => console.error(' Error:', e));
65
73
  await mattermost.connect();
66
- console.log(`🎉 Ready! @${config.mattermost.botName}`);
74
+ console.log(` ✅ ${bold('Ready!')} Waiting for @${config.mattermost.botName} mentions...`);
75
+ console.log('');
67
76
  const shutdown = () => {
68
- console.log('\n👋 Bye');
69
- session.killSession();
77
+ console.log('');
78
+ console.log(` 👋 ${dim('Shutting down...')}`);
79
+ session.killAllSessions();
70
80
  mattermost.disconnect();
71
81
  process.exit(0);
72
82
  };
@@ -16,7 +16,9 @@ export declare class MattermostClient extends EventEmitter {
16
16
  private reconnectDelay;
17
17
  private userCache;
18
18
  private botUserId;
19
+ private debug;
19
20
  constructor(config: Config);
21
+ private log;
20
22
  private api;
21
23
  getBotUser(): Promise<MattermostUser>;
22
24
  getUser(userId: string): Promise<MattermostUser | null>;
@@ -8,10 +8,15 @@ export class MattermostClient extends EventEmitter {
8
8
  reconnectDelay = 1000;
9
9
  userCache = new Map();
10
10
  botUserId = null;
11
+ debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
11
12
  constructor(config) {
12
13
  super();
13
14
  this.config = config;
14
15
  }
16
+ log(msg) {
17
+ if (this.debug)
18
+ console.log(` [ws] ${msg}`);
19
+ }
15
20
  // REST API helper
16
21
  async api(method, path, body) {
17
22
  const url = `${this.config.mattermost.url}/api/v4${path}`;
@@ -78,14 +83,14 @@ export class MattermostClient extends EventEmitter {
78
83
  async connect() {
79
84
  // Get bot user first
80
85
  await this.getBotUser();
81
- console.log(`[MM] Bot user ID: ${this.botUserId}`);
86
+ this.log(`Bot user ID: ${this.botUserId}`);
82
87
  const wsUrl = this.config.mattermost.url
83
88
  .replace(/^http/, 'ws')
84
89
  .concat('/api/v4/websocket');
85
90
  return new Promise((resolve, reject) => {
86
91
  this.ws = new WebSocket(wsUrl);
87
92
  this.ws.on('open', () => {
88
- console.log('[MM] WebSocket connected');
93
+ this.log('WebSocket connected');
89
94
  // Authenticate
90
95
  this.ws.send(JSON.stringify({
91
96
  seq: 1,
@@ -105,16 +110,16 @@ export class MattermostClient extends EventEmitter {
105
110
  }
106
111
  }
107
112
  catch (err) {
108
- console.error('[MM] Failed to parse WebSocket message:', err);
113
+ this.log(`Failed to parse message: ${err}`);
109
114
  }
110
115
  });
111
116
  this.ws.on('close', () => {
112
- console.log('[MM] WebSocket disconnected');
117
+ this.log('WebSocket disconnected');
113
118
  this.emit('disconnected');
114
119
  this.scheduleReconnect();
115
120
  });
116
121
  this.ws.on('error', (err) => {
117
- console.error('[MM] WebSocket error:', err);
122
+ this.log(`WebSocket error: ${err}`);
118
123
  this.emit('error', err);
119
124
  reject(err);
120
125
  });
@@ -140,7 +145,7 @@ export class MattermostClient extends EventEmitter {
140
145
  });
141
146
  }
142
147
  catch (err) {
143
- console.error('[MM] Failed to parse post:', err);
148
+ this.log(`Failed to parse post: ${err}`);
144
149
  }
145
150
  return;
146
151
  }
@@ -160,21 +165,21 @@ export class MattermostClient extends EventEmitter {
160
165
  });
161
166
  }
162
167
  catch (err) {
163
- console.error('[MM] Failed to parse reaction:', err);
168
+ this.log(`Failed to parse reaction: ${err}`);
164
169
  }
165
170
  }
166
171
  }
167
172
  scheduleReconnect() {
168
173
  if (this.reconnectAttempts >= this.maxReconnectAttempts) {
169
- console.error('[MM] Max reconnection attempts reached');
174
+ console.error(' ⚠️ Max reconnection attempts reached');
170
175
  return;
171
176
  }
172
177
  this.reconnectAttempts++;
173
178
  const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
174
- console.log(`[MM] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
179
+ console.log(` 🔄 Reconnecting... (attempt ${this.reconnectAttempts})`);
175
180
  setTimeout(() => {
176
181
  this.connect().catch((err) => {
177
- console.error('[MM] Reconnection failed:', err);
182
+ console.error(` ❌ Reconnection failed: ${err}`);
178
183
  });
179
184
  }, delay);
180
185
  }
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.1",
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",