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 +12 -0
- package/dist/claude/session.js +50 -11
- package/dist/index.js +110 -97
- package/package.json +1 -1
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
|
package/dist/claude/session.js
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
|
-
|
|
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
|
-
//
|
|
62
|
-
|
|
63
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
//
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
const prompt = mattermost.extractPrompt(message);
|
|
166
180
|
const files = post.metadata?.files;
|
|
167
|
-
if (
|
|
168
|
-
await
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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));
|