mattermost-claude-code 0.10.2 β 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 +17 -0
- package/dist/claude/session.d.ts +17 -0
- package/dist/claude/session.js +95 -6
- package/dist/index.js +27 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ 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
|
+
|
|
10
27
|
## [0.10.2] - 2025-12-28
|
|
11
28
|
|
|
12
29
|
### Changed
|
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;
|
|
@@ -59,6 +60,8 @@ interface Session {
|
|
|
59
60
|
isRestarting: boolean;
|
|
60
61
|
isResumed: boolean;
|
|
61
62
|
wasInterrupted: boolean;
|
|
63
|
+
inProgressTaskStart: number | null;
|
|
64
|
+
activeToolStarts: Map<string, number>;
|
|
62
65
|
}
|
|
63
66
|
export declare class SessionManager {
|
|
64
67
|
private mattermost;
|
|
@@ -141,6 +144,20 @@ export declare class SessionManager {
|
|
|
141
144
|
isInSessionThread(threadRoot: string): boolean;
|
|
142
145
|
/** Send a follow-up message to an existing session */
|
|
143
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;
|
|
144
161
|
/** Kill a specific session */
|
|
145
162
|
killSession(threadId: string, unpersist?: boolean): void;
|
|
146
163
|
/** Cancel a session with user feedback */
|
package/dist/claude/session.js
CHANGED
|
@@ -146,6 +146,8 @@ export class SessionManager {
|
|
|
146
146
|
isRestarting: false,
|
|
147
147
|
isResumed: true,
|
|
148
148
|
wasInterrupted: false,
|
|
149
|
+
inProgressTaskStart: null,
|
|
150
|
+
activeToolStarts: new Map(),
|
|
149
151
|
};
|
|
150
152
|
// Register session
|
|
151
153
|
this.sessions.set(state.threadId, session);
|
|
@@ -322,6 +324,8 @@ export class SessionManager {
|
|
|
322
324
|
isRestarting: false,
|
|
323
325
|
isResumed: false,
|
|
324
326
|
wasInterrupted: false,
|
|
327
|
+
inProgressTaskStart: null,
|
|
328
|
+
activeToolStarts: new Map(),
|
|
325
329
|
};
|
|
326
330
|
// Register session
|
|
327
331
|
this.sessions.set(actualThreadId, session);
|
|
@@ -469,8 +473,20 @@ export class SessionManager {
|
|
|
469
473
|
}
|
|
470
474
|
return;
|
|
471
475
|
}
|
|
472
|
-
//
|
|
473
|
-
|
|
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`;
|
|
474
490
|
for (const todo of todos) {
|
|
475
491
|
let icon;
|
|
476
492
|
let text;
|
|
@@ -479,12 +495,21 @@ export class SessionManager {
|
|
|
479
495
|
icon = 'β
';
|
|
480
496
|
text = `~~${todo.content}~~`;
|
|
481
497
|
break;
|
|
482
|
-
case 'in_progress':
|
|
498
|
+
case 'in_progress': {
|
|
483
499
|
icon = 'π';
|
|
484
|
-
|
|
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}`;
|
|
485
509
|
break;
|
|
510
|
+
}
|
|
486
511
|
default: // pending
|
|
487
|
-
icon = '
|
|
512
|
+
icon = 'β';
|
|
488
513
|
text = todo.content;
|
|
489
514
|
}
|
|
490
515
|
message += `${icon} ${text}\n`;
|
|
@@ -770,12 +795,30 @@ export class SessionManager {
|
|
|
770
795
|
}
|
|
771
796
|
case 'tool_use': {
|
|
772
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
|
+
}
|
|
773
802
|
return this.formatToolUse(tool.name, tool.input || {}) || null;
|
|
774
803
|
}
|
|
775
804
|
case 'tool_result': {
|
|
776
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
|
+
}
|
|
777
818
|
if (result.is_error)
|
|
778
|
-
return ` β³ β Error`;
|
|
819
|
+
return ` β³ β Error${elapsed}`;
|
|
820
|
+
if (elapsed)
|
|
821
|
+
return ` β³ β${elapsed}`;
|
|
779
822
|
return null;
|
|
780
823
|
}
|
|
781
824
|
case 'result': {
|
|
@@ -1089,6 +1132,52 @@ export class SessionManager {
|
|
|
1089
1132
|
session.lastActivityAt = new Date();
|
|
1090
1133
|
this.startTyping(session);
|
|
1091
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
|
+
}
|
|
1092
1181
|
/** Kill a specific session */
|
|
1093
1182
|
killSession(threadId, unpersist = true) {
|
|
1094
1183
|
const session = this.sessions.get(threadId);
|
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;
|