mattermost-claude-code 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,256 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.9.0] - 2025-12-28
9
+
10
+ ### Added
11
+ - **Session persistence** - Sessions now survive bot restarts!
12
+ - Active sessions are saved to `~/.config/mm-claude/sessions.json`
13
+ - On bot restart, sessions are automatically resumed using Claude's `--resume` flag
14
+ - Users see "Bot shutting down - session will resume" when bot stops
15
+ - Users see "Session resumed after bot restart" when session resumes
16
+ - Session state (participants, working dir, permissions) is preserved
17
+ - Stale sessions (older than SESSION_TIMEOUT_MS) are cleaned up on startup
18
+ - Thread existence is verified before resuming (deleted threads are skipped)
19
+
20
+ ### Fixed
21
+ - Truncate messages longer than 16K chars to avoid Mattermost API errors
22
+
23
+ ## [0.8.1] - 2025-12-28
24
+
25
+ ### Added
26
+ - **`!release-notes` command** - Show release notes for the current version
27
+ - **"What's new" in session header** - Shows a brief summary of new features when starting a session
28
+
29
+ ## [0.8.0] - 2025-12-28
30
+
31
+ ### Added
32
+ - **Image attachment support** - Attach images to your messages and Claude Code will analyze them
33
+ - Supports JPEG, PNG, GIF, and WebP formats
34
+ - Images are downloaded from Mattermost and sent to Claude as base64-encoded content blocks
35
+ - Works for both new sessions and follow-up messages
36
+ - Debug logging shows attached image details (name, type, size)
37
+
38
+ ## [0.7.3] - 2025-12-28
39
+
40
+ ### Fixed
41
+ - Actually fix `!cd` showing "[Exited: null]" - reset flag in async exit handler, not synchronously
42
+
43
+ ## [0.7.2] - 2025-12-28
44
+
45
+ ### Fixed
46
+ - Fix `!cd` command showing "[Exited: null]" message - now properly suppresses exit message during intentional restart
47
+
48
+ ## [0.7.1] - 2025-12-28
49
+
50
+ ### Fixed
51
+ - Fix infinite loop when plan is approved - no longer sends "Continue" message on subsequent ExitPlanMode calls
52
+
53
+ ## [0.7.0] - 2025-12-28
54
+
55
+ ### Added
56
+ - **`!cd <path>` command** - Change working directory mid-session
57
+ - Restarts Claude Code in the new directory with fresh context
58
+ - Session header updates to show current working directory
59
+ - Validates directory exists before switching
60
+
61
+ ## [0.6.1] - 2025-12-28
62
+
63
+ ### Changed
64
+ - Cleaner console output: removed verbose `[Session]` prefixes from logs
65
+ - Debug-only logging for internal session state changes (plan approval, question handling)
66
+ - Consistent emoji formatting for all log messages
67
+
68
+ ## [0.6.0] - 2025-12-28
69
+
70
+ ### Added
71
+ - **Auto-update notifications** - shows banner in session header when new version is available
72
+ - Checks npm registry on startup for latest version
73
+ - Update notice includes install command: `npm install -g mattermost-claude-code`
74
+
75
+ ## [0.5.9] - 2025-12-28
76
+
77
+ ### Fixed
78
+ - Security fix: sanitize bot username in regex to prevent injection
79
+
80
+ ## [0.5.8] - 2025-12-28
81
+
82
+ ### Changed
83
+ - Commands now use `!` prefix instead of `/` to avoid Mattermost slash command conflicts
84
+ - `!help`, `!invite`, `!kick`, `!permissions`, `!stop` replace `/` versions
85
+ - Commands without prefix (`help`, `stop`, `cancel`) still work
86
+
87
+ ## [0.5.7] - 2025-12-28
88
+
89
+ ### Fixed
90
+ - Bot now recognizes mentions with hyphens in username (e.g., `@annes-minion`)
91
+ - Side conversation detection regex updated to handle full Mattermost usernames
92
+
93
+ ## [0.5.6] - 2025-12-28
94
+
95
+ ### Added
96
+ - Timeout warning 5 minutes before session expires
97
+ - Warning message tells user to send a message to keep session alive
98
+ - Warning resets if activity resumes
99
+
100
+ ## [0.5.5] - 2025-12-28
101
+
102
+ ### Added
103
+ - `/help` command to show available session commands
104
+
105
+ ### Changed
106
+ - Replace ASCII diagram with Mermaid flowchart in README
107
+
108
+ ## [0.5.4] - 2025-12-28 (not released)
109
+
110
+ ### Added
111
+ - `/help` command to show available session commands
112
+
113
+ ## [0.5.3] - 2025-12-28
114
+
115
+ ### Added
116
+ - `/permissions interactive` command to enable interactive permissions for a session
117
+ - Can only downgrade permissions (auto → interactive), not upgrade
118
+ - Session header updates to show current permission mode
119
+
120
+ ## [0.5.2] - 2025-12-28
121
+
122
+ ### Changed
123
+ - Complete README rewrite with full documentation of all features
124
+
125
+ ## [0.5.1] - 2025-12-28
126
+
127
+ ### Added
128
+ - `--no-skip-permissions` flag to enable interactive permissions even when `SKIP_PERMISSIONS=true` is set in env
129
+
130
+ ## [0.5.0] - 2025-12-28
131
+
132
+ ### Added
133
+ - **Session collaboration** - invite users to specific sessions without global access
134
+ - **`/invite @username`** - Temporarily allow a user to participate in the current session
135
+ - **`/kick @username`** - Remove an invited user from the current session
136
+ - **Message approval flow** - When unauthorized users send messages in a session thread, the session owner/allowed users can approve via reactions:
137
+ - 👍 Allow this single message
138
+ - ✅ Invite them to the session
139
+ - 👎 Deny the message
140
+ - Per-session allowlist tracked via `sessionAllowedUsers` in each session
141
+ - **Side conversation support** - Messages starting with `@someone-else` are ignored, allowing users to chat without triggering the bot
142
+ - **Dynamic session header** - The session start message updates to show current participants when users are invited or kicked
143
+
144
+ ### Changed
145
+ - Session owner is automatically added to session allowlist
146
+ - Authorization checks now use `isUserAllowedInSession()` for follow-ups
147
+ - Globally allowed users can still access all sessions
148
+
149
+ ## [0.4.0] - 2025-12-28
150
+
151
+ ### Added
152
+ - **CLI arguments** to override all config options (`--url`, `--token`, `--channel`, etc.)
153
+ - **Interactive onboarding** when no `.env` file exists - guided setup with help text
154
+ - Full `--help` output with all available options
155
+ - `--debug` flag to enable verbose logging
156
+
157
+ ### Changed
158
+ - Switched from manual arg parsing to `commander` for better CLI experience
159
+ - Config now supports: CLI args > environment variables > defaults
160
+
161
+ ## [0.3.4] - 2025-12-27
162
+
163
+ ### Added
164
+ - Cancel sessions with `/stop`, `/cancel`, `stop`, or `cancel` commands in thread
165
+ - Cancel sessions by reacting with ❌ or 🛑 to any post in the thread
166
+
167
+ ## [0.3.3] - 2025-12-27
168
+
169
+ ### Added
170
+ - WebSocket heartbeat to detect dead connections after laptop sleep/idle
171
+ - Automatic reconnection when connection goes silent for 60+ seconds
172
+ - Ping every 30 seconds to keep connection alive
173
+
174
+ ### Fixed
175
+ - Connections no longer go "zombie" after laptop sleep - mm-claude now detects and reconnects
176
+
177
+ ## [0.3.2] - 2025-12-27
178
+
179
+ ### Fixed
180
+ - Session card now correctly shows "mm-claude" instead of "Claude Code"
181
+
182
+ ## [0.3.1] - 2025-12-27
183
+
184
+ ### Changed
185
+ - Cleaner console output with colors (verbose logs only shown with `DEBUG=1`)
186
+ - Pimped session start card in Mattermost with version, directory, user, session count, permissions mode, and prompt preview
187
+ - Typing indicator starts immediately when session begins
188
+ - Shortened thread IDs in logs for readability
189
+
190
+ ## [0.3.0] - 2025-12-27
191
+
192
+ ### Added
193
+ - **Multiple concurrent sessions** - each Mattermost thread gets its own Claude CLI process
194
+ - Sessions tracked via `sessions: Map<threadId, Session>` and `postIndex: Map<postId, threadId>`
195
+ - Configurable session limits via `MAX_SESSIONS` env var (default: 5)
196
+ - Automatic idle session cleanup via `SESSION_TIMEOUT_MS` env var (default: 30 min)
197
+ - `killAllSessions()` for graceful shutdown of all sessions
198
+ - Session count logging for monitoring
199
+
200
+ ### Changed
201
+ - `SessionManager` now manages multiple sessions instead of single session
202
+ - `sendFollowUp(threadId, message)` takes threadId parameter
203
+ - `isInSessionThread(threadId)` replaces `isInCurrentSessionThread()`
204
+ - `killSession(threadId)` takes threadId parameter
205
+
206
+ ### Fixed
207
+ - Reaction routing now uses post index lookup for correct session targeting
208
+
209
+ ## [0.2.3] - 2025-12-27
210
+
211
+ ### Added
212
+ - GitHub Actions workflow for automated npm publishing on release
213
+
214
+ ## [0.2.2] - 2025-12-27
215
+
216
+ ### Added
217
+ - Comprehensive `CLAUDE.md` with project documentation for AI assistants
218
+
219
+ ## [0.2.1] - 2025-12-27
220
+
221
+ ### Added
222
+ - `--version` / `-v` flag to display version
223
+ - Version number shown in `--help` output
224
+
225
+ ### Changed
226
+ - Lazy config loading (no .env file needed for --version/--help)
227
+
228
+ ## [0.2.0] - 2025-12-27
229
+
230
+ ### Added
231
+ - Interactive permission approval via Mattermost reactions
232
+ - Permission prompts forwarded to Mattermost thread
233
+ - React with 👍 to allow, ✅ to allow all, or 👎 to deny
234
+ - Only authorized users (ALLOWED_USERS) can approve permissions
235
+ - MCP-based permission server using Claude Code's `--permission-prompt-tool`
236
+ - `SKIP_PERMISSIONS` env var to control permission behavior
237
+
238
+ ### Changed
239
+ - Permissions are now interactive by default (previously skipped)
240
+ - Use `SKIP_PERMISSIONS=true` or `--dangerously-skip-permissions` to skip
241
+
242
+ ## [0.1.0] - 2024-12-27
243
+
244
+ ### Added
245
+ - Initial release
246
+ - Connect Claude Code CLI to Mattermost channels
247
+ - Real-time streaming of Claude responses
248
+ - Interactive plan approval with emoji reactions
249
+ - Sequential question flow with emoji answers
250
+ - Task list display with live updates
251
+ - Code diffs for Edit operations
252
+ - Content preview for Write operations
253
+ - Subagent status tracking
254
+ - Typing indicator while Claude is processing
255
+ - User allowlist for access control
256
+ - Bot mention detection for triggering sessions
package/README.md CHANGED
@@ -116,6 +116,8 @@ Type `!help` in any session thread to see available commands:
116
116
  | Command | Description |
