mattermost-claude-code 0.4.0 → 0.5.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/dist/claude/session.d.ts +26 -0
- package/dist/claude/session.js +171 -20
- package/dist/index.js +28 -4
- package/package.json +1 -1
package/dist/claude/session.d.ts
CHANGED
|
@@ -19,6 +19,14 @@ interface PendingApproval {
|
|
|
19
19
|
postId: string;
|
|
20
20
|
type: 'plan' | 'action';
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Pending message from unauthorized user awaiting approval
|
|
24
|
+
*/
|
|
25
|
+
interface PendingMessageApproval {
|
|
26
|
+
postId: string;
|
|
27
|
+
originalMessage: string;
|
|
28
|
+
fromUser: string;
|
|
29
|
+
}
|
|
22
30
|
/**
|
|
23
31
|
* Represents a single Claude Code session tied to a Mattermost thread.
|
|
24
32
|
* Each session has its own Claude CLI process and state.
|
|
@@ -28,12 +36,16 @@ interface Session {
|
|
|
28
36
|
startedBy: string;
|
|
29
37
|
startedAt: Date;
|
|
30
38
|
lastActivityAt: Date;
|
|
39
|
+
sessionNumber: number;
|
|
31
40
|
claude: ClaudeCli;
|
|
32
41
|
currentPostId: string | null;
|
|
33
42
|
pendingContent: string;
|
|
34
43
|
pendingApproval: PendingApproval | null;
|
|
35
44
|
pendingQuestionSet: PendingQuestionSet | null;
|
|
45
|
+
pendingMessageApproval: PendingMessageApproval | null;
|
|
36
46
|
planApproved: boolean;
|
|
47
|
+
sessionAllowedUsers: Set<string>;
|
|
48
|
+
sessionStartPostId: string | null;
|
|
37
49
|
tasksPostId: string | null;
|
|
38
50
|
activeSubagents: Map<string, string>;
|
|
39
51
|
updateTimer: ReturnType<typeof setTimeout> | null;
|
|
@@ -58,6 +70,11 @@ export declare class SessionManager {
|
|
|
58
70
|
private registerPost;
|
|
59
71
|
/** Find session by post ID (for reaction routing) */
|
|
60
72
|
private getSessionByPost;
|
|
73
|
+
/**
|
|
74
|
+
* Check if a user is allowed in a specific session.
|
|
75
|
+
* Checks global allowlist first, then session-specific allowlist.
|
|
76
|
+
*/
|
|
77
|
+
isUserAllowedInSession(threadId: string, username: string): boolean;
|
|
61
78
|
startSession(options: {
|
|
62
79
|
prompt: string;
|
|
63
80
|
}, username: string, replyToPostId?: string): Promise<void>;
|
|
@@ -71,6 +88,7 @@ export declare class SessionManager {
|
|
|
71
88
|
private handleReaction;
|
|
72
89
|
private handleQuestionReaction;
|
|
73
90
|
private handleApprovalReaction;
|
|
91
|
+
private handleMessageApprovalReaction;
|
|
74
92
|
private formatEvent;
|
|
75
93
|
private formatToolUse;
|
|
76
94
|
private appendContent;
|
|
@@ -89,6 +107,14 @@ export declare class SessionManager {
|
|
|
89
107
|
killSession(threadId: string): void;
|
|
90
108
|
/** Cancel a session with user feedback */
|
|
91
109
|
cancelSession(threadId: string, username: string): Promise<void>;
|
|
110
|
+
/** Invite a user to participate in a specific session */
|
|
111
|
+
inviteUser(threadId: string, invitedUser: string, invitedBy: string): Promise<void>;
|
|
112
|
+
/** Kick a user from a specific session */
|
|
113
|
+
kickUser(threadId: string, kickedUser: string, kickedBy: string): Promise<void>;
|
|
114
|
+
/** Update the session header post with current participants */
|
|
115
|
+
private updateSessionHeader;
|
|
116
|
+
/** Request approval for a message from an unauthorized user */
|
|
117
|
+
requestMessageApproval(threadId: string, username: string, message: string): Promise<void>;
|
|
92
118
|
/** Kill all active sessions (for graceful shutdown) */
|
|
93
119
|
killAllSessions(): void;
|
|
94
120
|
/** Cleanup idle sessions that have exceeded timeout */
|
package/dist/claude/session.js
CHANGED
|
@@ -65,6 +65,20 @@ export class SessionManager {
|
|
|
65
65
|
const threadId = this.postIndex.get(postId);
|
|
66
66
|
return threadId ? this.sessions.get(threadId) : undefined;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if a user is allowed in a specific session.
|
|
70
|
+
* Checks global allowlist first, then session-specific allowlist.
|
|
71
|
+
*/
|
|
72
|
+
isUserAllowedInSession(threadId, username) {
|
|
73
|
+
// Check global allowlist first
|
|
74
|
+
if (this.mattermost.isUserAllowed(username))
|
|
75
|
+
return true;
|
|
76
|
+
// Check session-specific allowlist
|
|
77
|
+
const session = this.sessions.get(threadId);
|
|
78
|
+
if (session?.sessionAllowedUsers.has(username))
|
|
79
|
+
return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
68
82
|
// ---------------------------------------------------------------------------
|
|
69
83
|
// Session Lifecycle
|
|
70
84
|
// ---------------------------------------------------------------------------
|
|
@@ -82,26 +96,8 @@ export class SessionManager {
|
|
|
82
96
|
await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
|
|
83
97
|
return;
|
|
84
98
|
}
|
|
85
|
-
// Post session
|
|
86
|
-
const
|
|
87
|
-
const sessionNum = this.sessions.size + 1;
|
|
88
|
-
const permMode = this.skipPermissions ? '⚡ Auto' : '🔐 Interactive';
|
|
89
|
-
const promptPreview = options.prompt.length > 60
|
|
90
|
-
? options.prompt.substring(0, 60) + '…'
|
|
91
|
-
: options.prompt;
|
|
92
|
-
const msg = [
|
|
93
|
-
`### 🤖 mm-claude \`v${pkg.version}\``,
|
|
94
|
-
``,
|
|
95
|
-
`| | |`,
|
|
96
|
-
`|:--|:--|`,
|
|
97
|
-
`| 📂 **Directory** | \`${shortDir}\` |`,
|
|
98
|
-
`| 👤 **Started by** | @${username} |`,
|
|
99
|
-
`| 🔢 **Session** | #${sessionNum} of ${MAX_SESSIONS} max |`,
|
|
100
|
-
`| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`,
|
|
101
|
-
``,
|
|
102
|
-
`> ${promptPreview}`,
|
|
103
|
-
].join('\n');
|
|
104
|
-
const post = await this.mattermost.createPost(msg, replyToPostId);
|
|
99
|
+
// Post initial session message (will be updated by updateSessionHeader)
|
|
100
|
+
const post = await this.mattermost.createPost(`### 🤖 mm-claude \`v${pkg.version}\`\n\n*Starting session...*`, replyToPostId);
|
|
105
101
|
const actualThreadId = replyToPostId || post.id;
|
|
106
102
|
// Create Claude CLI with options
|
|
107
103
|
const cliOptions = {
|
|
@@ -116,12 +112,16 @@ export class SessionManager {
|
|
|
116
112
|
startedBy: username,
|
|
117
113
|
startedAt: new Date(),
|
|
118
114
|
lastActivityAt: new Date(),
|
|
115
|
+
sessionNumber: this.sessions.size + 1,
|
|
119
116
|
claude,
|
|
120
117
|
currentPostId: null,
|
|
121
118
|
pendingContent: '',
|
|
122
119
|
pendingApproval: null,
|
|
123
120
|
pendingQuestionSet: null,
|
|
121
|
+
pendingMessageApproval: null,
|
|
124
122
|
planApproved: false,
|
|
123
|
+
sessionAllowedUsers: new Set([username]), // Owner is always allowed
|
|
124
|
+
sessionStartPostId: post.id, // Track for updating participants
|
|
125
125
|
tasksPostId: null,
|
|
126
126
|
activeSubagents: new Map(),
|
|
127
127
|
updateTimer: null,
|
|
@@ -132,6 +132,8 @@ export class SessionManager {
|
|
|
132
132
|
this.registerPost(post.id, actualThreadId); // For cancel reactions on session start post
|
|
133
133
|
const shortId = actualThreadId.substring(0, 8);
|
|
134
134
|
console.log(` ▶ Session #${this.sessions.size} started (${shortId}…) by @${username}`);
|
|
135
|
+
// Update the header with full session info
|
|
136
|
+
await this.updateSessionHeader(session);
|
|
135
137
|
// Start typing indicator immediately so user sees activity
|
|
136
138
|
this.startTyping(session);
|
|
137
139
|
// Bind event handlers with closure over threadId
|
|
@@ -404,6 +406,11 @@ export class SessionManager {
|
|
|
404
406
|
await this.handleQuestionReaction(session, postId, emojiName, username);
|
|
405
407
|
return;
|
|
406
408
|
}
|
|
409
|
+
// Handle message approval reactions
|
|
410
|
+
if (session.pendingMessageApproval && session.pendingMessageApproval.postId === postId) {
|
|
411
|
+
await this.handleMessageApprovalReaction(session, emojiName, username);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
407
414
|
}
|
|
408
415
|
async handleQuestionReaction(session, postId, emojiName, username) {
|
|
409
416
|
if (!session.pendingQuestionSet)
|
|
@@ -479,6 +486,44 @@ export class SessionManager {
|
|
|
479
486
|
this.startTyping(session);
|
|
480
487
|
}
|
|
481
488
|
}
|
|
489
|
+
async handleMessageApprovalReaction(session, emoji, approver) {
|
|
490
|
+
const pending = session.pendingMessageApproval;
|
|
491
|
+
if (!pending)
|
|
492
|
+
return;
|
|
493
|
+
// Only session owner or globally allowed users can approve
|
|
494
|
+
if (session.startedBy !== approver && !this.mattermost.isUserAllowed(approver)) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const isAllow = emoji === '+1' || emoji === 'thumbsup';
|
|
498
|
+
const isInvite = emoji === 'white_check_mark' || emoji === 'heavy_check_mark';
|
|
499
|
+
const isDeny = emoji === '-1' || emoji === 'thumbsdown';
|
|
500
|
+
if (!isAllow && !isInvite && !isDeny)
|
|
501
|
+
return;
|
|
502
|
+
if (isAllow) {
|
|
503
|
+
// Allow this single message
|
|
504
|
+
await this.mattermost.updatePost(pending.postId, `✅ Message from @${pending.fromUser} approved by @${approver}`);
|
|
505
|
+
session.claude.sendMessage(pending.originalMessage);
|
|
506
|
+
session.lastActivityAt = new Date();
|
|
507
|
+
this.startTyping(session);
|
|
508
|
+
console.log(` ✅ Message from @${pending.fromUser} approved by @${approver}`);
|
|
509
|
+
}
|
|
510
|
+
else if (isInvite) {
|
|
511
|
+
// Invite user to session
|
|
512
|
+
session.sessionAllowedUsers.add(pending.fromUser);
|
|
513
|
+
await this.mattermost.updatePost(pending.postId, `✅ @${pending.fromUser} invited to session by @${approver}`);
|
|
514
|
+
await this.updateSessionHeader(session);
|
|
515
|
+
session.claude.sendMessage(pending.originalMessage);
|
|
516
|
+
session.lastActivityAt = new Date();
|
|
517
|
+
this.startTyping(session);
|
|
518
|
+
console.log(` 👋 @${pending.fromUser} invited to session by @${approver}`);
|
|
519
|
+
}
|
|
520
|
+
else if (isDeny) {
|
|
521
|
+
// Deny
|
|
522
|
+
await this.mattermost.updatePost(pending.postId, `❌ Message from @${pending.fromUser} denied by @${approver}`);
|
|
523
|
+
console.log(` ❌ Message from @${pending.fromUser} denied by @${approver}`);
|
|
524
|
+
}
|
|
525
|
+
session.pendingMessageApproval = null;
|
|
526
|
+
}
|
|
482
527
|
formatEvent(session, e) {
|
|
483
528
|
switch (e.type) {
|
|
484
529
|
case 'assistant': {
|
|
@@ -724,6 +769,112 @@ export class SessionManager {
|
|
|
724
769
|
await this.mattermost.createPost(`🛑 **Session cancelled** by @${username}`, threadId);
|
|
725
770
|
this.killSession(threadId);
|
|
726
771
|
}
|
|
772
|
+
/** Invite a user to participate in a specific session */
|
|
773
|
+
async inviteUser(threadId, invitedUser, invitedBy) {
|
|
774
|
+
const session = this.sessions.get(threadId);
|
|
775
|
+
if (!session)
|
|
776
|
+
return;
|
|
777
|
+
// Only session owner or globally allowed users can invite
|
|
778
|
+
if (session.startedBy !== invitedBy && !this.mattermost.isUserAllowed(invitedBy)) {
|
|
779
|
+
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can invite others`, threadId);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
session.sessionAllowedUsers.add(invitedUser);
|
|
783
|
+
await this.mattermost.createPost(`✅ @${invitedUser} can now participate in this session (invited by @${invitedBy})`, threadId);
|
|
784
|
+
console.log(` 👋 @${invitedUser} invited to session by @${invitedBy}`);
|
|
785
|
+
await this.updateSessionHeader(session);
|
|
786
|
+
}
|
|
787
|
+
/** Kick a user from a specific session */
|
|
788
|
+
async kickUser(threadId, kickedUser, kickedBy) {
|
|
789
|
+
const session = this.sessions.get(threadId);
|
|
790
|
+
if (!session)
|
|
791
|
+
return;
|
|
792
|
+
// Only session owner or globally allowed users can kick
|
|
793
|
+
if (session.startedBy !== kickedBy && !this.mattermost.isUserAllowed(kickedBy)) {
|
|
794
|
+
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can kick others`, threadId);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
// Can't kick session owner
|
|
798
|
+
if (kickedUser === session.startedBy) {
|
|
799
|
+
await this.mattermost.createPost(`⚠️ Cannot kick session owner @${session.startedBy}`, threadId);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
// Can't kick globally allowed users (they'll still have access)
|
|
803
|
+
if (this.mattermost.isUserAllowed(kickedUser)) {
|
|
804
|
+
await this.mattermost.createPost(`⚠️ @${kickedUser} is globally allowed and cannot be kicked from individual sessions`, threadId);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (session.sessionAllowedUsers.delete(kickedUser)) {
|
|
808
|
+
await this.mattermost.createPost(`🚫 @${kickedUser} removed from this session by @${kickedBy}`, threadId);
|
|
809
|
+
console.log(` 🚫 @${kickedUser} kicked from session by @${kickedBy}`);
|
|
810
|
+
await this.updateSessionHeader(session);
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
await this.mattermost.createPost(`⚠️ @${kickedUser} was not in this session`, threadId);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
/** Update the session header post with current participants */
|
|
817
|
+
async updateSessionHeader(session) {
|
|
818
|
+
if (!session.sessionStartPostId)
|
|
819
|
+
return;
|
|
820
|
+
const shortDir = this.workingDir.replace(process.env.HOME || '', '~');
|
|
821
|
+
const permMode = this.skipPermissions ? '⚡ Auto' : '🔐 Interactive';
|
|
822
|
+
// Build participants list (excluding owner who is shown in "Started by")
|
|
823
|
+
const otherParticipants = [...session.sessionAllowedUsers]
|
|
824
|
+
.filter(u => u !== session.startedBy)
|
|
825
|
+
.map(u => `@${u}`)
|
|
826
|
+
.join(', ');
|
|
827
|
+
const rows = [
|
|
828
|
+
`| 📂 **Directory** | \`${shortDir}\` |`,
|
|
829
|
+
`| 👤 **Started by** | @${session.startedBy} |`,
|
|
830
|
+
];
|
|
831
|
+
if (otherParticipants) {
|
|
832
|
+
rows.push(`| 👥 **Participants** | ${otherParticipants} |`);
|
|
833
|
+
}
|
|
834
|
+
rows.push(`| 🔢 **Session** | #${session.sessionNumber} of ${MAX_SESSIONS} max |`);
|
|
835
|
+
rows.push(`| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`);
|
|
836
|
+
const msg = [
|
|
837
|
+
`### 🤖 mm-claude \`v${pkg.version}\``,
|
|
838
|
+
``,
|
|
839
|
+
`| | |`,
|
|
840
|
+
`|:--|:--|`,
|
|
841
|
+
...rows,
|
|
842
|
+
].join('\n');
|
|
843
|
+
try {
|
|
844
|
+
await this.mattermost.updatePost(session.sessionStartPostId, msg);
|
|
845
|
+
}
|
|
846
|
+
catch (err) {
|
|
847
|
+
console.error('[Session] Failed to update session header:', err);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
/** Request approval for a message from an unauthorized user */
|
|
851
|
+
async requestMessageApproval(threadId, username, message) {
|
|
852
|
+
const session = this.sessions.get(threadId);
|
|
853
|
+
if (!session)
|
|
854
|
+
return;
|
|
855
|
+
// If there's already a pending message approval, ignore
|
|
856
|
+
if (session.pendingMessageApproval) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const preview = message.length > 100 ? message.substring(0, 100) + '…' : message;
|
|
860
|
+
const post = await this.mattermost.createPost(`🔒 **@${username}** wants to send a message:\n> ${preview}\n\n` +
|
|
861
|
+
`React 👍 to allow this message, ✅ to invite them to the session, 👎 to deny`, threadId);
|
|
862
|
+
session.pendingMessageApproval = {
|
|
863
|
+
postId: post.id,
|
|
864
|
+
originalMessage: message,
|
|
865
|
+
fromUser: username,
|
|
866
|
+
};
|
|
867
|
+
this.registerPost(post.id, threadId);
|
|
868
|
+
// Add reaction options
|
|
869
|
+
try {
|
|
870
|
+
await this.mattermost.addReaction(post.id, '+1');
|
|
871
|
+
await this.mattermost.addReaction(post.id, 'white_check_mark');
|
|
872
|
+
await this.mattermost.addReaction(post.id, '-1');
|
|
873
|
+
}
|
|
874
|
+
catch (err) {
|
|
875
|
+
console.error('[Session] Failed to add message approval reactions:', err);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
727
878
|
/** Kill all active sessions (for graceful shutdown) */
|
|
728
879
|
killAllSessions() {
|
|
729
880
|
const count = this.sessions.size;
|
package/dist/index.js
CHANGED
|
@@ -72,16 +72,40 @@ async function main() {
|
|
|
72
72
|
const threadRoot = post.root_id || post.id;
|
|
73
73
|
// Follow-up in active thread
|
|
74
74
|
if (session.isInSessionThread(threadRoot)) {
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// If message starts with @mention to someone else, ignore it (side conversation)
|
|
76
|
+
const mentionMatch = message.trim().match(/^@(\w+)/);
|
|
77
|
+
if (mentionMatch && mentionMatch[1].toLowerCase() !== mattermost.getBotName().toLowerCase()) {
|
|
78
|
+
return; // Side conversation, don't interrupt
|
|
79
|
+
}
|
|
77
80
|
const content = mattermost.isBotMentioned(message)
|
|
78
81
|
? mattermost.extractPrompt(message)
|
|
79
82
|
: message.trim();
|
|
80
|
-
// Check for stop/cancel commands
|
|
81
83
|
const lowerContent = content.toLowerCase();
|
|
84
|
+
// Check for stop/cancel commands (only from allowed users)
|
|
82
85
|
if (lowerContent === '/stop' || lowerContent === 'stop' ||
|
|
83
86
|
lowerContent === '/cancel' || lowerContent === 'cancel') {
|
|
84
|
-
|
|
87
|
+
if (session.isUserAllowedInSession(threadRoot, username)) {
|
|
88
|
+
await session.cancelSession(threadRoot, username);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Check for /invite command
|
|
93
|
+
const inviteMatch = content.match(/^\/invite\s+@?(\w+)/i);
|
|
94
|
+
if (inviteMatch) {
|
|
95
|
+
await session.inviteUser(threadRoot, inviteMatch[1], username);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Check for /kick command
|
|
99
|
+
const kickMatch = content.match(/^\/kick\s+@?(\w+)/i);
|
|
100
|
+
if (kickMatch) {
|
|
101
|
+
await session.kickUser(threadRoot, kickMatch[1], username);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Check if user is allowed in this session
|
|
105
|
+
if (!session.isUserAllowedInSession(threadRoot, username)) {
|
|
106
|
+
// Request approval for their message
|
|
107
|
+
if (content)
|
|
108
|
+
await session.requestMessageApproval(threadRoot, username, content);
|
|
85
109
|
return;
|
|
86
110
|
}
|
|
87
111
|
if (content)
|