mattermost-claude-code 0.9.2 → 0.10.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 +36 -0
- package/dist/claude/cli.d.ts +2 -0
- package/dist/claude/cli.js +7 -0
- package/dist/claude/session.d.ts +8 -1
- package/dist/claude/session.js +133 -40
- package/dist/index.js +37 -5
- package/dist/logo.d.ts +26 -0
- package/dist/logo.js +46 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,42 @@ 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
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.10.0] - 2025-12-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **ASCII art logo** - Stylized "M" in Claude Code's block character style
|
|
14
|
+
- Shows on CLI startup with Mattermost blue and Claude orange colors
|
|
15
|
+
- Shows at the top of every Mattermost session thread
|
|
16
|
+
- Festive stars (✴) surround the logo
|
|
17
|
+
- **`!kill` command** - Emergency shutdown that kills ALL sessions and exits the bot
|
|
18
|
+
- Only available to globally authorized users (ALLOWED_USERS)
|
|
19
|
+
- Unpersists all sessions (they won't resume on restart)
|
|
20
|
+
- Posts notification to all active session threads before exiting
|
|
21
|
+
- **`!escape` / `!interrupt` commands** - Soft interrupt like pressing Escape in CLI
|
|
22
|
+
- Sends SIGINT to Claude CLI, stopping current task
|
|
23
|
+
- Session stays alive and user can continue the conversation
|
|
24
|
+
- Also available via ⏸️ reaction on any message in the session
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- **Fix plan mode getting stuck after approval** - tool calls now get proper responses
|
|
28
|
+
- `ExitPlanMode` and `AskUserQuestion` now receive `tool_result` instead of user messages
|
|
29
|
+
- Claude was waiting for tool results that never came, causing sessions to hang
|
|
30
|
+
- Added `toolUseId` tracking to `PendingApproval` interface
|
|
31
|
+
|
|
32
|
+
## [0.9.3] - 2025-12-28
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- **Major fix for session persistence** - completely rewrote session lifecycle management
|
|
36
|
+
- Sessions now correctly survive bot restarts (was broken in 0.9.0-0.9.2)
|
|
37
|
+
- `killAllSessions()` now explicitly preserves persistence instead of relying on exit event timing
|
|
38
|
+
- `killSession()` now takes an `unpersist` parameter to control persistence behavior
|
|
39
|
+
- `handleExit()` now only unpersists on graceful exits (code 0), not on errors
|
|
40
|
+
- Resumed sessions that fail are preserved for retry instead of being removed
|
|
41
|
+
- Added comprehensive debug logging to trace session lifecycle
|
|
42
|
+
- Race condition between shutdown and exit events eliminated
|
|
43
|
+
|
|
8
44
|
## [0.9.2] - 2025-12-28
|
|
9
45
|
|
|
10
46
|
### Fixed
|
package/dist/claude/cli.d.ts
CHANGED
package/dist/claude/cli.js
CHANGED
|
@@ -151,6 +151,13 @@ export class ClaudeCli extends EventEmitter {
|
|
|
151
151
|
this.process?.kill('SIGTERM');
|
|
152
152
|
this.process = null;
|
|
153
153
|
}
|
|
154
|
+
/** Interrupt current processing (like Escape in CLI) - keeps process alive */
|
|
155
|
+
interrupt() {
|
|
156
|
+
if (!this.process)
|
|
157
|
+
return false;
|
|
158
|
+
this.process.kill('SIGINT');
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
154
161
|
getMcpServerPath() {
|
|
155
162
|
// Get the path to the MCP permission server
|
|
156
163
|
// When running from source: src/mcp/permission-server.ts -> dist/mcp/permission-server.js
|
package/dist/claude/session.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ interface PendingQuestionSet {
|
|
|
19
19
|
interface PendingApproval {
|
|
20
20
|
postId: string;
|
|
21
21
|
type: 'plan' | 'action';
|
|
22
|
+
toolUseId: string;
|
|
22
23
|
}
|
|
23
24
|
/**
|
|
24
25
|
* Pending message from unauthorized user awaiting approval
|
|
@@ -94,6 +95,8 @@ export declare class SessionManager {
|
|
|
94
95
|
getSessionCount(): number;
|
|
95
96
|
/** Get all active session thread IDs */
|
|
96
97
|
getActiveThreadIds(): string[];
|
|
98
|
+
/** Mark that we're shutting down (prevents cleanup of persisted sessions) */
|
|
99
|
+
setShuttingDown(): void;
|
|
97
100
|
/** Register a post for reaction routing */
|
|
98
101
|
private registerPost;
|
|
99
102
|
/** Find session by post ID (for reaction routing) */
|
|
@@ -138,9 +141,11 @@ export declare class SessionManager {
|
|
|
138
141
|
/** Send a follow-up message to an existing session */
|
|
139
142
|
sendFollowUp(threadId: string, message: string, files?: MattermostFile[]): Promise<void>;
|
|
140
143
|
/** Kill a specific session */
|
|
141
|
-
killSession(threadId: string): void;
|
|
144
|
+
killSession(threadId: string, unpersist?: boolean): void;
|
|
142
145
|
/** Cancel a session with user feedback */
|
|
143
146
|
cancelSession(threadId: string, username: string): Promise<void>;
|
|
147
|
+
/** Interrupt current processing but keep session alive (like Escape in CLI) */
|
|
148
|
+
interruptSession(threadId: string, username: string): Promise<void>;
|
|
144
149
|
/** Change working directory for a session (restarts Claude CLI) */
|
|
145
150
|
changeDirectory(threadId: string, newDir: string, username: string): Promise<void>;
|
|
146
151
|
/** Invite a user to participate in a specific session */
|
|
@@ -160,6 +165,8 @@ export declare class SessionManager {
|
|
|
160
165
|
requestMessageApproval(threadId: string, username: string, message: string): Promise<void>;
|
|
161
166
|
/** Kill all active sessions (for graceful shutdown) */
|
|
162
167
|
killAllSessions(): void;
|
|
168
|
+
/** Kill all sessions AND unpersist them (for emergency shutdown - no resume) */
|
|
169
|
+
killAllSessionsAndUnpersist(): void;
|
|
163
170
|
/** Cleanup idle sessions that have exceeded timeout */
|
|
164
171
|
private cleanupIdleSessions;
|
|
165
172
|
}
|
package/dist/claude/session.js
CHANGED
|
@@ -2,6 +2,7 @@ import { ClaudeCli } from './cli.js';
|
|
|
2
2
|
import { getUpdateInfo } from '../update-notifier.js';
|
|
3
3
|
import { getReleaseNotes, getWhatsNewSummary } from '../changelog.js';
|
|
4
4
|
import { SessionStore } from '../persistence/session-store.js';
|
|
5
|
+
import { MATTERMOST_LOGO } from '../logo.js';
|
|
5
6
|
import { randomUUID } from 'crypto';
|
|
6
7
|
import { readFileSync } from 'fs';
|
|
7
8
|
import { dirname, resolve } from 'path';
|
|
@@ -180,6 +181,8 @@ export class SessionManager {
|
|
|
180
181
|
* Persist a session to disk
|
|
181
182
|
*/
|
|
182
183
|
persistSession(session) {
|
|
184
|
+
const shortId = session.threadId.substring(0, 8);
|
|
185
|
+
console.log(` [persist] Saving session ${shortId}...`);
|
|
183
186
|
const state = {
|
|
184
187
|
threadId: session.threadId,
|
|
185
188
|
claudeSessionId: session.claudeSessionId,
|
|
@@ -195,19 +198,14 @@ export class SessionManager {
|
|
|
195
198
|
planApproved: session.planApproved,
|
|
196
199
|
};
|
|
197
200
|
this.sessionStore.save(session.threadId, state);
|
|
198
|
-
|
|
199
|
-
const shortId = session.threadId.substring(0, 8);
|
|
200
|
-
console.log(` [persist] Saved session ${shortId}... (claudeId: ${session.claudeSessionId.substring(0, 8)}...)`);
|
|
201
|
-
}
|
|
201
|
+
console.log(` [persist] Saved session ${shortId}... (claudeId: ${session.claudeSessionId.substring(0, 8)}...)`);
|
|
202
202
|
}
|
|
203
203
|
/**
|
|
204
204
|
* Remove a session from persistence
|
|
205
205
|
*/
|
|
206
206
|
unpersistSession(threadId) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
console.log(` [persist] Removing session ${shortId}...`);
|
|
210
|
-
}
|
|
207
|
+
const shortId = threadId.substring(0, 8);
|
|
208
|
+
console.log(` [persist] REMOVING session ${shortId}... (this should NOT happen during shutdown!)`);
|
|
211
209
|
this.sessionStore.remove(threadId);
|
|
212
210
|
}
|
|
213
211
|
// ---------------------------------------------------------------------------
|
|
@@ -229,6 +227,11 @@ export class SessionManager {
|
|
|
229
227
|
getActiveThreadIds() {
|
|
230
228
|
return [...this.sessions.keys()];
|
|
231
229
|
}
|
|
230
|
+
/** Mark that we're shutting down (prevents cleanup of persisted sessions) */
|
|
231
|
+
setShuttingDown() {
|
|
232
|
+
console.log(' [shutdown] Setting isShuttingDown = true');
|
|
233
|
+
this.isShuttingDown = true;
|
|
234
|
+
}
|
|
232
235
|
/** Register a post for reaction routing */
|
|
233
236
|
registerPost(postId, threadId) {
|
|
234
237
|
this.postIndex.set(postId, threadId);
|
|
@@ -272,7 +275,7 @@ export class SessionManager {
|
|
|
272
275
|
// Post initial session message (will be updated by updateSessionHeader)
|
|
273
276
|
let post;
|
|
274
277
|
try {
|
|
275
|
-
post = await this.mattermost.createPost(
|
|
278
|
+
post = await this.mattermost.createPost(`${MATTERMOST_LOGO}\n**v${pkg.version}**\n\n*Starting session...*`, replyToPostId);
|
|
276
279
|
}
|
|
277
280
|
catch (err) {
|
|
278
281
|
console.error(` ❌ Failed to create session post:`, err);
|
|
@@ -360,7 +363,7 @@ export class SessionManager {
|
|
|
360
363
|
for (const block of msg?.content || []) {
|
|
361
364
|
if (block.type === 'tool_use') {
|
|
362
365
|
if (block.name === 'ExitPlanMode') {
|
|
363
|
-
this.handleExitPlanMode(session);
|
|
366
|
+
this.handleExitPlanMode(session, block.id);
|
|
364
367
|
hasSpecialTool = true;
|
|
365
368
|
}
|
|
366
369
|
else if (block.name === 'TodoWrite') {
|
|
@@ -408,12 +411,15 @@ export class SessionManager {
|
|
|
408
411
|
console.error(' ⚠️ Failed to update subagent completion:', err);
|
|
409
412
|
}
|
|
410
413
|
}
|
|
411
|
-
async handleExitPlanMode(session) {
|
|
412
|
-
// If already approved in this session,
|
|
413
|
-
// (
|
|
414
|
+
async handleExitPlanMode(session, toolUseId) {
|
|
415
|
+
// If already approved in this session, send empty tool result to acknowledge
|
|
416
|
+
// (Claude needs a response to continue)
|
|
414
417
|
if (session.planApproved) {
|
|
415
418
|
if (this.debug)
|
|
416
|
-
console.log(' ↪ Plan already approved,
|
|
419
|
+
console.log(' ↪ Plan already approved, sending acknowledgment');
|
|
420
|
+
if (session.claude.isRunning()) {
|
|
421
|
+
session.claude.sendToolResult(toolUseId, 'Plan already approved. Proceeding.');
|
|
422
|
+
}
|
|
417
423
|
return;
|
|
418
424
|
}
|
|
419
425
|
// If we already have a pending approval, don't post another one
|
|
@@ -442,8 +448,8 @@ export class SessionManager {
|
|
|
442
448
|
catch (err) {
|
|
443
449
|
console.error(' ⚠️ Failed to add approval reactions:', err);
|
|
444
450
|
}
|
|
445
|
-
// Track this for reaction handling
|
|
446
|
-
session.pendingApproval = { postId: post.id, type: 'plan' };
|
|
451
|
+
// Track this for reaction handling - include toolUseId for proper response
|
|
452
|
+
session.pendingApproval = { postId: post.id, type: 'plan', toolUseId };
|
|
447
453
|
// Stop typing while waiting
|
|
448
454
|
this.stopTyping(session);
|
|
449
455
|
}
|
|
@@ -591,6 +597,11 @@ export class SessionManager {
|
|
|
591
597
|
await this.cancelSession(session.threadId, username);
|
|
592
598
|
return;
|
|
593
599
|
}
|
|
600
|
+
// Handle interrupt reactions (⏸️) on any post in the session
|
|
601
|
+
if (emojiName === 'pause_button' || emojiName === 'double_vertical_bar') {
|
|
602
|
+
await this.interruptSession(session.threadId, username);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
594
605
|
// Handle approval reactions
|
|
595
606
|
if (session.pendingApproval && session.pendingApproval.postId === postId) {
|
|
596
607
|
await this.handleApprovalReaction(session, emojiName, username);
|
|
@@ -635,17 +646,20 @@ export class SessionManager {
|
|
|
635
646
|
await this.postCurrentQuestion(session);
|
|
636
647
|
}
|
|
637
648
|
else {
|
|
638
|
-
// All questions answered - send
|
|
649
|
+
// All questions answered - send tool result
|
|
639
650
|
let answersText = 'Here are my answers:\n';
|
|
640
651
|
for (const q of questions) {
|
|
641
652
|
answersText += `- **${q.header}**: ${q.answer}\n`;
|
|
642
653
|
}
|
|
643
654
|
if (this.debug)
|
|
644
655
|
console.log(' ✅ All questions answered');
|
|
645
|
-
//
|
|
656
|
+
// Get the toolUseId before clearing
|
|
657
|
+
const toolUseId = session.pendingQuestionSet.toolUseId;
|
|
658
|
+
// Clear pending questions
|
|
646
659
|
session.pendingQuestionSet = null;
|
|
660
|
+
// Send tool result to Claude (AskUserQuestion expects a tool_result, not a user message)
|
|
647
661
|
if (session.claude.isRunning()) {
|
|
648
|
-
session.claude.
|
|
662
|
+
session.claude.sendToolResult(toolUseId, answersText);
|
|
649
663
|
this.startTyping(session);
|
|
650
664
|
}
|
|
651
665
|
}
|
|
@@ -657,7 +671,7 @@ export class SessionManager {
|
|
|
657
671
|
const isReject = emojiName === '-1' || emojiName === 'thumbsdown';
|
|
658
672
|
if (!isApprove && !isReject)
|
|
659
673
|
return;
|
|
660
|
-
const postId = session.pendingApproval
|
|
674
|
+
const { postId, toolUseId } = session.pendingApproval;
|
|
661
675
|
const shortId = session.threadId.substring(0, 8);
|
|
662
676
|
console.log(` ${isApprove ? '✅' : '❌'} Plan ${isApprove ? 'approved' : 'rejected'} (${shortId}…) by @${username}`);
|
|
663
677
|
// Update the post to show the decision
|
|
@@ -675,12 +689,12 @@ export class SessionManager {
|
|
|
675
689
|
if (isApprove) {
|
|
676
690
|
session.planApproved = true;
|
|
677
691
|
}
|
|
678
|
-
// Send
|
|
692
|
+
// Send tool result to Claude (ExitPlanMode expects a tool_result, not a user message)
|
|
679
693
|
if (session.claude.isRunning()) {
|
|
680
694
|
const response = isApprove
|
|
681
695
|
? 'Approved. Please proceed with the implementation.'
|
|
682
696
|
: 'Please revise the plan. I would like some changes.';
|
|
683
|
-
session.claude.
|
|
697
|
+
session.claude.sendToolResult(toolUseId, response);
|
|
684
698
|
this.startTyping(session);
|
|
685
699
|
}
|
|
686
700
|
}
|
|
@@ -947,26 +961,52 @@ export class SessionManager {
|
|
|
947
961
|
async handleExit(threadId, code) {
|
|
948
962
|
const session = this.sessions.get(threadId);
|
|
949
963
|
const shortId = threadId.substring(0, 8);
|
|
964
|
+
// Always log exit events to trace the flow
|
|
965
|
+
console.log(` [exit] handleExit called for ${shortId}... code=${code} isShuttingDown=${this.isShuttingDown}`);
|
|
950
966
|
if (!session) {
|
|
951
|
-
|
|
952
|
-
console.log(` [exit] Session ${shortId}... not found (already cleaned up)`);
|
|
967
|
+
console.log(` [exit] Session ${shortId}... not found (already cleaned up)`);
|
|
953
968
|
return;
|
|
954
969
|
}
|
|
955
970
|
// If we're intentionally restarting (e.g., !cd), don't clean up or post exit message
|
|
956
971
|
if (session.isRestarting) {
|
|
957
|
-
|
|
958
|
-
console.log(` [exit] Session ${shortId}... restarting, skipping cleanup`);
|
|
972
|
+
console.log(` [exit] Session ${shortId}... restarting, skipping cleanup`);
|
|
959
973
|
session.isRestarting = false; // Reset flag here, after the exit event fires
|
|
960
974
|
return;
|
|
961
975
|
}
|
|
962
976
|
// If bot is shutting down, suppress exit messages (shutdown message already sent)
|
|
977
|
+
// IMPORTANT: Check this flag FIRST before any cleanup. The session should remain
|
|
978
|
+
// persisted so it can be resumed after restart.
|
|
963
979
|
if (this.isShuttingDown) {
|
|
964
|
-
|
|
965
|
-
|
|
980
|
+
console.log(` [exit] Session ${shortId}... bot shutting down, preserving persistence`);
|
|
981
|
+
// Still clean up from in-memory maps since we're shutting down anyway
|
|
982
|
+
this.stopTyping(session);
|
|
983
|
+
if (session.updateTimer) {
|
|
984
|
+
clearTimeout(session.updateTimer);
|
|
985
|
+
session.updateTimer = null;
|
|
986
|
+
}
|
|
987
|
+
this.sessions.delete(threadId);
|
|
966
988
|
return;
|
|
967
989
|
}
|
|
968
|
-
|
|
969
|
-
|
|
990
|
+
// For resumed sessions that exit quickly (e.g., Claude --resume fails),
|
|
991
|
+
// don't unpersist immediately - give it a chance to be retried
|
|
992
|
+
if (session.isResumed && code !== 0) {
|
|
993
|
+
console.log(` [exit] Resumed session ${shortId}... failed with code ${code}, preserving for retry`);
|
|
994
|
+
this.stopTyping(session);
|
|
995
|
+
if (session.updateTimer) {
|
|
996
|
+
clearTimeout(session.updateTimer);
|
|
997
|
+
session.updateTimer = null;
|
|
998
|
+
}
|
|
999
|
+
this.sessions.delete(threadId);
|
|
1000
|
+
// Post error message but keep persistence
|
|
1001
|
+
try {
|
|
1002
|
+
await this.mattermost.createPost(`⚠️ **Session resume failed** (exit code ${code}). The session data is preserved - try restarting the bot.`, session.threadId);
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
// Ignore if we can't post
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
console.log(` [exit] Session ${shortId}... normal exit, cleaning up`);
|
|
970
1010
|
this.stopTyping(session);
|
|
971
1011
|
if (session.updateTimer) {
|
|
972
1012
|
clearTimeout(session.updateTimer);
|
|
@@ -984,8 +1024,14 @@ export class SessionManager {
|
|
|
984
1024
|
this.postIndex.delete(postId);
|
|
985
1025
|
}
|
|
986
1026
|
}
|
|
987
|
-
//
|
|
988
|
-
|
|
1027
|
+
// Only unpersist for normal exits (code 0 or null means graceful completion)
|
|
1028
|
+
// Non-zero exits might be recoverable, so we keep the session persisted
|
|
1029
|
+
if (code === 0 || code === null) {
|
|
1030
|
+
this.unpersistSession(threadId);
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
console.log(` [exit] Session ${shortId}... non-zero exit, preserving for potential retry`);
|
|
1034
|
+
}
|
|
989
1035
|
console.log(` ■ Session ended (${shortId}…) — ${this.sessions.size} active`);
|
|
990
1036
|
}
|
|
991
1037
|
// ---------------------------------------------------------------------------
|
|
@@ -1011,10 +1057,16 @@ export class SessionManager {
|
|
|
1011
1057
|
this.startTyping(session);
|
|
1012
1058
|
}
|
|
1013
1059
|
/** Kill a specific session */
|
|
1014
|
-
killSession(threadId) {
|
|
1060
|
+
killSession(threadId, unpersist = true) {
|
|
1015
1061
|
const session = this.sessions.get(threadId);
|
|
1016
1062
|
if (!session)
|
|
1017
1063
|
return;
|
|
1064
|
+
const shortId = threadId.substring(0, 8);
|
|
1065
|
+
// Set restarting flag to prevent handleExit from also unpersisting
|
|
1066
|
+
// (we'll do it explicitly here if requested)
|
|
1067
|
+
if (!unpersist) {
|
|
1068
|
+
session.isRestarting = true; // Reuse this flag to skip cleanup in handleExit
|
|
1069
|
+
}
|
|
1018
1070
|
this.stopTyping(session);
|
|
1019
1071
|
session.claude.kill();
|
|
1020
1072
|
// Clean up session from maps
|
|
@@ -1024,7 +1076,10 @@ export class SessionManager {
|
|
|
1024
1076
|
this.postIndex.delete(postId);
|
|
1025
1077
|
}
|
|
1026
1078
|
}
|
|
1027
|
-
|
|
1079
|
+
// Explicitly unpersist if requested (e.g., for timeout, cancel, etc.)
|
|
1080
|
+
if (unpersist) {
|
|
1081
|
+
this.unpersistSession(threadId);
|
|
1082
|
+
}
|
|
1028
1083
|
console.log(` ✖ Session killed (${shortId}…) — ${this.sessions.size} active`);
|
|
1029
1084
|
}
|
|
1030
1085
|
/** Cancel a session with user feedback */
|
|
@@ -1037,6 +1092,22 @@ export class SessionManager {
|
|
|
1037
1092
|
await this.mattermost.createPost(`🛑 **Session cancelled** by @${username}`, threadId);
|
|
1038
1093
|
this.killSession(threadId);
|
|
1039
1094
|
}
|
|
1095
|
+
/** Interrupt current processing but keep session alive (like Escape in CLI) */
|
|
1096
|
+
async interruptSession(threadId, username) {
|
|
1097
|
+
const session = this.sessions.get(threadId);
|
|
1098
|
+
if (!session)
|
|
1099
|
+
return;
|
|
1100
|
+
if (!session.claude.isRunning()) {
|
|
1101
|
+
await this.mattermost.createPost(`ℹ️ Session is idle, nothing to interrupt`, threadId);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const shortId = threadId.substring(0, 8);
|
|
1105
|
+
const interrupted = session.claude.interrupt();
|
|
1106
|
+
if (interrupted) {
|
|
1107
|
+
console.log(` ⏸️ Session (${shortId}…) interrupted by @${username}`);
|
|
1108
|
+
await this.mattermost.createPost(`⏸️ **Interrupted** by @${username} — session still active, you can continue`, threadId);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1040
1111
|
/** Change working directory for a session (restarts Claude CLI) */
|
|
1041
1112
|
async changeDirectory(threadId, newDir, username) {
|
|
1042
1113
|
const session = this.sessions.get(threadId);
|
|
@@ -1230,7 +1301,8 @@ export class SessionManager {
|
|
|
1230
1301
|
const whatsNew = releaseNotes ? getWhatsNewSummary(releaseNotes) : '';
|
|
1231
1302
|
const whatsNewLine = whatsNew ? `\n> ✨ **What's new:** ${whatsNew}\n` : '';
|
|
1232
1303
|
const msg = [
|
|
1233
|
-
|
|
1304
|
+
MATTERMOST_LOGO,
|
|
1305
|
+
`**v${pkg.version}**`,
|
|
1234
1306
|
updateNotice,
|
|
1235
1307
|
whatsNewLine,
|
|
1236
1308
|
`| | |`,
|
|
@@ -1274,12 +1346,33 @@ export class SessionManager {
|
|
|
1274
1346
|
}
|
|
1275
1347
|
/** Kill all active sessions (for graceful shutdown) */
|
|
1276
1348
|
killAllSessions() {
|
|
1277
|
-
|
|
1349
|
+
console.log(` [shutdown] killAllSessions called, isShuttingDown already=${this.isShuttingDown}`);
|
|
1350
|
+
// Set shutdown flag to suppress exit messages (should already be true from setShuttingDown)
|
|
1278
1351
|
this.isShuttingDown = true;
|
|
1279
1352
|
const count = this.sessions.size;
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1353
|
+
console.log(` [shutdown] About to kill ${count} session(s) (preserving persistence for resume)`);
|
|
1354
|
+
// Kill each session WITHOUT unpersisting - we want them to resume after restart
|
|
1355
|
+
for (const [threadId] of this.sessions.entries()) {
|
|
1356
|
+
this.killSession(threadId, false); // false = don't unpersist
|
|
1357
|
+
}
|
|
1358
|
+
// Maps should already be cleared by killSession, but clear again to be safe
|
|
1359
|
+
this.sessions.clear();
|
|
1360
|
+
this.postIndex.clear();
|
|
1361
|
+
if (this.cleanupTimer) {
|
|
1362
|
+
clearInterval(this.cleanupTimer);
|
|
1363
|
+
this.cleanupTimer = null;
|
|
1364
|
+
}
|
|
1365
|
+
if (count > 0) {
|
|
1366
|
+
console.log(` ✖ Killed ${count} session${count === 1 ? '' : 's'} (sessions preserved for resume)`);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/** Kill all sessions AND unpersist them (for emergency shutdown - no resume) */
|
|
1370
|
+
killAllSessionsAndUnpersist() {
|
|
1371
|
+
this.isShuttingDown = true;
|
|
1372
|
+
const count = this.sessions.size;
|
|
1373
|
+
// Kill each session WITH unpersisting - emergency shutdown, no resume
|
|
1374
|
+
for (const [threadId] of this.sessions.entries()) {
|
|
1375
|
+
this.killSession(threadId, true); // true = unpersist
|
|
1283
1376
|
}
|
|
1284
1377
|
this.sessions.clear();
|
|
1285
1378
|
this.postIndex.clear();
|
|
@@ -1288,7 +1381,7 @@ export class SessionManager {
|
|
|
1288
1381
|
this.cleanupTimer = null;
|
|
1289
1382
|
}
|
|
1290
1383
|
if (count > 0) {
|
|
1291
|
-
console.log(`
|
|
1384
|
+
console.log(` 🔴 Emergency killed ${count} session${count === 1 ? '' : 's'} (sessions NOT preserved)`);
|
|
1292
1385
|
}
|
|
1293
1386
|
}
|
|
1294
1387
|
/** Cleanup idle sessions that have exceeded timeout */
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { dirname, resolve } from 'path';
|
|
|
9
9
|
import { fileURLToPath } from 'url';
|
|
10
10
|
import { checkForUpdates } from './update-notifier.js';
|
|
11
11
|
import { getReleaseNotes, formatReleaseNotes } from './changelog.js';
|
|
12
|
+
import { printLogo } from './logo.js';
|
|
12
13
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
14
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
14
15
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
@@ -55,10 +56,11 @@ async function main() {
|
|
|
55
56
|
}
|
|
56
57
|
const workingDir = process.cwd();
|
|
57
58
|
const config = loadConfig(cliArgs);
|
|
58
|
-
//
|
|
59
|
+
// Print ASCII logo
|
|
60
|
+
printLogo();
|
|
61
|
+
// Startup info
|
|
62
|
+
console.log(dim(` v${pkg.version}`));
|
|
59
63
|
console.log('');
|
|
60
|
-
console.log(bold(` 🤖 mm-claude v${pkg.version}`));
|
|
61
|
-
console.log(dim(' ─────────────────────────────────'));
|
|
62
64
|
console.log(` 📂 ${cyan(workingDir)}`);
|
|
63
65
|
console.log(` 💬 ${cyan('@' + config.mattermost.botName)}`);
|
|
64
66
|
console.log(` 🌐 ${dim(config.mattermost.url)}`);
|
|
@@ -97,6 +99,31 @@ async function main() {
|
|
|
97
99
|
}
|
|
98
100
|
return;
|
|
99
101
|
}
|
|
102
|
+
// Check for !escape/!interrupt commands (soft interrupt, keeps session alive)
|
|
103
|
+
if (lowerContent === '!escape' || lowerContent === '!interrupt') {
|
|
104
|
+
if (session.isUserAllowedInSession(threadRoot, username)) {
|
|
105
|
+
await session.interruptSession(threadRoot, username);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Check for !kill command (emergency shutdown - authorized users only)
|
|
110
|
+
if (lowerContent === '!kill') {
|
|
111
|
+
if (!mattermost.isUserAllowed(username)) {
|
|
112
|
+
await mattermost.createPost('⛔ Only authorized users can use `!kill`', threadRoot);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// Notify all active sessions before killing
|
|
116
|
+
for (const tid of session.getActiveThreadIds()) {
|
|
117
|
+
try {
|
|
118
|
+
await mattermost.createPost(`🔴 **EMERGENCY SHUTDOWN** by @${username}`, tid);
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore */ }
|
|
121
|
+
}
|
|
122
|
+
console.log(` 🔴 EMERGENCY SHUTDOWN initiated by @${username}`);
|
|
123
|
+
session.killAllSessionsAndUnpersist();
|
|
124
|
+
mattermost.disconnect();
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
100
127
|
// Check for !help command
|
|
101
128
|
if (lowerContent === '!help' || lowerContent === 'help') {
|
|
102
129
|
await mattermost.createPost(`**Available commands:**\n\n` +
|
|
@@ -108,10 +135,13 @@ async function main() {
|
|
|
108
135
|
`| \`!invite @user\` | Invite a user to this session |\n` +
|
|
109
136
|
`| \`!kick @user\` | Remove an invited user |\n` +
|
|
110
137
|
`| \`!permissions interactive\` | Enable interactive permissions |\n` +
|
|
111
|
-
`| \`!
|
|
138
|
+
`| \`!escape\` | Interrupt current task (session stays active) |\n` +
|
|
139
|
+
`| \`!stop\` | Stop this session |\n` +
|
|
140
|
+
`| \`!kill\` | Emergency shutdown (kills ALL sessions, exits bot) |\n\n` +
|
|
112
141
|
`**Reactions:**\n` +
|
|
113
142
|
`- 👍 Approve action · ✅ Approve all · 👎 Deny\n` +
|
|
114
|
-
`-
|
|
143
|
+
`- ⏸️ Interrupt current task (session stays active)\n` +
|
|
144
|
+
`- ❌ or 🛑 Stop session`, threadRoot);
|
|
115
145
|
return;
|
|
116
146
|
}
|
|
117
147
|
// Check for !release-notes command
|
|
@@ -211,6 +241,8 @@ async function main() {
|
|
|
211
241
|
isShuttingDown = true;
|
|
212
242
|
console.log('');
|
|
213
243
|
console.log(` 👋 ${dim('Shutting down...')}`);
|
|
244
|
+
// Set shutdown flag FIRST to prevent race conditions with exit events
|
|
245
|
+
session.setShuttingDown();
|
|
214
246
|
// Post shutdown message to active sessions
|
|
215
247
|
const activeThreads = session.getActiveThreadIds();
|
|
216
248
|
if (activeThreads.length > 0) {
|
package/dist/logo.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII Art Logo for Mattermost Claude Code
|
|
3
|
+
*
|
|
4
|
+
* Stylized M in Claude Code's block character style.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* ASCII logo for CLI display (with ANSI colors)
|
|
8
|
+
* Stylized M in block characters
|
|
9
|
+
*/
|
|
10
|
+
export declare const CLI_LOGO: string;
|
|
11
|
+
/**
|
|
12
|
+
* ASCII logo for Mattermost (plain text, no ANSI codes)
|
|
13
|
+
*/
|
|
14
|
+
export declare const MATTERMOST_LOGO = "```\n \u2734 \u2590\u2588\u2599 \u259F\u2588\u258C \u2734 mm-claude\n\u2734 \u2590\u2588\u2590\u2588\u258C\u2588\u258C \u2734 Mattermost \u00D7 Claude Code\n \u2734 \u2590\u2588\u258C \u2590\u2588\u258C \u2734\n```";
|
|
15
|
+
/**
|
|
16
|
+
* Compact inline logo for Mattermost headers
|
|
17
|
+
*/
|
|
18
|
+
export declare const MATTERMOST_LOGO_INLINE = "`\u2590\u259BM\u259C\u258C` **mm-claude**";
|
|
19
|
+
/**
|
|
20
|
+
* Very compact logo for space-constrained contexts
|
|
21
|
+
*/
|
|
22
|
+
export declare const LOGO_COMPACT = "\u2590\u259BM\u259C\u258C mm-claude";
|
|
23
|
+
/**
|
|
24
|
+
* Print CLI logo to stdout
|
|
25
|
+
*/
|
|
26
|
+
export declare function printLogo(): void;
|
package/dist/logo.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASCII Art Logo for Mattermost Claude Code
|
|
3
|
+
*
|
|
4
|
+
* Stylized M in Claude Code's block character style.
|
|
5
|
+
*/
|
|
6
|
+
// ANSI color codes for terminal
|
|
7
|
+
const colors = {
|
|
8
|
+
reset: '\x1b[0m',
|
|
9
|
+
bold: '\x1b[1m',
|
|
10
|
+
dim: '\x1b[2m',
|
|
11
|
+
// Mattermost blue (#1C58D9)
|
|
12
|
+
blue: '\x1b[38;5;27m',
|
|
13
|
+
// Claude orange/coral
|
|
14
|
+
orange: '\x1b[38;5;209m',
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* ASCII logo for CLI display (with ANSI colors)
|
|
18
|
+
* Stylized M in block characters
|
|
19
|
+
*/
|
|
20
|
+
export const CLI_LOGO = `
|
|
21
|
+
${colors.orange} ✴${colors.reset} ${colors.blue}▐█▙ ▟█▌${colors.reset} ${colors.orange}✴${colors.reset} ${colors.bold}mm-claude${colors.reset}
|
|
22
|
+
${colors.orange}✴${colors.reset} ${colors.blue}▐█▐█▌█▌${colors.reset} ${colors.orange}✴${colors.reset} ${colors.dim}Mattermost × Claude Code${colors.reset}
|
|
23
|
+
${colors.orange}✴${colors.reset} ${colors.blue}▐█▌ ▐█▌${colors.reset} ${colors.orange}✴${colors.reset}
|
|
24
|
+
`;
|
|
25
|
+
/**
|
|
26
|
+
* ASCII logo for Mattermost (plain text, no ANSI codes)
|
|
27
|
+
*/
|
|
28
|
+
export const MATTERMOST_LOGO = `\`\`\`
|
|
29
|
+
✴ ▐█▙ ▟█▌ ✴ mm-claude
|
|
30
|
+
✴ ▐█▐█▌█▌ ✴ Mattermost × Claude Code
|
|
31
|
+
✴ ▐█▌ ▐█▌ ✴
|
|
32
|
+
\`\`\``;
|
|
33
|
+
/**
|
|
34
|
+
* Compact inline logo for Mattermost headers
|
|
35
|
+
*/
|
|
36
|
+
export const MATTERMOST_LOGO_INLINE = '`▐▛M▜▌` **mm-claude**';
|
|
37
|
+
/**
|
|
38
|
+
* Very compact logo for space-constrained contexts
|
|
39
|
+
*/
|
|
40
|
+
export const LOGO_COMPACT = '▐▛M▜▌ mm-claude';
|
|
41
|
+
/**
|
|
42
|
+
* Print CLI logo to stdout
|
|
43
|
+
*/
|
|
44
|
+
export function printLogo() {
|
|
45
|
+
console.log(CLI_LOGO);
|
|
46
|
+
}
|