mattermost-claude-code 0.10.1 β†’ 0.10.3

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
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.3] - 2025-12-28
11
+
12
+ ### Changed
13
+ - **Improved task list UX**
14
+ - Progress indicator: `πŸ“‹ **Tasks** (2/5 Β· 40%)`
15
+ - Elapsed time for in-progress tasks: `πŸ”„ **Running tests...** (45s)`
16
+ - Better pending icon: `β—‹` instead of `⬜` (no longer overlaps)
17
+ - **Tool output now shows elapsed time**
18
+ - Long-running tools (β‰₯3s) show completion time: `↳ βœ“ (12s)`
19
+ - Errors also show timing: `↳ ❌ Error (5s)`
20
+
21
+ ### Fixed
22
+ - **Paused sessions now resume on new message** - messages to paused sessions were being ignored
23
+ - After ⏸️ interrupt, sending a new message in the thread now resumes the session
24
+ - Previously messages without @mention were ignored because the session was removed from memory
25
+ - Added `hasPausedSession()`, `resumePausedSession()`, and `getPersistedSession()` methods
26
+
27
+ ## [0.10.2] - 2025-12-28
28
+
29
+ ### Changed
30
+ - Version number now displays directly after "mm-claude" in the logo instead of on a separate line
31
+
32
+ ### Fixed
33
+ - **Interrupt (⏸️) no longer kills session** - sessions now pause and can be resumed
34
+ - Previously SIGINT caused Claude CLI to exit and the session was lost
35
+ - Now session is preserved and user can send a new message to continue
36
+ - Works with both ⏸️ reaction and `!escape`/`!interrupt` commands
37
+ - **Filter `<thinking>` tags from output** - Claude's internal thinking is no longer shown to users
38
+ - Previously `<thinking>...</thinking>` tags would appear literally in Mattermost messages
39
+
10
40
  ## [0.10.1] - 2025-12-28
11
41
 
12
42
  ### Fixed
@@ -1,6 +1,7 @@
1
1
  import { ClaudeCli } from './cli.js';
2
2
  import { MattermostClient } from '../mattermost/client.js';
3
3
  import { MattermostFile } from '../mattermost/types.js';
