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 +256 -0
- package/README.md +2 -0
- package/dist/changelog.d.ts +20 -0
- package/dist/changelog.js +134 -0
- package/dist/claude/cli.d.ts +2 -0
- package/dist/claude/cli.js +9 -0
- package/dist/claude/session.d.ts +22 -0
- package/dist/claude/session.js +177 -2
- package/dist/index.js +31 -3
- package/dist/mattermost/client.d.ts +1 -0
- package/dist/mattermost/client.js +9 -0
- package/dist/persistence/session-store.d.ts +53 -0
- package/dist/persistence/session-store.js +127 -0
- package/package.json +2 -1
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
|
+
}
|
package/dist/claude/cli.d.ts
CHANGED
package/dist/claude/cli.js
CHANGED
|
@@ -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');
|
package/dist/claude/session.d.ts
CHANGED
|
@@ -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) */
|
package/dist/claude/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
],
|