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 +30 -0
- package/dist/claude/session.d.ts +18 -0
- package/dist/claude/session.js +135 -12
- package/dist/index.js +27 -0
- package/dist/logo.d.ts +5 -0
- package/dist/logo.js +11 -0
- package/package.json +1 -1
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
|
package/dist/claude/session.d.ts
CHANGED
|
@@ -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 */
|
package/dist/claude/session.js
CHANGED
|
@@ -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 {
|
|
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(`${
|
|
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
|
-
//
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
*/
|