mattermost-claude-code 0.9.0 โ†’ 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,28 @@ 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
+
20
+ ## [0.9.1] - 2025-12-28
21
+
22
+ ### Changed
23
+ - Resume message now shows version: "Session resumed after bot restart (v0.9.1)"
24
+ - Session header is updated with new version after resume
25
+
26
+ ### Fixed
27
+ - Fix duplicate "Bot shutting down" messages when stopping bot
28
+ - Fix "[Exited: null]" message appearing during graceful shutdown
29
+
8
30
  ## [0.9.0] - 2025-12-28
9
31
 
10
32
  ### Added
@@ -67,6 +67,7 @@ export declare class SessionManager {
67
67
  private postIndex;
68
68
  private sessionStore;
69
69
  private cleanupTimer;
70
+ private isShuttingDown;
70
71
  constructor(mattermost: MattermostClient, workingDir: string, skipPermissions?: boolean);
71
72
  /**
72
73
  * Initialize session manager by resuming any persisted sessions.
@@ -37,13 +37,20 @@ export class SessionManager {
37
37
  sessionStore = new SessionStore();
38
38
  // Cleanup timer
39
39
  cleanupTimer = null;
40
+ // Shutdown flag to suppress exit messages during graceful shutdown
41
+ isShuttingDown = false;
40
42
  constructor(mattermost, workingDir, skipPermissions = false) {
41
43
  this.mattermost = mattermost;
42
44
  this.workingDir = workingDir;
43
45
  this.skipPermissions = skipPermissions;
44
46
  // Listen for reactions to answer questions
45
- this.mattermost.on('reaction', (reaction, user) => {
46
- 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
+ }
47
54
  });
48
55
  // Start periodic cleanup of idle sessions
49
56
  this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
@@ -56,13 +63,22 @@ export class SessionManager {
56
63
  * Should be called before starting to listen for new messages.
57
64
  */
58
65
  async initialize() {
59
- // Clean up stale sessions first
60
- const staleIds = this.sessionStore.cleanStale(SESSION_TIMEOUT_MS);
61
- if (staleIds.length > 0) {
62
- console.log(` ๐Ÿงน Cleaned ${staleIds.length} stale session(s)`);
63
- }
64
- // 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
65
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.
66
82
  if (persisted.size === 0) {
67
83
  if (this.debug)
68
84
  console.log(' [resume] No sessions to resume');
@@ -141,7 +157,7 @@ export class SessionManager {
141
157
  claude.start();
142
158
  console.log(` ๐Ÿ”„ Resumed session ${shortId}... (@${state.startedBy})`);
143
159
  // Post resume message
144
- await this.mattermost.createPost(`๐Ÿ”„ **Session resumed** after bot restart\n*Reconnected to Claude session. You can continue where you left off.*`, state.threadId);
160
+ await this.mattermost.createPost(`๐Ÿ”„ **Session resumed** after bot restart (v${pkg.version})\n*Reconnected to Claude session. You can continue where you left off.*`, state.threadId);
145
161
  // Update session header
146
162
  await this.updateSessionHeader(session);
147
163
  // Update persistence with new activity time
@@ -179,11 +195,19 @@ export class SessionManager {
179
195
  planApproved: session.planApproved,
180
196
  };
181
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
+ }
182
202
  }
183
203
  /**
184
204
  * Remove a session from persistence
185
205
  */
186
206
  unpersistSession(threadId) {
207
+ if (this.debug) {
208
+ const shortId = threadId.substring(0, 8);
209
+ console.log(` [persist] Removing session ${shortId}...`);
210
+ }
187
211
  this.sessionStore.remove(threadId);
188
212
  }
189
213
  // ---------------------------------------------------------------------------
@@ -246,7 +270,15 @@ export class SessionManager {
246
270
  return;
247
271
  }
248
272
  // Post initial session message (will be updated by updateSessionHeader)
249
- 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
+ }
250
282
  const actualThreadId = replyToPostId || post.id;
251
283
  // Generate a unique session ID for this Claude session
252
284
  const claudeSessionId = randomUUID();
@@ -914,20 +946,34 @@ export class SessionManager {
914
946
  }
915
947
  async handleExit(threadId, code) {
916
948
  const session = this.sessions.get(threadId);
917
- 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)`);
918
953
  return;
954
+ }
919
955
  // If we're intentionally restarting (e.g., !cd), don't clean up or post exit message
920
956
  if (session.isRestarting) {
957
+ if (this.debug)
958
+ console.log(` [exit] Session ${shortId}... restarting, skipping cleanup`);
921
959
  session.isRestarting = false; // Reset flag here, after the exit event fires
922
960
  return;
923
961
  }
962
+ // If bot is shutting down, suppress exit messages (shutdown message already sent)
963
+ if (this.isShuttingDown) {
964
+ if (this.debug)
965
+ console.log(` [exit] Session ${shortId}... bot shutting down, preserving persistence`);
966
+ return;
967
+ }
968
+ if (this.debug)
969
+ console.log(` [exit] Session ${shortId}... exited with code ${code}, cleaning up`);
924
970
  this.stopTyping(session);
925
971
  if (session.updateTimer) {
926
972
  clearTimeout(session.updateTimer);
927
973
  session.updateTimer = null;
928
974
  }
929
975
  await this.flush(session);
930
- if (code !== 0) {
976
+ if (code !== 0 && code !== null) {
931
977
  await this.mattermost.createPost(`**[Exited: ${code}]**`, session.threadId);
932
978
  }
933
979
  // Clean up session from maps
@@ -940,7 +986,6 @@ export class SessionManager {
940
986
  }
941
987
  // Remove from persistence when session ends normally
942
988
  this.unpersistSession(threadId);
943
- const shortId = threadId.substring(0, 8);
944
989
  console.log(` โ–  Session ended (${shortId}โ€ฆ) โ€” ${this.sessions.size} active`);
945
990
  }
946
991
  // ---------------------------------------------------------------------------
@@ -1229,6 +1274,8 @@ export class SessionManager {
1229
1274
  }
1230
1275
  /** Kill all active sessions (for graceful shutdown) */
1231
1276
  killAllSessions() {
1277
+ // Set shutdown flag to suppress exit messages
1278
+ this.isShuttingDown = true;
1232
1279
  const count = this.sessions.size;
1233
1280
  for (const [, session] of this.sessions.entries()) {
1234
1281
  this.stopTyping(session);
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));
@@ -190,7 +203,12 @@ async function main() {
190
203
  await session.initialize();
191
204
  console.log(` โœ… ${bold('Ready!')} Waiting for @${config.mattermost.botName} mentions...`);
192
205
  console.log('');
206
+ let isShuttingDown = false;
193
207
  const shutdown = async () => {
208
+ // Guard against multiple shutdown calls (SIGINT + SIGTERM)
209
+ if (isShuttingDown)
210
+ return;
211
+ isShuttingDown = true;
194
212
  console.log('');
195
213
  console.log(` ๐Ÿ‘‹ ${dim('Shutting down...')}`);
196
214
  // Post shutdown message to active sessions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mattermost-claude-code",
3
- "version": "0.9.0",
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",