mattermost-claude-code 0.9.1 โ†’ 0.9.2

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.2] - 2025-12-28
9
+
10
+ ### Fixed
11
+ - **Fix session persistence** - sessions were being incorrectly cleaned as "stale" on startup
12
+ - The `cleanStale()` call was removing sessions older than 30 minutes before attempting to resume
13
+ - Now sessions survive bot restarts regardless of how long the bot was down
14
+ - Added debug logging (`DEBUG=1`) to trace persistence operations
15
+ - **Fix crash on Mattermost API errors** - bot no longer crashes when posts fail
16
+ - Added try-catch around message handler to prevent unhandled exceptions
17
+ - Added try-catch around reaction handler
18
+ - Graceful error handling when session start post fails (e.g., deleted thread)
19
+
8
20
  ## [0.9.1] - 2025-12-28
9
21
 
10
22
  ### Changed
@@ -44,8 +44,13 @@ export class SessionManager {
44
44
  this.workingDir = workingDir;
45
45
  this.skipPermissions = skipPermissions;
46
46
  // Listen for reactions to answer questions
47
- this.mattermost.on('reaction', (reaction, user) => {
48
- this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
47
+ this.mattermost.on('reaction', async (reaction, user) => {
48
+ try {
49
+ await this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
50
+ }
51
+ catch (err) {
52
+ console.error(' โŒ Error handling reaction:', err);
53
+ }
49
54
  });
50
55
  // Start periodic cleanup of idle sessions
51
56
  this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
@@ -58,13 +63,22 @@ export class SessionManager {
58
63
  * Should be called before starting to listen for new messages.
59
64
  */
60
65
  async initialize() {
61
- // Clean up stale sessions first
62
- const staleIds = this.sessionStore.cleanStale(SESSION_TIMEOUT_MS);
63
- if (staleIds.length > 0) {
64
- console.log(` ๐Ÿงน Cleaned ${staleIds.length} stale session(s)`);
65
- }
66
- // Load persisted sessions
66
+ // Load persisted sessions FIRST (before cleaning stale ones)
67
+ // This way we can resume sessions that were active when the bot stopped,
68
+ // even if the bot was down for longer than SESSION_TIMEOUT_MS
67
69
  const persisted = this.sessionStore.load();
70
+ if (this.debug) {
71
+ console.log(` [persist] Found ${persisted.size} persisted session(s)`);
72
+ for (const [threadId, state] of persisted) {
73
+ const age = Date.now() - new Date(state.lastActivityAt).getTime();
74
+ const ageMins = Math.round(age / 60000);
75
+ console.log(` [persist] - ${threadId.substring(0, 8)}... by @${state.startedBy}, age: ${ageMins}m`);
76
+ }
77
+ }
78
+ // Note: We intentionally do NOT clean stale sessions on startup anymore.
79
+ // Sessions are cleaned during normal operation by cleanupIdleSessions().
80
+ // This allows sessions to survive bot restarts even if the bot was down
81
+ // for longer than SESSION_TIMEOUT_MS.
68
82
  if (persisted.size === 0) {
69
83
  if (this.debug)
70
84
  console.log(' [resume] No sessions to resume');
@@ -181,11 +195,19 @@ export class SessionManager {
181
195
  planApproved: session.planApproved,
182
196
  };
183
197
  this.sessionStore.save(session.threadId, state);
198
+ if (this.debug) {
199
+ const shortId = session.threadId.substring(0, 8);
200
+ console.log(` [persist] Saved session ${shortId}... (claudeId: ${session.claudeSessionId.substring(0, 8)}...)`);
201
+ }
184
202
  }
185
203
  /**
186
204
  * Remove a session from persistence
187
205
  */
188
206
  unpersistSession(threadId) {
207
+ if (this.debug) {
208
+ const shortId = threadId.substring(0, 8);
209
+ console.log(` [persist] Removing session ${shortId}...`);
210
+ }
189
211
  this.sessionStore.remove(threadId);
190
212
  }
191
213
  // ---------------------------------------------------------------------------
@@ -248,7 +270,15 @@ export class SessionManager {
248
270
  return;
249
271
  }
250
272
  // Post initial session message (will be updated by updateSessionHeader)
251
- const post = await this.mattermost.createPost(`### ๐Ÿค– mm-claude \`v${pkg.version}\`\n\n*Starting session...*`, replyToPostId);
273
+ let post;
274
+ try {
275
+ post = await this.mattermost.createPost(`### ๐Ÿค– mm-claude \`v${pkg.version}\`\n\n*Starting session...*`, replyToPostId);
276
+ }
277
+ catch (err) {
278
+ console.error(` โŒ Failed to create session post:`, err);
279
+ // If we can't post to the thread, we can't start a session
280
+ return;
281
+ }
252
282
  const actualThreadId = replyToPostId || post.id;
253
283
  // Generate a unique session ID for this Claude session
254
284
  const claudeSessionId = randomUUID();
@@ -916,17 +946,27 @@ export class SessionManager {
916
946
  }
917
947
  async handleExit(threadId, code) {
918
948
  const session = this.sessions.get(threadId);
919
- if (!session)
949
+ const shortId = threadId.substring(0, 8);
950
+ if (!session) {
951
+ if (this.debug)
952
+ console.log(` [exit] Session ${shortId}... not found (already cleaned up)`);
920
953
  return;
954
+ }
921
955
  // If we're intentionally restarting (e.g., !cd), don't clean up or post exit message
922
956
  if (session.isRestarting) {
957
+ if (this.debug)
958
+ console.log(` [exit] Session ${shortId}... restarting, skipping cleanup`);
923
959
  session.isRestarting = false; // Reset flag here, after the exit event fires
924
960
  return;
925
961
  }
926
962
  // If bot is shutting down, suppress exit messages (shutdown message already sent)
927
963
  if (this.isShuttingDown) {
964
+ if (this.debug)
965
+ console.log(` [exit] Session ${shortId}... bot shutting down, preserving persistence`);
928
966
  return;
929
967
  }
968
+ if (this.debug)
969
+ console.log(` [exit] Session ${shortId}... exited with code ${code}, cleaning up`);
930
970
  this.stopTyping(session);
931
971
  if (session.updateTimer) {
932
972
  clearTimeout(session.updateTimer);
@@ -946,7 +986,6 @@ export class SessionManager {
946
986
  }
947
987
  // Remove from persistence when session ends normally
948
988
  this.unpersistSession(threadId);
949
- const shortId = threadId.substring(0, 8);
950
989
  console.log(` โ–  Session ended (${shortId}โ€ฆ) โ€” ${this.sessions.size} active`);
951
990
  }
952
991
  // ---------------------------------------------------------------------------
package/dist/index.js CHANGED
@@ -72,116 +72,129 @@ async function main() {
72
72
  const mattermost = new MattermostClient(config);
73
73
  const session = new SessionManager(mattermost, workingDir, config.skipPermissions);
74
74
  mattermost.on('message', async (post, user) => {
75
- const username = user?.username || 'unknown';
76
- const message = post.message;
77
- const threadRoot = post.root_id || post.id;
78
- // Follow-up in active thread
79
- if (session.isInSessionThread(threadRoot)) {
80
- // If message starts with @mention to someone else, ignore it (side conversation)
81
- // Note: Mattermost usernames can contain letters, numbers, hyphens, periods, and underscores
82
- const mentionMatch = message.trim().match(/^@([\w.-]+)/);
83
- if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
84
- return; // Side conversation, don't interrupt
85
- }
86
- const content = mattermost.isBotMentioned(message)
87
- ? mattermost.extractPrompt(message)
88
- : message.trim();
89
- const lowerContent = content.toLowerCase();
90
- // Check for stop/cancel commands (only from allowed users)
91
- // Note: Using ! prefix instead of / to avoid Mattermost slash command interception
92
- if (lowerContent === '!stop' || lowerContent === 'stop' ||
93
- lowerContent === '!cancel' || lowerContent === 'cancel') {
94
- if (session.isUserAllowedInSession(threadRoot, username)) {
95
- await session.cancelSession(threadRoot, username);
75
+ try {
76
+ const username = user?.username || 'unknown';
77
+ const message = post.message;
78
+ const threadRoot = post.root_id || post.id;
79
+ // Follow-up in active thread
80
+ if (session.isInSessionThread(threadRoot)) {
81
+ // If message starts with @mention to someone else, ignore it (side conversation)
82
+ // Note: Mattermost usernames can contain letters, numbers, hyphens, periods, and underscores
83
+ const mentionMatch = message.trim().match(/^@([\w.-]+)/);
84
+ if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
85
+ return; // Side conversation, don't interrupt
96
86
  }
97
- return;
98
- }
99
- // Check for !help command
100
- if (lowerContent === '!help' || lowerContent === 'help') {
101
- await mattermost.createPost(`**Available commands:**\n\n` +
102
- `| Command | Description |\n` +
103
- `|:--------|:------------|\n` +
104
- `| \`!help\` | Show this help message |\n` +
105
- `| \`!release-notes\` | Show release notes for current version |\n` +
106
- `| \`!cd <path>\` | Change working directory (restarts Claude) |\n` +
107
- `| \`!invite @user\` | Invite a user to this session |\n` +
108
- `| \`!kick @user\` | Remove an invited user |\n` +
109
- `| \`!permissions interactive\` | Enable interactive permissions |\n` +
110
- `| \`!stop\` | Stop this session |\n\n` +
111
- `**Reactions:**\n` +
112
- `- ๐Ÿ‘ Approve action ยท โœ… Approve all ยท ๐Ÿ‘Ž Deny\n` +
113
- `- โŒ or ๐Ÿ›‘ on any message to stop session`, threadRoot);
114
- return;
115
- }
116
- // Check for !release-notes command
117
- if (lowerContent === '!release-notes' || lowerContent === '!changelog') {
118
- const notes = getReleaseNotes(pkg.version);
119
- if (notes) {
120
- await mattermost.createPost(formatReleaseNotes(notes), threadRoot);
87
+ const content = mattermost.isBotMentioned(message)
88
+ ? mattermost.extractPrompt(message)
89
+ : message.trim();
90
+ const lowerContent = content.toLowerCase();
91
+ // Check for stop/cancel commands (only from allowed users)
92
+ // Note: Using ! prefix instead of / to avoid Mattermost slash command interception
93
+ if (lowerContent === '!stop' || lowerContent === 'stop' ||
94
+ lowerContent === '!cancel' || lowerContent === 'cancel') {
95
+ if (session.isUserAllowedInSession(threadRoot, username)) {
96
+ await session.cancelSession(threadRoot, username);
97
+ }
98
+ return;
121
99
  }
122
- else {
123
- await mattermost.createPost(`๐Ÿ“‹ **mm-claude v${pkg.version}**\n\nRelease notes not available. See [GitHub releases](https://github.com/anneschuth/mattermost-claude-code/releases).`, threadRoot);
100
+ // Check for !help command
101
+ if (lowerContent === '!help' || lowerContent === 'help') {
102
+ await mattermost.createPost(`**Available commands:**\n\n` +
103
+ `| Command | Description |\n` +
104
+ `|:--------|:------------|\n` +
105
+ `| \`!help\` | Show this help message |\n` +
106
+ `| \`!release-notes\` | Show release notes for current version |\n` +
107
+ `| \`!cd <path>\` | Change working directory (restarts Claude) |\n` +
108
+ `| \`!invite @user\` | Invite a user to this session |\n` +
109
+ `| \`!kick @user\` | Remove an invited user |\n` +
110
+ `| \`!permissions interactive\` | Enable interactive permissions |\n` +
111
+ `| \`!stop\` | Stop this session |\n\n` +
112
+ `**Reactions:**\n` +
113
+ `- ๐Ÿ‘ Approve action ยท โœ… Approve all ยท ๐Ÿ‘Ž Deny\n` +
114
+ `- โŒ or ๐Ÿ›‘ on any message to stop session`, threadRoot);
115
+ return;
124
116
  }
125
- return;
126
- }
127
- // Check for !invite command
128
- const inviteMatch = content.match(/^!invite\s+@?([\w.-]+)/i);
129
- if (inviteMatch) {
130
- await session.inviteUser(threadRoot, inviteMatch[1], username);
131
- return;
132
- }
133
- // Check for !kick command
134
- const kickMatch = content.match(/^!kick\s+@?([\w.-]+)/i);
135
- if (kickMatch) {
136
- await session.kickUser(threadRoot, kickMatch[1], username);
137
- return;
138
- }
139
- // Check for !permissions command
140
- const permMatch = content.match(/^!permissions?\s+(interactive|auto)/i);
141
- if (permMatch) {
142
- const mode = permMatch[1].toLowerCase();
143
- if (mode === 'interactive') {
144
- await session.enableInteractivePermissions(threadRoot, username);
117
+ // Check for !release-notes command
118
+ if (lowerContent === '!release-notes' || lowerContent === '!changelog') {
119
+ const notes = getReleaseNotes(pkg.version);
120
+ if (notes) {
121
+ await mattermost.createPost(formatReleaseNotes(notes), threadRoot);
122
+ }
123
+ else {
124
+ await mattermost.createPost(`๐Ÿ“‹ **mm-claude v${pkg.version}**\n\nRelease notes not available. See [GitHub releases](https://github.com/anneschuth/mattermost-claude-code/releases).`, threadRoot);
125
+ }
126
+ return;
127
+ }
128
+ // Check for !invite command
129
+ const inviteMatch = content.match(/^!invite\s+@?([\w.-]+)/i);
130
+ if (inviteMatch) {
131
+ await session.inviteUser(threadRoot, inviteMatch[1], username);
132
+ return;
133
+ }
134
+ // Check for !kick command
135
+ const kickMatch = content.match(/^!kick\s+@?([\w.-]+)/i);
136
+ if (kickMatch) {
137
+ await session.kickUser(threadRoot, kickMatch[1], username);
138
+ return;
145
139
  }
146
- else {
147
- // Can't upgrade to auto - that would be less secure
148
- await mattermost.createPost(`โš ๏ธ Cannot upgrade to auto permissions - can only downgrade to interactive`, threadRoot);
140
+ // Check for !permissions command
141
+ const permMatch = content.match(/^!permissions?\s+(interactive|auto)/i);
142
+ if (permMatch) {
143
+ const mode = permMatch[1].toLowerCase();
144
+ if (mode === 'interactive') {
145
+ await session.enableInteractivePermissions(threadRoot, username);
146
+ }
147
+ else {
148
+ // Can't upgrade to auto - that would be less secure
149
+ await mattermost.createPost(`โš ๏ธ Cannot upgrade to auto permissions - can only downgrade to interactive`, threadRoot);
150
+ }
151
+ return;
149
152
  }
153
+ // Check for !cd command
154
+ const cdMatch = content.match(/^!cd\s+(.+)/i);
155
+ if (cdMatch) {
156
+ await session.changeDirectory(threadRoot, cdMatch[1].trim(), username);
157
+ return;
158
+ }
159
+ // Check if user is allowed in this session
160
+ if (!session.isUserAllowedInSession(threadRoot, username)) {
161
+ // Request approval for their message
162
+ if (content)
163
+ await session.requestMessageApproval(threadRoot, username, content);
164
+ return;
165
+ }
166
+ // Get any attached files (images)
167
+ const files = post.metadata?.files;
168
+ if (content || files?.length)
169
+ await session.sendFollowUp(threadRoot, content, files);
150
170
  return;
151
171
  }
152
- // Check for !cd command
153
- const cdMatch = content.match(/^!cd\s+(.+)/i);
154
- if (cdMatch) {
155
- await session.changeDirectory(threadRoot, cdMatch[1].trim(), username);
172
+ // New session requires @mention
173
+ if (!mattermost.isBotMentioned(message))
156
174
  return;
157
- }
158
- // Check if user is allowed in this session
159
- if (!session.isUserAllowedInSession(threadRoot, username)) {
160
- // Request approval for their message
161
- if (content)
162
- await session.requestMessageApproval(threadRoot, username, content);
175
+ if (!mattermost.isUserAllowed(username)) {
176
+ await mattermost.createPost(`โš ๏ธ @${username} is not authorized`, threadRoot);
163
177
  return;
164
178
  }
165
- // Get any attached files (images)
179
+ const prompt = mattermost.extractPrompt(message);
166
180
  const files = post.metadata?.files;
167
- if (content || files?.length)
168
- await session.sendFollowUp(threadRoot, content, files);
169
- return;
170
- }
171
- // New session requires @mention
172
- if (!mattermost.isBotMentioned(message))
173
- return;
174
- if (!mattermost.isUserAllowed(username)) {
175
- await mattermost.createPost(`โš ๏ธ @${username} is not authorized`, threadRoot);
176
- return;
181
+ if (!prompt && !files?.length) {
182
+ await mattermost.createPost(`Mention me with your request`, threadRoot);
183
+ return;
184
+ }
185
+ await session.startSession({ prompt, files }, username, threadRoot);
177
186
  }
178
- const prompt = mattermost.extractPrompt(message);
179
- const files = post.metadata?.files;
180
- if (!prompt && !files?.length) {
181
- await mattermost.createPost(`Mention me with your request`, threadRoot);
182
- return;
187
+ catch (err) {
188
+ console.error(' โŒ Error handling message:', err);
189
+ // Try to notify user if possible
190
+ try {
191
+ const threadRoot = post.root_id || post.id;
192
+ await mattermost.createPost(`โš ๏ธ An error occurred. Please try again.`, threadRoot);
193
+ }
194
+ catch {
195
+ // Ignore if we can't post the error message
196
+ }
183
197
  }
184
- await session.startSession({ prompt, files }, username, threadRoot);
185
198
  });
186
199
  mattermost.on('connected', () => { });
187
200
  mattermost.on('error', (e) => console.error(' โŒ Error:', e));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mattermost-claude-code",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
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",