117
117
  |:--------|:------------|
118
118
  | `!help` | Show available commands |
119
+ | `!release-notes` | Show release notes for current version |
120
+ | `!cd <path>` | Change working directory (restarts Claude) |
119
121
  | `!invite @user` | Invite a user to this session |
120
122
  | `!kick @user` | Remove an invited user |
121
123
  | `!permissions interactive` | Enable interactive permissions |
@@ -0,0 +1,20 @@
1
+ interface ReleaseNotes {
2
+ version: string;
3
+ date: string;
4
+ sections: {
5
+ [key: string]: string[];
6
+ };
7
+ }
8
+ /**
9
+ * Parse CHANGELOG.md and extract release notes for a specific version.
10
+ */
11
+ export declare function getReleaseNotes(version?: string): ReleaseNotes | null;
12
+ /**
13
+ * Format release notes as a Mattermost message.
14
+ */
15
+ export declare function formatReleaseNotes(notes: ReleaseNotes): string;
16
+ /**
17
+ * Get a short summary of what's new (for session header).
18
+ */
19
+ export declare function getWhatsNewSummary(notes: ReleaseNotes): string;
20
+ export {};
@@ -0,0 +1,134 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { dirname, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ /**
6
+ * Parse CHANGELOG.md and extract release notes for a specific version.
7
+ */
8
+ export function getReleaseNotes(version) {
9
+ // Try to find CHANGELOG.md in various locations
10
+ const possiblePaths = [
11
+ resolve(__dirname, '..', 'CHANGELOG.md'), // dist/../CHANGELOG.md (installed)
12
+ resolve(__dirname, '..', '..', 'CHANGELOG.md'), // src/../CHANGELOG.md (dev)
13
+ ];
14
+ let changelogPath = null;
15
+ for (const p of possiblePaths) {
16
+ if (existsSync(p)) {
17
+ changelogPath = p;
18
+ break;
19
+ }
20
+ }
21
+ if (!changelogPath) {
22
+ return null;
23
+ }
24
+ try {
25
+ const content = readFileSync(changelogPath, 'utf-8');
26
+ return parseChangelog(content, version);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /**
33
+ * Parse changelog content and extract notes for a version.
34
+ * If no version specified, returns the latest (first) version.
35
+ */
36
+ function parseChangelog(content, targetVersion) {
37
+ const lines = content.split('\n');
38
+ let currentVersion = null;
39
+ let currentDate = null;
40
+ let currentSection = null;
41
+ let sections = {};
42
+ let foundTarget = false;
43
+ for (const line of lines) {
44
+ // Match version header: ## [0.8.0] - 2025-12-28
45
+ const versionMatch = line.match(/^## \[(\d+\.\d+\.\d+)\](?: - (\d{4}-\d{2}-\d{2}))?/);
46
+ if (versionMatch) {
47
+ // If we already found our target, we're done
48
+ if (foundTarget) {
49
+ break;
50
+ }
51
+ currentVersion = versionMatch[1];
52
+ currentDate = versionMatch[2] || '';
53
+ sections = {};
54
+ currentSection = null;
55
+ // Check if this is the version we want
56
+ if (!targetVersion || currentVersion === targetVersion) {
57
+ foundTarget = true;
58
+ }
59
+ continue;
60
+ }
61
+ // Only process if we're in the target version
62
+ if (!foundTarget)
63
+ continue;
64
+ // Match section header: ### Added, ### Fixed, ### Changed
65
+ const sectionMatch = line.match(/^### (\w+)/);
66
+ if (sectionMatch) {
67
+ currentSection = sectionMatch[1];
68
+ sections[currentSection] = [];
69
+ continue;
70
+ }
71
+ // Match list item: - Item text
72
+ const itemMatch = line.match(/^- (.+)/);
73
+ if (itemMatch && currentSection) {
74
+ sections[currentSection].push(itemMatch[1]);
75
+ }
76
+ }
77
+ if (!foundTarget || !currentVersion) {
78
+ return null;
79
+ }
80
+ return {
81
+ version: currentVersion,
82
+ date: currentDate || '',
83
+ sections,
84
+ };
85
+ }
86
+ /**
87
+ * Format release notes as a Mattermost message.
88
+ */
89
+ export function formatReleaseNotes(notes) {
90
+ let msg = `### 📋 Release Notes - v${notes.version}`;
91
+ if (notes.date) {
92
+ msg += ` (${notes.date})`;
93
+ }
94
+ msg += '\n\n';
95
+ for (const [section, items] of Object.entries(notes.sections)) {
96
+ if (items.length === 0)
97
+ continue;
98
+ const emoji = section === 'Added' ? '✨' :
99
+ section === 'Fixed' ? '🐛' :
100
+ section === 'Changed' ? '🔄' :
101
+ section === 'Removed' ? '🗑️' : '•';
102
+ msg += `**${emoji} ${section}**\n`;
103
+ for (const item of items) {
104
+ msg += `- ${item}\n`;
105
+ }
106
+ msg += '\n';
107
+ }
108
+ return msg.trim();
109
+ }
110
+ /**
111
+ * Get a short summary of what's new (for session header).
112
+ */
113
+ export function getWhatsNewSummary(notes) {
114
+ const items = [];
115
+ // Prioritize: Added > Fixed > Changed
116
+ for (const section of ['Added', 'Fixed', 'Changed']) {
117
+ const sectionItems = notes.sections[section] || [];
118
+ for (const item of sectionItems) {
119
+ // Extract just the first part (before any dash or detail)
120
+ const short = item.split(' - ')[0].replace(/\*\*/g, '');
121
+ if (short.length <= 50) {
122
+ items.push(short);
123
+ }
124
+ else {
125
+ items.push(short.substring(0, 47) + '...');
126
+ }
127
+ if (items.length >= 2)
128
+ break;
129
+ }
130
+ if (items.length >= 2)
131
+ break;
132
+ }
133
+ return items.join(', ');
134
+ }
@@ -20,6 +20,8 @@ export interface ClaudeCliOptions {
20
20
  workingDir: string;
21
21
  threadId?: string;
22
22
  skipPermissions?: boolean;
23
+ sessionId?: string;
24
+ resume?: boolean;
23
25
  }
24
26
  export declare class ClaudeCli extends EventEmitter {
25
27
  private process;
@@ -20,6 +20,15 @@ export class ClaudeCli extends EventEmitter {
20
20
  '--output-format', 'stream-json',
21
21
  '--verbose',
22
22
  ];
23
+ // Add session ID for persistence/resume support
24
+ if (this.options.sessionId) {
25
+ if (this.options.resume) {
26
+ args.push('--resume', this.options.sessionId);
27
+ }
28
+ else {
29
+ args.push('--session-id', this.options.sessionId);
30
+ }
31
+ }
23
32
  // Either use skip permissions or the MCP-based permission system
24
33
  if (this.options.skipPermissions) {
25
34
  args.push('--dangerously-skip-permissions');
@@ -34,6 +34,7 @@ interface PendingMessageApproval {
34
34
  */
35
35
  interface Session {
36
36
  threadId: string;
37
+ claudeSessionId: string;
37
38
  startedBy: string;
38
39
  startedAt: Date;
39
40
  lastActivityAt: Date;
@@ -55,6 +56,7 @@ interface Session {
55
56
  typingTimer: ReturnType<typeof setInterval> | null;
56
57
  timeoutWarningPosted: boolean;
57
58
  isRestarting: boolean;
59
+ isResumed: boolean;
58
60
  }
59
61
  export declare class SessionManager {
60
62
  private mattermost;
@@ -63,14 +65,34 @@ export declare class SessionManager {
63
65
  private debug;
64
66
  private sessions;
65
67
  private postIndex;
68
+ private sessionStore;
66
69
  private cleanupTimer;
67
70
  constructor(mattermost: MattermostClient, workingDir: string, skipPermissions?: boolean);
71
+ /**
72
+ * Initialize session manager by resuming any persisted sessions.
73
+ * Should be called before starting to listen for new messages.
74
+ */
75
+ initialize(): Promise<void>;
76
+ /**
77
+ * Resume a single session from persisted state
78
+ */
79
+ private resumeSession;
80
+ /**
81
+ * Persist a session to disk
82
+ */
83
+ private persistSession;
84
+ /**
85
+ * Remove a session from persistence
86
+ */
87
+ private unpersistSession;
68
88
  /** Get a session by thread ID */
69
89
  getSession(threadId: string): Session | undefined;
70
90
  /** Check if a session exists for this thread */
71
91
  hasSession(threadId: string): boolean;
72
92
  /** Get the number of active sessions */
73
93
  getSessionCount(): number;
94
+ /** Get all active session thread IDs */
95
+ getActiveThreadIds(): string[];
74
96
  /** Register a post for reaction routing */
75
97
  private registerPost;
76
98
  /** Find session by post ID (for reaction routing) */
@@ -1,5 +1,8 @@
1
1
  import { ClaudeCli } from './cli.js';
2
2
  import { getUpdateInfo } from '../update-notifier.js';
3
+ import { getReleaseNotes, getWhatsNewSummary } from '../changelog.js';
4
+ import { SessionStore } from '../persistence/session-store.js';
5
+ import { randomUUID } from 'crypto';
3
6
  import { readFileSync } from 'fs';
4
7
  import { dirname, resolve } from 'path';
5
8
  import { fileURLToPath } from 'url';
@@ -30,6 +33,8 @@ export class SessionManager {
30
33
  // Multi-session storage
31
34
  sessions = new Map(); // threadId -> Session
32
35
  postIndex = new Map(); // postId -> threadId (for reaction routing)
36
+ // Persistence
37
+ sessionStore = new SessionStore();
33
38
  // Cleanup timer
34
39
  cleanupTimer = null;
35
40
  constructor(mattermost, workingDir, skipPermissions = false) {
@@ -44,6 +49,144 @@ export class SessionManager {
44
49
  this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
45
50
  }
46
51
  // ---------------------------------------------------------------------------
52
+ // Session Initialization (Resume)
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Initialize session manager by resuming any persisted sessions.
56
+ * Should be called before starting to listen for new messages.
57
+ */
58
+ 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
65
+ const persisted = this.sessionStore.load();
66
+ if (persisted.size === 0) {
67
+ if (this.debug)
68
+ console.log(' [resume] No sessions to resume');
69
+ return;
70
+ }
71
+ console.log(` 📂 Found ${persisted.size} session(s) to resume...`);
72
+ // Resume each session
73
+ for (const [threadId, state] of persisted) {
74
+ await this.resumeSession(state);
75
+ }
76
+ console.log(` ✅ Resumed ${this.sessions.size} session(s)`);
77
+ }
78
+ /**
79
+ * Resume a single session from persisted state
80
+ */
81
+ async resumeSession(state) {
82
+ const shortId = state.threadId.substring(0, 8);
83
+ // Verify thread still exists
84
+ const post = await this.mattermost.getPost(state.threadId);
85
+ if (!post) {
86
+ console.log(` ⚠️ Thread ${shortId}... deleted, skipping resume`);
87
+ this.sessionStore.remove(state.threadId);
88
+ return;
89
+ }
90
+ // Check max sessions limit
91
+ if (this.sessions.size >= MAX_SESSIONS) {
92
+ console.log(` ⚠️ Max sessions reached, skipping resume for ${shortId}...`);
93
+ return;
94
+ }
95
+ // Create Claude CLI with resume flag
96
+ const skipPerms = this.skipPermissions && !state.forceInteractivePermissions;
97
+ const cliOptions = {
98
+ workingDir: state.workingDir,
99
+ threadId: state.threadId,
100
+ skipPermissions: skipPerms,
101
+ sessionId: state.claudeSessionId,
102
+ resume: true,
103
+ };
104
+ const claude = new ClaudeCli(cliOptions);
105
+ // Rebuild Session object from persisted state
106
+ const session = {
107
+ threadId: state.threadId,
108
+ claudeSessionId: state.claudeSessionId,
109
+ startedBy: state.startedBy,
110
+ startedAt: new Date(state.startedAt),
111
+ lastActivityAt: new Date(),
112
+ sessionNumber: state.sessionNumber,
113
+ workingDir: state.workingDir,
114
+ claude,
115
+ currentPostId: null,
116
+ pendingContent: '',
117
+ pendingApproval: null,
118
+ pendingQuestionSet: null,
119
+ pendingMessageApproval: null,
120
+ planApproved: state.planApproved,
121
+ sessionAllowedUsers: new Set(state.sessionAllowedUsers),
122
+ forceInteractivePermissions: state.forceInteractivePermissions,
123
+ sessionStartPostId: state.sessionStartPostId,
124
+ tasksPostId: state.tasksPostId,
125
+ activeSubagents: new Map(),
126
+ updateTimer: null,
127
+ typingTimer: null,
128
+ timeoutWarningPosted: false,
129
+ isRestarting: false,
130
+ isResumed: true,
131
+ };
132
+ // Register session
133
+ this.sessions.set(state.threadId, session);
134
+ if (state.sessionStartPostId) {
135
+ this.registerPost(state.sessionStartPostId, state.threadId);
136
+ }
137
+ // Bind event handlers
138
+ claude.on('event', (e) => this.handleEvent(state.threadId, e));
139
+ claude.on('exit', (code) => this.handleExit(state.threadId, code));
140
+ try {
141
+ claude.start();
142
+ console.log(` 🔄 Resumed session ${shortId}... (@${state.startedBy})`);
143
+ // 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);
145
+ // Update session header
146
+ await this.updateSessionHeader(session);
147
+ // Update persistence with new activity time
148
+ this.persistSession(session);
149
+ }
150
+ catch (err) {
151
+ console.error(` ❌ Failed to resume session ${shortId}...:`, err);
152
+ this.sessions.delete(state.threadId);
153
+ this.sessionStore.remove(state.threadId);
154
+ // Try to notify user
155
+ try {
156
+ await this.mattermost.createPost(`⚠️ **Could not resume previous session.** Starting fresh.\n*Your previous conversation context is preserved, but Claude needs to re-read it.*`, state.threadId);
157
+ }
158
+ catch {
159
+ // Ignore if we can't post
160
+ }
161
+ }
162
+ }
163
+ /**
164
+ * Persist a session to disk
165
+ */
166
+ persistSession(session) {
167
+ const state = {
168
+ threadId: session.threadId,
169
+ claudeSessionId: session.claudeSessionId,
170
+ startedBy: session.startedBy,
171
+ startedAt: session.startedAt.toISOString(),
172
+ sessionNumber: session.sessionNumber,
173
+ workingDir: session.workingDir,
174
+ sessionAllowedUsers: [...session.sessionAllowedUsers],
175
+ forceInteractivePermissions: session.forceInteractivePermissions,
176
+ sessionStartPostId: session.sessionStartPostId,
177
+ tasksPostId: session.tasksPostId,
178
+ lastActivityAt: session.lastActivityAt.toISOString(),
179
+ planApproved: session.planApproved,
180
+ };
181
+ this.sessionStore.save(session.threadId, state);
182
+ }
183
+ /**
184
+ * Remove a session from persistence
185
+ */
186
+ unpersistSession(threadId) {
187
+ this.sessionStore.remove(threadId);
188
+ }
189
+ // ---------------------------------------------------------------------------
47
190
  // Session Lookup Methods
48
191
  // ---------------------------------------------------------------------------
49
192
  /** Get a session by thread ID */
@@ -58,6 +201,10 @@ export class SessionManager {
58
201
  getSessionCount() {
59
202
  return this.sessions.size;
60
203
  }
204
+ /** Get all active session thread IDs */
205
+ getActiveThreadIds() {
206
+ return [...this.sessions.keys()];
207
+ }
61
208
  /** Register a post for reaction routing */
62
209
  registerPost(postId, threadId) {
63
210
  this.postIndex.set(postId, threadId);
@@ -101,16 +248,21 @@ export class SessionManager {
101
248
  // Post initial session message (will be updated by updateSessionHeader)
102
249
  const post = await this.mattermost.createPost(`### 🤖 mm-claude \`v${pkg.version}\`\n\n*Starting session...*`, replyToPostId);
103
250
  const actualThreadId = replyToPostId || post.id;
251
+ // Generate a unique session ID for this Claude session
252
+ const claudeSessionId = randomUUID();
104
253
  // Create Claude CLI with options
105
254
  const cliOptions = {
106
255
  workingDir: this.workingDir,
107
256
  threadId: actualThreadId,
108
257
  skipPermissions: this.skipPermissions,
258
+ sessionId: claudeSessionId,
259
+ resume: false,
109
260
  };
110
261
  const claude = new ClaudeCli(cliOptions);
111
262
  // Create the session object
112
263
  const session = {
113
264
  threadId: actualThreadId,
265
+ claudeSessionId,
114
266
  startedBy: username,
115
267
  startedAt: new Date(),
116
268
  lastActivityAt: new Date(),
@@ -132,6 +284,7 @@ export class SessionManager {
132
284
  typingTimer: null,
133
285
  timeoutWarningPosted: false,
134
286
  isRestarting: false,
287
+ isResumed: false,
135
288
  };
136
289
  // Register session
137
290
  this.sessions.set(actualThreadId, session);
@@ -158,6 +311,8 @@ export class SessionManager {
158
311
  // Send the message to Claude (with images if present)
159
312
  const content = await this.buildMessageContent(options.prompt, options.files);
160
313
  claude.sendMessage(content);
314
+ // Persist session for resume after restart
315
+ this.persistSession(session);
161
316
  }
162
317
  handleEvent(threadId, event) {
163
318
  const session = this.sessions.get(threadId);
@@ -741,7 +896,12 @@ export class SessionManager {
741
896
  async flush(session) {
742
897
  if (!session.pendingContent.trim())
743
898
  return;
744
- const content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
899
+ let content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
900
+ // Mattermost has a 16,383 character limit for posts
901
+ const MAX_POST_LENGTH = 16000; // Leave some margin
902
+ if (content.length > MAX_POST_LENGTH) {
903
+ content = content.substring(0, MAX_POST_LENGTH - 50) + '\n\n*... (truncated)*';
904
+ }
745
905
  if (session.currentPostId) {
746
906
  await this.mattermost.updatePost(session.currentPostId, content);
747
907
  }
@@ -778,6 +938,8 @@ export class SessionManager {
778
938
  this.postIndex.delete(postId);
779
939
  }
780
940
  }
941
+ // Remove from persistence when session ends normally
942
+ this.unpersistSession(threadId);
781
943
  const shortId = threadId.substring(0, 8);
782
944
  console.log(` ■ Session ended (${shortId}…) — ${this.sessions.size} active`);
783
945
  }
@@ -870,11 +1032,14 @@ export class SessionManager {
870
1032
  session.pendingContent = '';
871
1033
  // Update session working directory
872
1034
  session.workingDir = absoluteDir;
873
- // Create new Claude CLI with new working directory
1035
+ // Generate new session ID for the restarted CLI (or keep using --resume with same ID)
1036
+ // We use --resume to maintain conversation context
874
1037
  const cliOptions = {
875
1038
  workingDir: absoluteDir,
876
1039
  threadId: threadId,
877
1040
  skipPermissions: this.skipPermissions || !session.forceInteractivePermissions,
1041
+ sessionId: session.claudeSessionId,
1042
+ resume: true, // Resume to keep conversation context
878
1043
  };
879
1044
  session.claude = new ClaudeCli(cliOptions);
880
1045
  // Rebind event handlers
@@ -898,6 +1063,8 @@ export class SessionManager {
898
1063
  // Update activity
899
1064
  session.lastActivityAt = new Date();
900
1065
  session.timeoutWarningPosted = false;
1066
+ // Persist the updated session state
1067
+ this.persistSession(session);
901
1068
  }
902
1069
  /** Invite a user to participate in a specific session */
903
1070
  async inviteUser(threadId, invitedUser, invitedBy) {
@@ -913,6 +1080,7 @@ export class SessionManager {
913
1080
  await this.mattermost.createPost(`✅ @${invitedUser} can now participate in this session (invited by @${invitedBy})`, threadId);
914
1081
  console.log(` 👋 @${invitedUser} invited to session by @${invitedBy}`);
915
1082
  await this.updateSessionHeader(session);
1083
+ this.persistSession(session); // Persist collaboration change
916
1084
  }
917
1085
  /** Kick a user from a specific session */
918
1086
  async kickUser(threadId, kickedUser, kickedBy) {
@@ -938,6 +1106,7 @@ export class SessionManager {
938
1106
  await this.mattermost.createPost(`🚫 @${kickedUser} removed from this session by @${kickedBy}`, threadId);
939
1107
  console.log(` 🚫 @${kickedUser} kicked from session by @${kickedBy}`);
940
1108
  await this.updateSessionHeader(session);
1109
+ this.persistSession(session); // Persist collaboration change
941
1110
  }
942
1111
  else {
943
1112
  await this.mattermost.createPost(`⚠️ @${kickedUser} was not in this session`, threadId);
@@ -970,6 +1139,7 @@ export class SessionManager {
970
1139
  await this.mattermost.createPost(`🔐 Interactive permissions enabled for this session by @${username}`, threadId);
971
1140
  console.log(` 🔐 Interactive permissions enabled for session by @${username}`);
972
1141
  await this.updateSessionHeader(session);
1142
+ this.persistSession(session); // Persist permission change
973
1143
  }
974
1144
  /** Check if a session should use interactive permissions */
975
1145
  isSessionInteractive(threadId) {
@@ -1010,9 +1180,14 @@ export class SessionManager {
1010
1180
  const updateNotice = updateInfo
1011
1181
  ? `\n> ⚠️ **Update available:** v${updateInfo.current} → v${updateInfo.latest} - Run \`npm install -g mattermost-claude-code\`\n`
1012
1182
  : '';
1183
+ // Get "What's new" from release notes
1184
+ const releaseNotes = getReleaseNotes(pkg.version);
1185
+ const whatsNew = releaseNotes ? getWhatsNewSummary(releaseNotes) : '';
1186
+ const whatsNewLine = whatsNew ? `\n> ✨ **What's new:** ${whatsNew}\n` : '';
1013
1187
  const msg = [
1014
1188
  `### 🤖 mm-claude \`v${pkg.version}\``,
1015
1189
  updateNotice,
1190
+ whatsNewLine,
1016
1191
  `| | |`,
1017
1192
  `|:--|:--|`,
1018
1193
  ...rows,
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { readFileSync } from 'fs';
8
8
  import { dirname, resolve } from 'path';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { checkForUpdates } from './update-notifier.js';
11
+ import { getReleaseNotes, formatReleaseNotes } from './changelog.js';
11
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
12
13
  const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
13
14
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
@@ -101,6 +102,7 @@ async function main() {
101
102
  `| Command | Description |\n` +
102
103
  `|:--------|:------------|\n` +
103
104
  `| \`!help\` | Show this help message |\n` +
105
+ `| \`!release-notes\` | Show release notes for current version |\n` +
104
106
  `| \`!cd <path>\` | Change working directory (restarts Claude) |\n` +
105
107
  `| \`!invite @user\` | Invite a user to this session |\n` +
106
108
  `| \`!kick @user\` | Remove an invited user |\n` +
@@ -111,6 +113,17 @@ async function main() {
111
113
  `- ❌ or 🛑 on any message to stop session`, threadRoot);
112
114
  return;
113
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);
121
+ }
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);
124
+ }
125
+ return;
126
+ }
114
127
  // Check for !invite command
115
128
  const inviteMatch = content.match(/^!invite\s+@?([\w.-]+)/i);
116
129
  if (inviteMatch) {
@@ -173,16 +186,31 @@ async function main() {
173
186
  mattermost.on('connected', () => { });
174
187
  mattermost.on('error', (e) => console.error(' ❌ Error:', e));
175
188
  await mattermost.connect();
189
+ // Resume any persisted sessions from before restart
190
+ await session.initialize();
176
191
  console.log(` ✅ ${bold('Ready!')} Waiting for @${config.mattermost.botName} mentions...`);
177
192
  console.log('');
178
- const shutdown = () => {
193
+ const shutdown = async () => {
179
194
  console.log('');
180
195
  console.log(` 👋 ${dim('Shutting down...')}`);
196
+ // Post shutdown message to active sessions
197
+ const activeThreads = session.getActiveThreadIds();
198
+ if (activeThreads.length > 0) {
199
+ console.log(` 📤 Notifying ${activeThreads.length} active session(s)...`);
200
+ for (const threadId of activeThreads) {
201
+ try {
202
+ await mattermost.createPost(`⏸️ **Bot shutting down** - session will resume on restart`, threadId);
203
+ }
204
+ catch {
205
+ // Ignore errors, we're shutting down
206
+ }
207
+ }
208
+ }
181
209
  session.killAllSessions();
182
210
  mattermost.disconnect();
183
211
  process.exit(0);
184
212
  };
185
- process.on('SIGINT', shutdown);
186
- process.on('SIGTERM', shutdown);
213
+ process.on('SIGINT', () => { shutdown(); });
214
+ process.on('SIGTERM', () => { shutdown(); });
187
215
  }
188
216
  main().catch(e => { console.error(e); process.exit(1); });
@@ -31,6 +31,7 @@ export declare class MattermostClient extends EventEmitter {
31
31
  addReaction(postId: string, emojiName: string): Promise<void>;
32
32
  downloadFile(fileId: string): Promise<Buffer>;
33
33
  getFileInfo(fileId: string): Promise<import('./types.js').MattermostFile>;
34
+ getPost(postId: string): Promise<MattermostPost | null>;
34
35
  connect(): Promise<void>;
35
36
  private handleEvent;
36
37
  private scheduleReconnect;
@@ -106,6 +106,15 @@ export class MattermostClient extends EventEmitter {
106
106
  async getFileInfo(fileId) {
107
107
  return this.api('GET', `/files/${fileId}/info`);
108
108
  }
109
+ // Get a post by ID (used to verify thread still exists on resume)
110
+ async getPost(postId) {
111
+ try {
112
+ return await this.api('GET', `/posts/${postId}`);
113
+ }
114
+ catch {
115
+ return null; // Post doesn't exist or was deleted
116
+ }
117
+ }
109
118
  // Connect to WebSocket
110
119
  async connect() {
111
120
  // Get bot user first
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Persisted session state for resuming after bot restart
3
+ */
4
+ export interface PersistedSession {
5
+ threadId: string;
6
+ claudeSessionId: string;
7
+ startedBy: string;
8
+ startedAt: string;
9
+ sessionNumber: number;
10
+ workingDir: string;
11
+ sessionAllowedUsers: string[];
12
+ forceInteractivePermissions: boolean;
13
+ sessionStartPostId: string | null;
14
+ tasksPostId: string | null;
15
+ lastActivityAt: string;
16
+ planApproved: boolean;
17
+ }
18
+ /**
19
+ * SessionStore - Persistence layer for session state
20
+ * Stores session data as JSON file for resume after restart
21
+ */
22
+ export declare class SessionStore {
23
+ private debug;
24
+ constructor();
25
+ /**
26
+ * Load all persisted sessions
27
+ */
28
+ load(): Map<string, PersistedSession>;
29
+ /**
30
+ * Save a session (creates or updates)
31
+ */
32
+ save(threadId: string, session: PersistedSession): void;
33
+ /**
34
+ * Remove a session
35
+ */
36
+ remove(threadId: string): void;
37
+ /**
38
+ * Remove sessions older than maxAgeMs
39
+ */
40
+ cleanStale(maxAgeMs: number): string[];
41
+ /**
42
+ * Clear all sessions
43
+ */
44
+ clear(): void;
45
+ /**
46
+ * Load raw data from file
47
+ */
48
+ private loadRaw;
49
+ /**
50
+ * Write data atomically (write to temp file, then rename)
51
+ */
52
+ private writeAtomic;
53
+ }
@@ -0,0 +1,127 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+ const STORE_VERSION = 1;
5
+ const CONFIG_DIR = join(homedir(), '.config', 'mm-claude');
6
+ const SESSIONS_FILE = join(CONFIG_DIR, 'sessions.json');
7
+ /**
8
+ * SessionStore - Persistence layer for session state
9
+ * Stores session data as JSON file for resume after restart
10
+ */
11
+ export class SessionStore {
12
+ debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
13
+ constructor() {
14
+ // Ensure config directory exists
15
+ if (!existsSync(CONFIG_DIR)) {
16
+ mkdirSync(CONFIG_DIR, { recursive: true });
17
+ }
18
+ }
19
+ /**
20
+ * Load all persisted sessions
21
+ */
22
+ load() {
23
+ const sessions = new Map();
24
+ if (!existsSync(SESSIONS_FILE)) {
25
+ if (this.debug)
26
+ console.log(' [persist] No sessions file found');
27
+ return sessions;
28
+ }
29
+ try {
30
+ const data = JSON.parse(readFileSync(SESSIONS_FILE, 'utf-8'));
31
+ // Version check for future migrations
32
+ if (data.version !== STORE_VERSION) {
33
+ console.warn(` [persist] Sessions file version mismatch (${data.version} vs ${STORE_VERSION}), starting fresh`);
34
+ return sessions;
35
+ }
36
+ for (const [threadId, session] of Object.entries(data.sessions)) {
37
+ sessions.set(threadId, session);
38
+ }
39
+ if (this.debug) {
40
+ console.log(` [persist] Loaded ${sessions.size} session(s)`);
41
+ }
42
+ }
43
+ catch (err) {
44
+ console.error(' [persist] Failed to load sessions:', err);
45
+ }
46
+ return sessions;
47
+ }
48
+ /**
49
+ * Save a session (creates or updates)
50
+ */
51
+ save(threadId, session) {
52
+ const data = this.loadRaw();
53
+ data.sessions[threadId] = session;
54
+ this.writeAtomic(data);
55
+ if (this.debug) {
56
+ const shortId = threadId.substring(0, 8);
57
+ console.log(` [persist] Saved session ${shortId}...`);
58
+ }
59
+ }
60
+ /**
61
+ * Remove a session
62
+ */
63
+ remove(threadId) {
64
+ const data = this.loadRaw();
65
+ if (data.sessions[threadId]) {
66
+ delete data.sessions[threadId];
67
+ this.writeAtomic(data);
68
+ if (this.debug) {
69
+ const shortId = threadId.substring(0, 8);
70
+ console.log(` [persist] Removed session ${shortId}...`);
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Remove sessions older than maxAgeMs
76
+ */
77
+ cleanStale(maxAgeMs) {
78
+ const data = this.loadRaw();
79
+ const now = Date.now();
80
+ const staleIds = [];
81
+ for (const [threadId, session] of Object.entries(data.sessions)) {
82
+ const lastActivity = new Date(session.lastActivityAt).getTime();
83
+ if (now - lastActivity > maxAgeMs) {
84
+ staleIds.push(threadId);
85
+ delete data.sessions[threadId];
86
+ }
87
+ }
88
+ if (staleIds.length > 0) {
89
+ this.writeAtomic(data);
90
+ if (this.debug) {
91
+ console.log(` [persist] Cleaned ${staleIds.length} stale session(s)`);
92
+ }
93
+ }
94
+ return staleIds;
95
+ }
96
+ /**
97
+ * Clear all sessions
98
+ */
99
+ clear() {
100
+ this.writeAtomic({ version: STORE_VERSION, sessions: {} });
101
+ if (this.debug) {
102
+ console.log(' [persist] Cleared all sessions');
103
+ }
104
+ }
105
+ /**
106
+ * Load raw data from file
107
+ */
108
+ loadRaw() {
109
+ if (!existsSync(SESSIONS_FILE)) {
110
+ return { version: STORE_VERSION, sessions: {} };
111
+ }
112
+ try {
113
+ return JSON.parse(readFileSync(SESSIONS_FILE, 'utf-8'));
114
+ }
115
+ catch {
116
+ return { version: STORE_VERSION, sessions: {} };
117
+ }
118
+ }
119
+ /**
120
+ * Write data atomically (write to temp file, then rename)
121
+ */
122
+ writeAtomic(data) {
123
+ const tempFile = `${SESSIONS_FILE}.tmp`;
124
+ writeFileSync(tempFile, JSON.stringify(data, null, 2), 'utf-8');
125
+ renameSync(tempFile, SESSIONS_FILE);
126
+ }
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mattermost-claude-code",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  "files": [
37
37
  "dist",
38
38
  "README.md",
39
+ "CHANGELOG.md",
39
40
  "LICENSE",
40
41
  "package.json"
41
42
  ],