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 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
@@ -35,5 +35,7 @@ export declare class ClaudeCli extends EventEmitter {
35
35
  private parseOutput;
36
36
  isRunning(): boolean;
37
37
  kill(): void;
38
+ /** Interrupt current processing (like Escape in CLI) - keeps process alive */
39
+ interrupt(): boolean;
38
40
  private getMcpServerPath;
39
41
  }
@@ -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
@@ -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
  }
@@ -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
- if (this.debug) {
199
- const shortId = session.threadId.substring(0, 8);
200
- console.log(` [persist] Saved session ${shortId}... (claudeId: ${session.claudeSessionId.substring(0, 8)}...)`);
201
- }
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
- if (this.debug) {
208
- const shortId = threadId.substring(0, 8);
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(`### 🤖 mm-claude \`v${pkg.version}\`\n\n*Starting session...*`, replyToPostId);
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, silently ignore subsequent ExitPlanMode calls
413
- // (Don't send another message - that causes Claude to loop)
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, ignoring ExitPlanMode');
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 as follow-up message
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
- // Clear and send as regular message
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.sendMessage(answersText);
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.postId;
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 response to Claude
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.sendMessage(response);
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
- if (this.debug)
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
- if (this.debug)
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
- if (this.debug)
965
- console.log(` [exit] Session ${shortId}... bot shutting down, preserving persistence`);
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
- if (this.debug)
969
- console.log(` [exit] Session ${shortId}... exited with code ${code}, cleaning up`);
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
- // Remove from persistence when session ends normally
988
- this.unpersistSession(threadId);
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
- const shortId = threadId.substring(0, 8);
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
- `### 🤖 mm-claude \`v${pkg.version}\``,
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
- // Set shutdown flag to suppress exit messages
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
- for (const [, session] of this.sessions.entries()) {
1281
- this.stopTyping(session);
1282
- session.claude.kill();
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(` Killed ${count} session${count === 1 ? '' : 's'}`);
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
- // Nice startup banner
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
- `| \`!stop\` | Stop this session |\n\n` +
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
- `- or 🛑 on any message to stop session`, threadRoot);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mattermost-claude-code",
3
- "version": "0.9.2",
3
+ "version": "0.10.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",