4
+ import { PersistedSession } from '../persistence/session-store.js';
4
5
  interface QuestionOption {
5
6
  label: string;
6
7
  description: string;
@@ -58,6 +59,9 @@ interface Session {
58
59
  timeoutWarningPosted: boolean;
59
60
  isRestarting: boolean;
60
61
  isResumed: boolean;
62
+ wasInterrupted: boolean;
63
+ inProgressTaskStart: number | null;
64
+ activeToolStarts: Map<string, number>;
61
65
  }
62
66
  export declare class SessionManager {
63
67
  private mattermost;
@@ -140,6 +144,20 @@ export declare class SessionManager {
140
144
  isInSessionThread(threadRoot: string): boolean;
141
145
  /** Send a follow-up message to an existing session */
142
146
  sendFollowUp(threadId: string, message: string, files?: MattermostFile[]): Promise<void>;
147
+ /**
148
+ * Check if there's a paused (persisted but not active) session for this thread.
149
+ * This is used to detect when we should resume a session instead of ignoring the message.
150
+ */
151
+ hasPausedSession(threadId: string): boolean;
152
+ /**
153
+ * Resume a paused session and send a message to it.
154
+ * Called when a user sends a message to a thread with a paused session.
155
+ */
156
+ resumePausedSession(threadId: string, message: string, files?: MattermostFile[]): Promise<void>;
157
+ /**
158
+ * Get persisted session info for access control checks
159
+ */
160
+ getPersistedSession(threadId: string): PersistedSession | undefined;
143
161
  /** Kill a specific session */
144
162
  killSession(threadId: string, unpersist?: boolean): void;
145
163
  /** Cancel a session with user feedback */
@@ -2,7 +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
+ import { getMattermostLogo } from '../logo.js';
6
6
  import { randomUUID } from 'crypto';
7
7
  import { readFileSync } from 'fs';
8
8
  import { dirname, resolve } from 'path';
@@ -145,6 +145,9 @@ export class SessionManager {
145
145
  timeoutWarningPosted: false,
146
146
  isRestarting: false,
147
147
  isResumed: true,
148
+ wasInterrupted: false,
149
+ inProgressTaskStart: null,
150
+ activeToolStarts: new Map(),
148
151
  };
149
152
  // Register session
150
153
  this.sessions.set(state.threadId, session);
@@ -275,7 +278,7 @@ export class SessionManager {
275
278
  // Post initial session message (will be updated by updateSessionHeader)
276
279
  let post;
277
280
  try {
278
- post = await this.mattermost.createPost(`${MATTERMOST_LOGO}\n**v${pkg.version}**\n\n*Starting session...*`, replyToPostId);
281
+ post = await this.mattermost.createPost(`${getMattermostLogo(pkg.version)}\n\n*Starting session...*`, replyToPostId);
279
282
  }
280
283
  catch (err) {
281
284
  console.error(` ❌ Failed to create session post:`, err);
@@ -320,6 +323,9 @@ export class SessionManager {
320
323
  timeoutWarningPosted: false,
321
324
  isRestarting: false,
322
325
  isResumed: false,
326
+ wasInterrupted: false,
327
+ inProgressTaskStart: null,
328
+ activeToolStarts: new Map(),
323
329
  };
324
330
  // Register session
325
331
  this.sessions.set(actualThreadId, session);
@@ -467,8 +473,20 @@ export class SessionManager {
467
473
  }
468
474
  return;
469
475
  }
470
- // Format tasks nicely
471
- let message = 'πŸ“‹ **Tasks**\n\n';
476
+ // Count progress
477
+ const completed = todos.filter(t => t.status === 'completed').length;
478
+ const total = todos.length;
479
+ const pct = Math.round((completed / total) * 100);
480
+ // Check if there's an in_progress task and track timing
481
+ const hasInProgress = todos.some(t => t.status === 'in_progress');
482
+ if (hasInProgress && !session.inProgressTaskStart) {
483
+ session.inProgressTaskStart = Date.now();
484
+ }
485
+ else if (!hasInProgress) {
486
+ session.inProgressTaskStart = null;
487
+ }
488
+ // Format tasks nicely with progress header
489
+ let message = `πŸ“‹ **Tasks** (${completed}/${total} Β· ${pct}%)\n\n`;
472
490
  for (const todo of todos) {
473
491
  let icon;
474
492
  let text;
@@ -477,12 +495,21 @@ export class SessionManager {
477
495
  icon = 'βœ…';
478
496
  text = `~~${todo.content}~~`;
479
497
  break;
480
- case 'in_progress':
498
+ case 'in_progress': {
481
499
  icon = 'πŸ”„';
482
- text = `**${todo.activeForm}**`;
500
+ // Add elapsed time if we have a start time
501
+ let elapsed = '';
502
+ if (session.inProgressTaskStart) {
503
+ const secs = Math.round((Date.now() - session.inProgressTaskStart) / 1000);
504
+ if (secs >= 5) { // Only show if >= 5 seconds
505
+ elapsed = ` (${secs}s)`;
506
+ }
507
+ }
508
+ text = `**${todo.activeForm}**${elapsed}`;
483
509
  break;
510
+ }
484
511
  default: // pending
485
- icon = '⬜';
512
+ icon = 'β—‹';
486
513
  text = todo.content;
487
514
  }
488
515
  message += `${icon} ${text}\n`;
@@ -743,7 +770,10 @@ export class SessionManager {
743
770
  const parts = [];
744
771
  for (const block of msg?.content || []) {
745
772
  if (block.type === 'text' && block.text) {
746
- parts.push(block.text);
773
+ // Filter out <thinking> tags that may appear in text content
774
+ const text = block.text.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim();
775
+ if (text)
776
+ parts.push(text);
747
777
  }
748
778
  else if (block.type === 'tool_use' && block.name) {
749
779
  const formatted = this.formatToolUse(block.name, block.input || {});
@@ -765,12 +795,30 @@ export class SessionManager {
765
795
  }
766
796
  case 'tool_use': {
767
797
  const tool = e.tool_use;
798
+ // Track tool start time for elapsed display
799
+ if (tool.id) {
800
+ session.activeToolStarts.set(tool.id, Date.now());
801
+ }
768
802
  return this.formatToolUse(tool.name, tool.input || {}) || null;
769
803
  }
770
804
  case 'tool_result': {
771
805
  const result = e.tool_result;
806
+ // Calculate elapsed time
807
+ let elapsed = '';
808
+ if (result.tool_use_id) {
809
+ const startTime = session.activeToolStarts.get(result.tool_use_id);
810
+ if (startTime) {
811
+ const secs = Math.round((Date.now() - startTime) / 1000);
812
+ if (secs >= 3) { // Only show if >= 3 seconds
813
+ elapsed = ` (${secs}s)`;
814
+ }
815
+ session.activeToolStarts.delete(result.tool_use_id);
816
+ }
817
+ }
772
818
  if (result.is_error)
773
- return ` ↳ ❌ Error`;
819
+ return ` ↳ ❌ Error${elapsed}`;
820
+ if (elapsed)
821
+ return ` ↳ βœ“${elapsed}`;
774
822
  return null;
775
823
  }
776
824
  case 'result': {
@@ -987,6 +1035,34 @@ export class SessionManager {
987
1035
  this.sessions.delete(threadId);
988
1036
  return;
989
1037
  }
1038
+ // If session was interrupted (SIGINT sent), preserve for resume
1039
+ // Claude CLI exits on SIGINT, but we want to allow resuming the session
1040
+ if (session.wasInterrupted) {
1041
+ console.log(` [exit] Session ${shortId}... exited after interrupt, preserving for resume`);
1042
+ this.stopTyping(session);
1043
+ if (session.updateTimer) {
1044
+ clearTimeout(session.updateTimer);
1045
+ session.updateTimer = null;
1046
+ }
1047
+ // Update persistence with current state before cleanup
1048
+ this.persistSession(session);
1049
+ this.sessions.delete(threadId);
1050
+ // Clean up post index
1051
+ for (const [postId, tid] of this.postIndex.entries()) {
1052
+ if (tid === threadId) {
1053
+ this.postIndex.delete(postId);
1054
+ }
1055
+ }
1056
+ // Notify user they can send a new message to resume
1057
+ try {
1058
+ await this.mattermost.createPost(`ℹ️ Session paused. Send a new message to continue.`, session.threadId);
1059
+ }
1060
+ catch {
1061
+ // Ignore if we can't post
1062
+ }
1063
+ console.log(` ⏸️ Session paused (${shortId}…) β€” ${this.sessions.size} active`);
1064
+ return;
1065
+ }
990
1066
  // For resumed sessions that exit quickly (e.g., Claude --resume fails),
991
1067
  // don't unpersist immediately - give it a chance to be retried
992
1068
  if (session.isResumed && code !== 0) {
@@ -1056,6 +1132,52 @@ export class SessionManager {
1056
1132
  session.lastActivityAt = new Date();
1057
1133
  this.startTyping(session);
1058
1134
  }
1135
+ /**
1136
+ * Check if there's a paused (persisted but not active) session for this thread.
1137
+ * This is used to detect when we should resume a session instead of ignoring the message.
1138
+ */
1139
+ hasPausedSession(threadId) {
1140
+ // If there's an active session, it's not paused
1141
+ if (this.sessions.has(threadId))
1142
+ return false;
1143
+ // Check persistence
1144
+ const persisted = this.sessionStore.load();
1145
+ return persisted.has(threadId);
1146
+ }
1147
+ /**
1148
+ * Resume a paused session and send a message to it.
1149
+ * Called when a user sends a message to a thread with a paused session.
1150
+ */
1151
+ async resumePausedSession(threadId, message, files) {
1152
+ const persisted = this.sessionStore.load();
1153
+ const state = persisted.get(threadId);
1154
+ if (!state) {
1155
+ console.log(` [resume] No persisted session found for ${threadId.substring(0, 8)}...`);
1156
+ return;
1157
+ }
1158
+ const shortId = threadId.substring(0, 8);
1159
+ console.log(` πŸ”„ Resuming paused session ${shortId}... for new message`);
1160
+ // Resume the session (similar to initialize() but for a single session)
1161
+ await this.resumeSession(state);
1162
+ // Wait a moment for the session to be ready, then send the message
1163
+ const session = this.sessions.get(threadId);
1164
+ if (session && session.claude.isRunning()) {
1165
+ const content = await this.buildMessageContent(message, files);
1166
+ session.claude.sendMessage(content);
1167
+ session.lastActivityAt = new Date();
1168
+ this.startTyping(session);
1169
+ }
1170
+ else {
1171
+ console.log(` ⚠️ Failed to resume session ${shortId}..., could not send message`);
1172
+ }
1173
+ }
1174
+ /**
1175
+ * Get persisted session info for access control checks
1176
+ */
1177
+ getPersistedSession(threadId) {
1178
+ const persisted = this.sessionStore.load();
1179
+ return persisted.get(threadId);
1180
+ }
1059
1181
  /** Kill a specific session */
1060
1182
  killSession(threadId, unpersist = true) {
1061
1183
  const session = this.sessions.get(threadId);
@@ -1102,10 +1224,12 @@ export class SessionManager {
1102
1224
  return;
1103
1225
  }
1104
1226
  const shortId = threadId.substring(0, 8);
1227
+ // Set flag BEFORE interrupt - if Claude exits due to SIGINT, we won't unpersist
1228
+ session.wasInterrupted = true;
1105
1229
  const interrupted = session.claude.interrupt();
1106
1230
  if (interrupted) {
1107
1231
  console.log(` ⏸️ Session (${shortId}…) interrupted by @${username}`);
1108
- await this.mattermost.createPost(`⏸️ **Interrupted** by @${username} β€” session still active, you can continue`, threadId);
1232
+ await this.mattermost.createPost(`⏸️ **Interrupted** by @${username}`, threadId);
1109
1233
  }
1110
1234
  }
1111
1235
  /** Change working directory for a session (restarts Claude CLI) */
@@ -1301,8 +1425,7 @@ export class SessionManager {
1301
1425
  const whatsNew = releaseNotes ? getWhatsNewSummary(releaseNotes) : '';
1302
1426
  const whatsNewLine = whatsNew ? `\n> ✨ **What's new:** ${whatsNew}\n` : '';
1303
1427
  const msg = [
1304
- MATTERMOST_LOGO,
1305
- `**v${pkg.version}**`,
1428
+ getMattermostLogo(pkg.version),
1306
1429
  updateNotice,
1307
1430
  whatsNewLine,
1308
1431
  `| | |`,
package/dist/index.js CHANGED
@@ -201,6 +201,33 @@ async function main() {
201
201
  await session.sendFollowUp(threadRoot, content, files);
202
202
  return;
203
203
  }
204
+ // Check for paused session that can be resumed
205
+ if (session.hasPausedSession(threadRoot)) {
206
+ // If message starts with @mention to someone else, ignore it (side conversation)
207
+ const mentionMatch = message.trim().match(/^@([\w.-]+)/);
208
+ if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
209
+ return; // Side conversation, don't interrupt
210
+ }
211
+ const content = mattermost.isBotMentioned(message)
212
+ ? mattermost.extractPrompt(message)
213
+ : message.trim();
214
+ // Check if user is allowed in the paused session
215
+ const persistedSession = session.getPersistedSession(threadRoot);
216
+ if (persistedSession) {
217
+ const allowedUsers = new Set(persistedSession.sessionAllowedUsers);
218
+ if (!allowedUsers.has(username) && !mattermost.isUserAllowed(username)) {
219
+ // Not allowed - could request approval but that would require the session to be active
220
+ await mattermost.createPost(`⚠️ @${username} is not authorized to resume this session`, threadRoot);
221
+ return;
222
+ }
223
+ }
224
+ // Get any attached files (images)
225
+ const files = post.metadata?.files;
226
+ if (content || files?.length) {
227
+ await session.resumePausedSession(threadRoot, content, files);
228
+ }
229
+ return;
230
+ }
204
231
  // New session requires @mention
205
232
  if (!mattermost.isBotMentioned(message))
206
233
  return;
package/dist/logo.d.ts CHANGED
@@ -10,8 +10,13 @@
10
10
  export declare const CLI_LOGO: string;
11
11
  /**
12
12
  * ASCII logo for Mattermost (plain text, no ANSI codes)
13
+ * Use getMattermostLogo(version) instead to include version
13
14
  */
14
15
  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```";
16
+ /**
17
+ * Get ASCII logo for Mattermost with version included
18
+ */
19
+ export declare function getMattermostLogo(version: string): string;
15
20
  /**
16
21
  * Compact inline logo for Mattermost headers
17
22
  */
package/dist/logo.js CHANGED
@@ -24,12 +24,23 @@ ${colors.orange}✴${colors.reset} ${colors.blue}β–β–ˆβ–β–ˆβ–Œβ–ˆβ–Œ${colors.
24
24
  `;
25
25
  /**
26
26
  * ASCII logo for Mattermost (plain text, no ANSI codes)
27
+ * Use getMattermostLogo(version) instead to include version
27
28
  */
28
29
  export const MATTERMOST_LOGO = `\`\`\`
29
30
  ✴ β–β–ˆβ–™ β–Ÿβ–ˆβ–Œ ✴ mm-claude
30
31
  ✴ β–β–ˆβ–β–ˆβ–Œβ–ˆβ–Œ ✴ Mattermost Γ— Claude Code
31
32
  ✴ β–β–ˆβ–Œ β–β–ˆβ–Œ ✴
32
33
  \`\`\``;
34
+ /**
35
+ * Get ASCII logo for Mattermost with version included
36
+ */
37
+ export function getMattermostLogo(version) {
38
+ return `\`\`\`
39
+ ✴ β–β–ˆβ–™ β–Ÿβ–ˆβ–Œ ✴ mm-claude v${version}
40
+ ✴ β–β–ˆβ–β–ˆβ–Œβ–ˆβ–Œ ✴ Mattermost Γ— Claude Code
41
+ ✴ β–β–ˆβ–Œ β–β–ˆβ–Œ ✴
42
+ \`\`\``;
43
+ }
33
44
  /**
34
45
  * Compact inline logo for Mattermost headers
35
46
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mattermost-claude-code",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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",