mattermost-claude-code 0.2.3 → 0.3.1
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/cli.js +16 -6
- package/dist/claude/session.d.ts +66 -12
- package/dist/claude/session.js +334 -174
- package/dist/config.js +3 -1
- package/dist/index.js +24 -14
- package/dist/mattermost/client.d.ts +2 -0
- package/dist/mattermost/client.js +15 -10
- package/package.json +1 -1
package/dist/claude/cli.js
CHANGED
|
@@ -47,7 +47,9 @@ export class ClaudeCli extends EventEmitter {
|
|
|
47
47
|
args.push('--mcp-config', JSON.stringify(mcpConfig));
|
|
48
48
|
args.push('--permission-prompt-tool', 'mcp__mm-claude-permissions__permission_prompt');
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
if (this.debug) {
|
|
51
|
+
console.log(` [claude] Starting: ${claudePath} ${args.slice(0, 5).join(' ')}...`);
|
|
52
|
+
}
|
|
51
53
|
this.process = spawn(claudePath, args, {
|
|
52
54
|
cwd: this.options.workingDir,
|
|
53
55
|
env: process.env,
|
|
@@ -57,14 +59,18 @@ export class ClaudeCli extends EventEmitter {
|
|
|
57
59
|
this.parseOutput(chunk.toString());
|
|
58
60
|
});
|
|
59
61
|
this.process.stderr?.on('data', (chunk) => {
|
|
60
|
-
|
|
62
|
+
if (this.debug) {
|
|
63
|
+
console.error(` [claude:err] ${chunk.toString().trim()}`);
|
|
64
|
+
}
|
|
61
65
|
});
|
|
62
66
|
this.process.on('error', (err) => {
|
|
63
|
-
console.error('
|
|
67
|
+
console.error(' ❌ Claude error:', err);
|
|
64
68
|
this.emit('error', err);
|
|
65
69
|
});
|
|
66
70
|
this.process.on('exit', (code) => {
|
|
67
|
-
|
|
71
|
+
if (this.debug) {
|
|
72
|
+
console.log(` [claude] Exited ${code}`);
|
|
73
|
+
}
|
|
68
74
|
this.process = null;
|
|
69
75
|
this.buffer = '';
|
|
70
76
|
this.emit('exit', code);
|
|
@@ -78,7 +84,9 @@ export class ClaudeCli extends EventEmitter {
|
|
|
78
84
|
type: 'user',
|
|
79
85
|
message: { role: 'user', content }
|
|
80
86
|
}) + '\n';
|
|
81
|
-
|
|
87
|
+
if (this.debug) {
|
|
88
|
+
console.log(` [claude] Sending: ${content.substring(0, 50)}...`);
|
|
89
|
+
}
|
|
82
90
|
this.process.stdin.write(msg);
|
|
83
91
|
}
|
|
84
92
|
// Send a tool result response
|
|
@@ -96,7 +104,9 @@ export class ClaudeCli extends EventEmitter {
|
|
|
96
104
|
}]
|
|
97
105
|
}
|
|
98
106
|
}) + '\n';
|
|
99
|
-
|
|
107
|
+
if (this.debug) {
|
|
108
|
+
console.log(` [claude] Sending tool_result for ${toolUseId}`);
|
|
109
|
+
}
|
|
100
110
|
this.process.stdin.write(msg);
|
|
101
111
|
}
|
|
102
112
|
parseOutput(data) {
|
package/dist/claude/session.d.ts
CHANGED
|
@@ -1,19 +1,63 @@
|
|
|
1
|
+
import { ClaudeCli } from './cli.js';
|
|
1
2
|
import { MattermostClient } from '../mattermost/client.js';
|
|
3
|
+
interface QuestionOption {
|
|
4
|
+
label: string;
|
|
5
|
+
description: string;
|
|
6
|
+
}
|
|
7
|
+
interface PendingQuestionSet {
|
|
8
|
+
toolUseId: string;
|
|
9
|
+
currentIndex: number;
|
|
10
|
+
currentPostId: string | null;
|
|
11
|
+
questions: Array<{
|
|
12
|
+
header: string;
|
|
13
|
+
question: string;
|
|
14
|
+
options: QuestionOption[];
|
|
15
|
+
answer: string | null;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
interface PendingApproval {
|
|
19
|
+
postId: string;
|
|
20
|
+
type: 'plan' | 'action';
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Represents a single Claude Code session tied to a Mattermost thread.
|
|
24
|
+
* Each session has its own Claude CLI process and state.
|
|
25
|
+
*/
|
|
26
|
+
interface Session {
|
|
27
|
+
threadId: string;
|
|
28
|
+
startedBy: string;
|
|
29
|
+
startedAt: Date;
|
|
30
|
+
lastActivityAt: Date;
|
|
31
|
+
claude: ClaudeCli;
|
|
32
|
+
currentPostId: string | null;
|
|
33
|
+
pendingContent: string;
|
|
34
|
+
pendingApproval: PendingApproval | null;
|
|
35
|
+
pendingQuestionSet: PendingQuestionSet | null;
|
|
36
|
+
planApproved: boolean;
|
|
37
|
+
tasksPostId: string | null;
|
|
38
|
+
activeSubagents: Map<string, string>;
|
|
39
|
+
updateTimer: ReturnType<typeof setTimeout> | null;
|
|
40
|
+
typingTimer: ReturnType<typeof setInterval> | null;
|
|
41
|
+
}
|
|
2
42
|
export declare class SessionManager {
|
|
3
|
-
private claude;
|
|
4
43
|
private mattermost;
|
|
5
44
|
private workingDir;
|
|
6
45
|
private skipPermissions;
|
|
7
|
-
private session;
|
|
8
|
-
private updateTimer;
|
|
9
|
-
private typingTimer;
|
|
10
|
-
private pendingQuestionSet;
|
|
11
|
-
private pendingApproval;
|
|
12
|
-
private planApproved;
|
|
13
|
-
private tasksPostId;
|
|
14
|
-
private activeSubagents;
|
|
15
46
|
private debug;
|
|
47
|
+
private sessions;
|
|
48
|
+
private postIndex;
|
|
49
|
+
private cleanupTimer;
|
|
16
50
|
constructor(mattermost: MattermostClient, workingDir: string, skipPermissions?: boolean);
|
|
51
|
+
/** Get a session by thread ID */
|
|
52
|
+
getSession(threadId: string): Session | undefined;
|
|
53
|
+
/** Check if a session exists for this thread */
|
|
54
|
+
hasSession(threadId: string): boolean;
|
|
55
|
+
/** Get the number of active sessions */
|
|
56
|
+
getSessionCount(): number;
|
|
57
|
+
/** Register a post for reaction routing */
|
|
58
|
+
private registerPost;
|
|
59
|
+
/** Find session by post ID (for reaction routing) */
|
|
60
|
+
private getSessionByPost;
|
|
17
61
|
startSession(options: {
|
|
18
62
|
prompt: string;
|
|
19
63
|
}, username: string, replyToPostId?: string): Promise<void>;
|
|
@@ -25,6 +69,7 @@ export declare class SessionManager {
|
|
|
25
69
|
private handleAskUserQuestion;
|
|
26
70
|
private postCurrentQuestion;
|
|
27
71
|
private handleReaction;
|
|
72
|
+
private handleQuestionReaction;
|
|
28
73
|
private handleApprovalReaction;
|
|
29
74
|
private formatEvent;
|
|
30
75
|
private formatToolUse;
|
|
@@ -34,8 +79,17 @@ export declare class SessionManager {
|
|
|
34
79
|
private stopTyping;
|
|
35
80
|
private flush;
|
|
36
81
|
private handleExit;
|
|
82
|
+
/** Check if any sessions are active */
|
|
37
83
|
isSessionActive(): boolean;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
84
|
+
/** Check if a session exists for this thread */
|
|
85
|
+
isInSessionThread(threadRoot: string): boolean;
|
|
86
|
+
/** Send a follow-up message to an existing session */
|
|
87
|
+
sendFollowUp(threadId: string, message: string): Promise<void>;
|
|
88
|
+
/** Kill a specific session */
|
|
89
|
+
killSession(threadId: string): void;
|
|
90
|
+
/** Kill all active sessions (for graceful shutdown) */
|
|
91
|
+
killAllSessions(): void;
|
|
92
|
+
/** Cleanup idle sessions that have exceeded timeout */
|
|
93
|
+
private cleanupIdleSessions;
|
|
41
94
|
}
|
|
95
|
+
export {};
|
package/dist/claude/session.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { ClaudeCli } from './cli.js';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { dirname, resolve } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
2
7
|
const REACTION_EMOJIS = ['one', 'two', 'three', 'four'];
|
|
3
8
|
const EMOJI_TO_INDEX = {
|
|
4
9
|
'one': 0, '1️⃣': 0,
|
|
@@ -6,20 +11,25 @@ const EMOJI_TO_INDEX = {
|
|
|
6
11
|
'three': 2, '3️⃣': 2,
|
|
7
12
|
'four': 3, '4️⃣': 3,
|
|
8
13
|
};
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Configuration
|
|
16
|
+
// =============================================================================
|
|
17
|
+
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '5', 10);
|
|
18
|
+
const SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS || '1800000', 10); // 30 min
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// SessionManager - Manages multiple concurrent Claude Code sessions
|
|
21
|
+
// =============================================================================
|
|
9
22
|
export class SessionManager {
|
|
10
|
-
|
|
23
|
+
// Shared state
|
|
11
24
|
mattermost;
|
|
12
25
|
workingDir;
|
|
13
26
|
skipPermissions;
|
|
14
|
-
session = null;
|
|
15
|
-
updateTimer = null;
|
|
16
|
-
typingTimer = null;
|
|
17
|
-
pendingQuestionSet = null;
|
|
18
|
-
pendingApproval = null;
|
|
19
|
-
planApproved = false; // Track if we already approved this session
|
|
20
|
-
tasksPostId = null; // Track the tasks display post
|
|
21
|
-
activeSubagents = new Map(); // taskId -> postId for subagent status
|
|
22
27
|
debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
|
|
28
|
+
// Multi-session storage
|
|
29
|
+
sessions = new Map(); // threadId -> Session
|
|
30
|
+
postIndex = new Map(); // postId -> threadId (for reaction routing)
|
|
31
|
+
// Cleanup timer
|
|
32
|
+
cleanupTimer = null;
|
|
23
33
|
constructor(mattermost, workingDir, skipPermissions = false) {
|
|
24
34
|
this.mattermost = mattermost;
|
|
25
35
|
this.workingDir = workingDir;
|
|
@@ -28,41 +38,123 @@ export class SessionManager {
|
|
|
28
38
|
this.mattermost.on('reaction', (reaction, user) => {
|
|
29
39
|
this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
|
|
30
40
|
});
|
|
41
|
+
// Start periodic cleanup of idle sessions
|
|
42
|
+
this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
|
|
31
43
|
}
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Session Lookup Methods
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
/** Get a session by thread ID */
|
|
48
|
+
getSession(threadId) {
|
|
49
|
+
return this.sessions.get(threadId);
|
|
50
|
+
}
|
|
51
|
+
/** Check if a session exists for this thread */
|
|
52
|
+
hasSession(threadId) {
|
|
53
|
+
return this.sessions.has(threadId);
|
|
54
|
+
}
|
|
55
|
+
/** Get the number of active sessions */
|
|
56
|
+
getSessionCount() {
|
|
57
|
+
return this.sessions.size;
|
|
58
|
+
}
|
|
59
|
+
/** Register a post for reaction routing */
|
|
60
|
+
registerPost(postId, threadId) {
|
|
61
|
+
this.postIndex.set(postId, threadId);
|
|
62
|
+
}
|
|
63
|
+
/** Find session by post ID (for reaction routing) */
|
|
64
|
+
getSessionByPost(postId) {
|
|
65
|
+
const threadId = this.postIndex.get(postId);
|
|
66
|
+
return threadId ? this.sessions.get(threadId) : undefined;
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Session Lifecycle
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
32
71
|
async startSession(options, username, replyToPostId) {
|
|
33
|
-
|
|
34
|
-
if
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
this.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
72
|
+
const threadId = replyToPostId || '';
|
|
73
|
+
// Check if session already exists for this thread
|
|
74
|
+
const existingSession = this.sessions.get(threadId);
|
|
75
|
+
if (existingSession && existingSession.claude.isRunning()) {
|
|
76
|
+
// Send as follow-up instead
|
|
77
|
+
await this.sendFollowUp(threadId, options.prompt);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Check max sessions limit
|
|
81
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
82
|
+
await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Post session start message
|
|
86
|
+
const shortDir = this.workingDir.replace(process.env.HOME || '', '~');
|
|
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
|
+
`### 🤖 Claude Code \`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);
|
|
105
|
+
const actualThreadId = replyToPostId || post.id;
|
|
106
|
+
// Create Claude CLI with options
|
|
107
|
+
const cliOptions = {
|
|
108
|
+
workingDir: this.workingDir,
|
|
109
|
+
threadId: actualThreadId,
|
|
110
|
+
skipPermissions: this.skipPermissions,
|
|
111
|
+
};
|
|
112
|
+
const claude = new ClaudeCli(cliOptions);
|
|
113
|
+
// Create the session object
|
|
114
|
+
const session = {
|
|
115
|
+
threadId: actualThreadId,
|
|
116
|
+
startedBy: username,
|
|
117
|
+
startedAt: new Date(),
|
|
118
|
+
lastActivityAt: new Date(),
|
|
119
|
+
claude,
|
|
120
|
+
currentPostId: null,
|
|
121
|
+
pendingContent: '',
|
|
122
|
+
pendingApproval: null,
|
|
123
|
+
pendingQuestionSet: null,
|
|
124
|
+
planApproved: false,
|
|
125
|
+
tasksPostId: null,
|
|
126
|
+
activeSubagents: new Map(),
|
|
127
|
+
updateTimer: null,
|
|
128
|
+
typingTimer: null,
|
|
129
|
+
};
|
|
130
|
+
// Register session
|
|
131
|
+
this.sessions.set(actualThreadId, session);
|
|
132
|
+
const shortId = actualThreadId.substring(0, 8);
|
|
133
|
+
console.log(` ▶ Session #${this.sessions.size} started (${shortId}…) by @${username}`);
|
|
134
|
+
// Start typing indicator immediately so user sees activity
|
|
135
|
+
this.startTyping(session);
|
|
136
|
+
// Bind event handlers with closure over threadId
|
|
137
|
+
claude.on('event', (e) => this.handleEvent(actualThreadId, e));
|
|
138
|
+
claude.on('exit', (code) => this.handleExit(actualThreadId, code));
|
|
139
|
+
try {
|
|
140
|
+
claude.start();
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.error(' ❌ Failed to start Claude:', err);
|
|
144
|
+
this.stopTyping(session);
|
|
145
|
+
await this.mattermost.createPost(`❌ ${err}`, actualThreadId);
|
|
146
|
+
this.sessions.delete(actualThreadId);
|
|
147
|
+
return;
|
|
60
148
|
}
|
|
61
|
-
// Send the message
|
|
62
|
-
|
|
63
|
-
this.startTyping();
|
|
149
|
+
// Send the message to Claude
|
|
150
|
+
claude.sendMessage(options.prompt);
|
|
64
151
|
}
|
|
65
|
-
handleEvent(event) {
|
|
152
|
+
handleEvent(threadId, event) {
|
|
153
|
+
const session = this.sessions.get(threadId);
|
|
154
|
+
if (!session)
|
|
155
|
+
return;
|
|
156
|
+
// Update last activity
|
|
157
|
+
session.lastActivityAt = new Date();
|
|
66
158
|
// Check for special tool uses that need custom handling
|
|
67
159
|
if (event.type === 'assistant') {
|
|
68
160
|
const msg = event.message;
|
|
@@ -70,24 +162,21 @@ export class SessionManager {
|
|
|
70
162
|
for (const block of msg?.content || []) {
|
|
71
163
|
if (block.type === 'tool_use') {
|
|
72
164
|
if (block.name === 'ExitPlanMode') {
|
|
73
|
-
this.handleExitPlanMode();
|
|
165
|
+
this.handleExitPlanMode(session);
|
|
74
166
|
hasSpecialTool = true;
|
|
75
167
|
}
|
|
76
168
|
else if (block.name === 'TodoWrite') {
|
|
77
|
-
this.handleTodoWrite(block.input);
|
|
78
|
-
// Don't set hasSpecialTool - let other content through
|
|
169
|
+
this.handleTodoWrite(session, block.input);
|
|
79
170
|
}
|
|
80
171
|
else if (block.name === 'Task') {
|
|
81
|
-
this.handleTaskStart(block.id, block.input);
|
|
82
|
-
// Don't set hasSpecialTool - let other content through
|
|
172
|
+
this.handleTaskStart(session, block.id, block.input);
|
|
83
173
|
}
|
|
84
174
|
else if (block.name === 'AskUserQuestion') {
|
|
85
|
-
this.handleAskUserQuestion(block.id, block.input);
|
|
175
|
+
this.handleAskUserQuestion(session, block.id, block.input);
|
|
86
176
|
hasSpecialTool = true;
|
|
87
177
|
}
|
|
88
178
|
}
|
|
89
179
|
}
|
|
90
|
-
// Skip normal output if we handled a special tool (we post it ourselves)
|
|
91
180
|
if (hasSpecialTool)
|
|
92
181
|
return;
|
|
93
182
|
}
|
|
@@ -96,58 +185,58 @@ export class SessionManager {
|
|
|
96
185
|
const msg = event.message;
|
|
97
186
|
for (const block of msg?.content || []) {
|
|
98
187
|
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
99
|
-
const postId =
|
|
188
|
+
const postId = session.activeSubagents.get(block.tool_use_id);
|
|
100
189
|
if (postId) {
|
|
101
|
-
this.handleTaskComplete(block.tool_use_id, postId);
|
|
190
|
+
this.handleTaskComplete(session, block.tool_use_id, postId);
|
|
102
191
|
}
|
|
103
192
|
}
|
|
104
193
|
}
|
|
105
194
|
}
|
|
106
|
-
const formatted = this.formatEvent(event);
|
|
195
|
+
const formatted = this.formatEvent(session, event);
|
|
107
196
|
if (this.debug) {
|
|
108
|
-
console.log(`[DEBUG] handleEvent: ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
|
|
197
|
+
console.log(`[DEBUG] handleEvent(${threadId}): ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
|
|
109
198
|
}
|
|
110
199
|
if (formatted)
|
|
111
|
-
this.appendContent(formatted);
|
|
200
|
+
this.appendContent(session, formatted);
|
|
112
201
|
}
|
|
113
|
-
async handleTaskComplete(toolUseId, postId) {
|
|
202
|
+
async handleTaskComplete(session, toolUseId, postId) {
|
|
114
203
|
try {
|
|
115
|
-
await this.mattermost.updatePost(postId,
|
|
204
|
+
await this.mattermost.updatePost(postId, session.activeSubagents.has(toolUseId)
|
|
116
205
|
? `🤖 **Subagent** ✅ *completed*`
|
|
117
206
|
: `🤖 **Subagent** ✅`);
|
|
118
|
-
|
|
207
|
+
session.activeSubagents.delete(toolUseId);
|
|
119
208
|
}
|
|
120
209
|
catch (err) {
|
|
121
210
|
console.error('[Session] Failed to update subagent completion:', err);
|
|
122
211
|
}
|
|
123
212
|
}
|
|
124
|
-
async handleExitPlanMode() {
|
|
125
|
-
if (!this.session)
|
|
126
|
-
return;
|
|
213
|
+
async handleExitPlanMode(session) {
|
|
127
214
|
// If already approved in this session, auto-continue
|
|
128
|
-
if (
|
|
215
|
+
if (session.planApproved) {
|
|
129
216
|
console.log('[Session] Plan already approved, auto-continuing...');
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
this.startTyping();
|
|
217
|
+
if (session.claude.isRunning()) {
|
|
218
|
+
session.claude.sendMessage('Continue with the implementation.');
|
|
219
|
+
this.startTyping(session);
|
|
133
220
|
}
|
|
134
221
|
return;
|
|
135
222
|
}
|
|
136
223
|
// If we already have a pending approval, don't post another one
|
|
137
|
-
if (
|
|
224
|
+
if (session.pendingApproval && session.pendingApproval.type === 'plan') {
|
|
138
225
|
console.log('[Session] Plan approval already pending, waiting...');
|
|
139
226
|
return;
|
|
140
227
|
}
|
|
141
228
|
// Flush any pending content first
|
|
142
|
-
await this.flush();
|
|
143
|
-
|
|
144
|
-
|
|
229
|
+
await this.flush(session);
|
|
230
|
+
session.currentPostId = null;
|
|
231
|
+
session.pendingContent = '';
|
|
145
232
|
// Post approval message with reactions
|
|
146
233
|
const message = `✅ **Plan ready for approval**\n\n` +
|
|
147
234
|
`👍 Approve and start building\n` +
|
|
148
235
|
`👎 Request changes\n\n` +
|
|
149
236
|
`*React to respond*`;
|
|
150
|
-
const post = await this.mattermost.createPost(message,
|
|
237
|
+
const post = await this.mattermost.createPost(message, session.threadId);
|
|
238
|
+
// Register post for reaction routing
|
|
239
|
+
this.registerPost(post.id, session.threadId);
|
|
151
240
|
// Add approval reactions
|
|
152
241
|
try {
|
|
153
242
|
await this.mattermost.addReaction(post.id, '+1');
|
|
@@ -157,19 +246,17 @@ export class SessionManager {
|
|
|
157
246
|
console.error('[Session] Failed to add approval reactions:', err);
|
|
158
247
|
}
|
|
159
248
|
// Track this for reaction handling
|
|
160
|
-
|
|
249
|
+
session.pendingApproval = { postId: post.id, type: 'plan' };
|
|
161
250
|
// Stop typing while waiting
|
|
162
|
-
this.stopTyping();
|
|
251
|
+
this.stopTyping(session);
|
|
163
252
|
}
|
|
164
|
-
async handleTodoWrite(input) {
|
|
165
|
-
if (!this.session)
|
|
166
|
-
return;
|
|
253
|
+
async handleTodoWrite(session, input) {
|
|
167
254
|
const todos = input.todos;
|
|
168
255
|
if (!todos || todos.length === 0) {
|
|
169
256
|
// Clear tasks display if empty
|
|
170
|
-
if (
|
|
257
|
+
if (session.tasksPostId) {
|
|
171
258
|
try {
|
|
172
|
-
await this.mattermost.updatePost(
|
|
259
|
+
await this.mattermost.updatePost(session.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
|
|
173
260
|
}
|
|
174
261
|
catch (err) {
|
|
175
262
|
console.error('[Session] Failed to update tasks:', err);
|
|
@@ -199,21 +286,19 @@ export class SessionManager {
|
|
|
199
286
|
}
|
|
200
287
|
// Update or create tasks post
|
|
201
288
|
try {
|
|
202
|
-
if (
|
|
203
|
-
await this.mattermost.updatePost(
|
|
289
|
+
if (session.tasksPostId) {
|
|
290
|
+
await this.mattermost.updatePost(session.tasksPostId, message);
|
|
204
291
|
}
|
|
205
292
|
else {
|
|
206
|
-
const post = await this.mattermost.createPost(message,
|
|
207
|
-
|
|
293
|
+
const post = await this.mattermost.createPost(message, session.threadId);
|
|
294
|
+
session.tasksPostId = post.id;
|
|
208
295
|
}
|
|
209
296
|
}
|
|
210
297
|
catch (err) {
|
|
211
298
|
console.error('[Session] Failed to update tasks:', err);
|
|
212
299
|
}
|
|
213
300
|
}
|
|
214
|
-
async handleTaskStart(toolUseId, input) {
|
|
215
|
-
if (!this.session)
|
|
216
|
-
return;
|
|
301
|
+
async handleTaskStart(session, toolUseId, input) {
|
|
217
302
|
const description = input.description || 'Working...';
|
|
218
303
|
const subagentType = input.subagent_type || 'general';
|
|
219
304
|
// Post subagent status
|
|
@@ -221,30 +306,28 @@ export class SessionManager {
|
|
|
221
306
|
`> ${description}\n` +
|
|
222
307
|
`⏳ Running...`;
|
|
223
308
|
try {
|
|
224
|
-
const post = await this.mattermost.createPost(message,
|
|
225
|
-
|
|
309
|
+
const post = await this.mattermost.createPost(message, session.threadId);
|
|
310
|
+
session.activeSubagents.set(toolUseId, post.id);
|
|
226
311
|
}
|
|
227
312
|
catch (err) {
|
|
228
313
|
console.error('[Session] Failed to post subagent status:', err);
|
|
229
314
|
}
|
|
230
315
|
}
|
|
231
|
-
async handleAskUserQuestion(toolUseId, input) {
|
|
232
|
-
if (!this.session)
|
|
233
|
-
return;
|
|
316
|
+
async handleAskUserQuestion(session, toolUseId, input) {
|
|
234
317
|
// If we already have pending questions, don't start another set
|
|
235
|
-
if (
|
|
318
|
+
if (session.pendingQuestionSet) {
|
|
236
319
|
console.log('[Session] Questions already pending, waiting...');
|
|
237
320
|
return;
|
|
238
321
|
}
|
|
239
322
|
// Flush any pending content first
|
|
240
|
-
await this.flush();
|
|
241
|
-
|
|
242
|
-
|
|
323
|
+
await this.flush(session);
|
|
324
|
+
session.currentPostId = null;
|
|
325
|
+
session.pendingContent = '';
|
|
243
326
|
const questions = input.questions;
|
|
244
327
|
if (!questions || questions.length === 0)
|
|
245
328
|
return;
|
|
246
329
|
// Create a new question set - we'll ask one at a time
|
|
247
|
-
|
|
330
|
+
session.pendingQuestionSet = {
|
|
248
331
|
toolUseId,
|
|
249
332
|
currentIndex: 0,
|
|
250
333
|
currentPostId: null,
|
|
@@ -256,19 +339,19 @@ export class SessionManager {
|
|
|
256
339
|
})),
|
|
257
340
|
};
|
|
258
341
|
// Post the first question
|
|
259
|
-
await this.postCurrentQuestion();
|
|
342
|
+
await this.postCurrentQuestion(session);
|
|
260
343
|
// Stop typing while waiting for answer
|
|
261
|
-
this.stopTyping();
|
|
344
|
+
this.stopTyping(session);
|
|
262
345
|
}
|
|
263
|
-
async postCurrentQuestion() {
|
|
264
|
-
if (!
|
|
346
|
+
async postCurrentQuestion(session) {
|
|
347
|
+
if (!session.pendingQuestionSet)
|
|
265
348
|
return;
|
|
266
|
-
const { currentIndex, questions } =
|
|
349
|
+
const { currentIndex, questions } = session.pendingQuestionSet;
|
|
267
350
|
if (currentIndex >= questions.length)
|
|
268
351
|
return;
|
|
269
352
|
const q = questions[currentIndex];
|
|
270
353
|
const total = questions.length;
|
|
271
|
-
// Format the question message
|
|
354
|
+
// Format the question message
|
|
272
355
|
let message = `❓ **Question** *(${currentIndex + 1}/${total})*\n`;
|
|
273
356
|
message += `**${q.header}:** ${q.question}\n\n`;
|
|
274
357
|
for (let i = 0; i < q.options.length && i < 4; i++) {
|
|
@@ -280,8 +363,10 @@ export class SessionManager {
|
|
|
280
363
|
message += '\n';
|
|
281
364
|
}
|
|
282
365
|
// Post the question
|
|
283
|
-
const post = await this.mattermost.createPost(message,
|
|
284
|
-
|
|
366
|
+
const post = await this.mattermost.createPost(message, session.threadId);
|
|
367
|
+
session.pendingQuestionSet.currentPostId = post.id;
|
|
368
|
+
// Register post for reaction routing
|
|
369
|
+
this.registerPost(post.id, session.threadId);
|
|
285
370
|
// Add reaction emojis
|
|
286
371
|
for (let i = 0; i < q.options.length && i < 4; i++) {
|
|
287
372
|
try {
|
|
@@ -292,19 +377,32 @@ export class SessionManager {
|
|
|
292
377
|
}
|
|
293
378
|
}
|
|
294
379
|
}
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Reaction Handling
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
295
383
|
async handleReaction(postId, emojiName, username) {
|
|
296
384
|
// Check if user is allowed
|
|
297
385
|
if (!this.mattermost.isUserAllowed(username))
|
|
298
386
|
return;
|
|
387
|
+
// Find the session this post belongs to
|
|
388
|
+
const session = this.getSessionByPost(postId);
|
|
389
|
+
if (!session)
|
|
390
|
+
return;
|
|
299
391
|
// Handle approval reactions
|
|
300
|
-
if (
|
|
301
|
-
await this.handleApprovalReaction(emojiName, username);
|
|
392
|
+
if (session.pendingApproval && session.pendingApproval.postId === postId) {
|
|
393
|
+
await this.handleApprovalReaction(session, emojiName, username);
|
|
302
394
|
return;
|
|
303
395
|
}
|
|
304
|
-
// Handle question reactions
|
|
305
|
-
if (
|
|
396
|
+
// Handle question reactions
|
|
397
|
+
if (session.pendingQuestionSet && session.pendingQuestionSet.currentPostId === postId) {
|
|
398
|
+
await this.handleQuestionReaction(session, postId, emojiName, username);
|
|
306
399
|
return;
|
|
307
|
-
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async handleQuestionReaction(session, postId, emojiName, username) {
|
|
403
|
+
if (!session.pendingQuestionSet)
|
|
404
|
+
return;
|
|
405
|
+
const { currentIndex, questions } = session.pendingQuestionSet;
|
|
308
406
|
const question = questions[currentIndex];
|
|
309
407
|
if (!question)
|
|
310
408
|
return;
|
|
@@ -322,35 +420,34 @@ export class SessionManager {
|
|
|
322
420
|
console.error('[Session] Failed to update answered question:', err);
|
|
323
421
|
}
|
|
324
422
|
// Move to next question or finish
|
|
325
|
-
|
|
326
|
-
if (
|
|
423
|
+
session.pendingQuestionSet.currentIndex++;
|
|
424
|
+
if (session.pendingQuestionSet.currentIndex < questions.length) {
|
|
327
425
|
// Post next question
|
|
328
|
-
await this.postCurrentQuestion();
|
|
426
|
+
await this.postCurrentQuestion(session);
|
|
329
427
|
}
|
|
330
428
|
else {
|
|
331
429
|
// All questions answered - send as follow-up message
|
|
332
|
-
// (CLI auto-responds with error to AskUserQuestion, so tool_result won't work)
|
|
333
430
|
let answersText = 'Here are my answers:\n';
|
|
334
431
|
for (const q of questions) {
|
|
335
432
|
answersText += `- **${q.header}**: ${q.answer}\n`;
|
|
336
433
|
}
|
|
337
434
|
console.log(`[Session] All questions answered, sending as message:`, answersText);
|
|
338
435
|
// Clear and send as regular message
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
|
|
342
|
-
this.startTyping();
|
|
436
|
+
session.pendingQuestionSet = null;
|
|
437
|
+
if (session.claude.isRunning()) {
|
|
438
|
+
session.claude.sendMessage(answersText);
|
|
439
|
+
this.startTyping(session);
|
|
343
440
|
}
|
|
344
441
|
}
|
|
345
442
|
}
|
|
346
|
-
async handleApprovalReaction(emojiName, username) {
|
|
347
|
-
if (!
|
|
443
|
+
async handleApprovalReaction(session, emojiName, username) {
|
|
444
|
+
if (!session.pendingApproval)
|
|
348
445
|
return;
|
|
349
446
|
const isApprove = emojiName === '+1' || emojiName === 'thumbsup';
|
|
350
447
|
const isReject = emojiName === '-1' || emojiName === 'thumbsdown';
|
|
351
448
|
if (!isApprove && !isReject)
|
|
352
449
|
return;
|
|
353
|
-
const postId =
|
|
450
|
+
const postId = session.pendingApproval.postId;
|
|
354
451
|
console.log(`[Session] User ${username} ${isApprove ? 'approved' : 'rejected'} the plan`);
|
|
355
452
|
// Update the post to show the decision
|
|
356
453
|
try {
|
|
@@ -363,20 +460,20 @@ export class SessionManager {
|
|
|
363
460
|
console.error('[Session] Failed to update approval post:', err);
|
|
364
461
|
}
|
|
365
462
|
// Clear pending approval and mark as approved
|
|
366
|
-
|
|
463
|
+
session.pendingApproval = null;
|
|
367
464
|
if (isApprove) {
|
|
368
|
-
|
|
465
|
+
session.planApproved = true;
|
|
369
466
|
}
|
|
370
467
|
// Send response to Claude
|
|
371
|
-
if (
|
|
468
|
+
if (session.claude.isRunning()) {
|
|
372
469
|
const response = isApprove
|
|
373
470
|
? 'Approved. Please proceed with the implementation.'
|
|
374
471
|
: 'Please revise the plan. I would like some changes.';
|
|
375
|
-
|
|
376
|
-
this.startTyping();
|
|
472
|
+
session.claude.sendMessage(response);
|
|
473
|
+
this.startTyping(session);
|
|
377
474
|
}
|
|
378
475
|
}
|
|
379
|
-
formatEvent(e) {
|
|
476
|
+
formatEvent(session, e) {
|
|
380
477
|
switch (e.type) {
|
|
381
478
|
case 'assistant': {
|
|
382
479
|
const msg = e.message;
|
|
@@ -415,12 +512,10 @@ export class SessionManager {
|
|
|
415
512
|
}
|
|
416
513
|
case 'result': {
|
|
417
514
|
// Response complete - stop typing and start new post for next message
|
|
418
|
-
this.stopTyping();
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
this.session.content = '';
|
|
423
|
-
}
|
|
515
|
+
this.stopTyping(session);
|
|
516
|
+
this.flush(session);
|
|
517
|
+
session.currentPostId = null;
|
|
518
|
+
session.pendingContent = '';
|
|
424
519
|
return null;
|
|
425
520
|
}
|
|
426
521
|
case 'system':
|
|
@@ -508,75 +603,140 @@ export class SessionManager {
|
|
|
508
603
|
}
|
|
509
604
|
}
|
|
510
605
|
}
|
|
511
|
-
appendContent(text) {
|
|
512
|
-
if (!
|
|
606
|
+
appendContent(session, text) {
|
|
607
|
+
if (!text)
|
|
513
608
|
return;
|
|
514
|
-
|
|
515
|
-
this.scheduleUpdate();
|
|
609
|
+
session.pendingContent += text + '\n';
|
|
610
|
+
this.scheduleUpdate(session);
|
|
516
611
|
}
|
|
517
|
-
scheduleUpdate() {
|
|
518
|
-
if (
|
|
612
|
+
scheduleUpdate(session) {
|
|
613
|
+
if (session.updateTimer)
|
|
519
614
|
return;
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
this.flush();
|
|
615
|
+
session.updateTimer = setTimeout(() => {
|
|
616
|
+
session.updateTimer = null;
|
|
617
|
+
this.flush(session);
|
|
523
618
|
}, 500);
|
|
524
619
|
}
|
|
525
|
-
startTyping() {
|
|
526
|
-
if (
|
|
620
|
+
startTyping(session) {
|
|
621
|
+
if (session.typingTimer)
|
|
527
622
|
return;
|
|
528
623
|
// Send typing immediately, then every 3 seconds
|
|
529
|
-
this.mattermost.sendTyping(
|
|
530
|
-
|
|
531
|
-
this.mattermost.sendTyping(
|
|
624
|
+
this.mattermost.sendTyping(session.threadId);
|
|
625
|
+
session.typingTimer = setInterval(() => {
|
|
626
|
+
this.mattermost.sendTyping(session.threadId);
|
|
532
627
|
}, 3000);
|
|
533
628
|
}
|
|
534
|
-
stopTyping() {
|
|
535
|
-
if (
|
|
536
|
-
clearInterval(
|
|
537
|
-
|
|
629
|
+
stopTyping(session) {
|
|
630
|
+
if (session.typingTimer) {
|
|
631
|
+
clearInterval(session.typingTimer);
|
|
632
|
+
session.typingTimer = null;
|
|
538
633
|
}
|
|
539
634
|
}
|
|
540
|
-
async flush() {
|
|
541
|
-
if (!
|
|
635
|
+
async flush(session) {
|
|
636
|
+
if (!session.pendingContent.trim())
|
|
542
637
|
return;
|
|
543
|
-
const content =
|
|
544
|
-
if (
|
|
545
|
-
await this.mattermost.updatePost(
|
|
638
|
+
const content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
|
|
639
|
+
if (session.currentPostId) {
|
|
640
|
+
await this.mattermost.updatePost(session.currentPostId, content);
|
|
546
641
|
}
|
|
547
642
|
else {
|
|
548
|
-
const post = await this.mattermost.createPost(content,
|
|
549
|
-
|
|
643
|
+
const post = await this.mattermost.createPost(content, session.threadId);
|
|
644
|
+
session.currentPostId = post.id;
|
|
645
|
+
// Register post for reaction routing
|
|
646
|
+
this.registerPost(post.id, session.threadId);
|
|
550
647
|
}
|
|
551
648
|
}
|
|
552
|
-
async handleExit(code) {
|
|
553
|
-
this.
|
|
554
|
-
if (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
649
|
+
async handleExit(threadId, code) {
|
|
650
|
+
const session = this.sessions.get(threadId);
|
|
651
|
+
if (!session)
|
|
652
|
+
return;
|
|
653
|
+
this.stopTyping(session);
|
|
654
|
+
if (session.updateTimer) {
|
|
655
|
+
clearTimeout(session.updateTimer);
|
|
656
|
+
session.updateTimer = null;
|
|
657
|
+
}
|
|
658
|
+
await this.flush(session);
|
|
659
|
+
if (code !== 0) {
|
|
660
|
+
await this.mattermost.createPost(`**[Exited: ${code}]**`, session.threadId);
|
|
661
|
+
}
|
|
662
|
+
// Clean up session from maps
|
|
663
|
+
this.sessions.delete(threadId);
|
|
664
|
+
// Clean up post index entries for this session
|
|
665
|
+
for (const [postId, tid] of this.postIndex.entries()) {
|
|
666
|
+
if (tid === threadId) {
|
|
667
|
+
this.postIndex.delete(postId);
|
|
668
|
+
}
|
|
561
669
|
}
|
|
562
|
-
|
|
670
|
+
const shortId = threadId.substring(0, 8);
|
|
671
|
+
console.log(` ■ Session ended (${shortId}…) — ${this.sessions.size} active`);
|
|
563
672
|
}
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// Public Session API
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
/** Check if any sessions are active */
|
|
564
677
|
isSessionActive() {
|
|
565
|
-
return this.
|
|
678
|
+
return this.sessions.size > 0;
|
|
566
679
|
}
|
|
567
|
-
|
|
568
|
-
|
|
680
|
+
/** Check if a session exists for this thread */
|
|
681
|
+
isInSessionThread(threadRoot) {
|
|
682
|
+
const session = this.sessions.get(threadRoot);
|
|
683
|
+
return session !== undefined && session.claude.isRunning();
|
|
569
684
|
}
|
|
570
|
-
|
|
571
|
-
|
|
685
|
+
/** Send a follow-up message to an existing session */
|
|
686
|
+
async sendFollowUp(threadId, message) {
|
|
687
|
+
const session = this.sessions.get(threadId);
|
|
688
|
+
if (!session || !session.claude.isRunning())
|
|
572
689
|
return;
|
|
573
|
-
|
|
574
|
-
|
|
690
|
+
session.claude.sendMessage(message);
|
|
691
|
+
session.lastActivityAt = new Date();
|
|
692
|
+
this.startTyping(session);
|
|
693
|
+
}
|
|
694
|
+
/** Kill a specific session */
|
|
695
|
+
killSession(threadId) {
|
|
696
|
+
const session = this.sessions.get(threadId);
|
|
697
|
+
if (!session)
|
|
698
|
+
return;
|
|
699
|
+
this.stopTyping(session);
|
|
700
|
+
session.claude.kill();
|
|
701
|
+
// Clean up session from maps
|
|
702
|
+
this.sessions.delete(threadId);
|
|
703
|
+
for (const [postId, tid] of this.postIndex.entries()) {
|
|
704
|
+
if (tid === threadId) {
|
|
705
|
+
this.postIndex.delete(postId);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
const shortId = threadId.substring(0, 8);
|
|
709
|
+
console.log(` ✖ Session killed (${shortId}…) — ${this.sessions.size} active`);
|
|
710
|
+
}
|
|
711
|
+
/** Kill all active sessions (for graceful shutdown) */
|
|
712
|
+
killAllSessions() {
|
|
713
|
+
const count = this.sessions.size;
|
|
714
|
+
for (const [, session] of this.sessions.entries()) {
|
|
715
|
+
this.stopTyping(session);
|
|
716
|
+
session.claude.kill();
|
|
717
|
+
}
|
|
718
|
+
this.sessions.clear();
|
|
719
|
+
this.postIndex.clear();
|
|
720
|
+
if (this.cleanupTimer) {
|
|
721
|
+
clearInterval(this.cleanupTimer);
|
|
722
|
+
this.cleanupTimer = null;
|
|
723
|
+
}
|
|
724
|
+
if (count > 0) {
|
|
725
|
+
console.log(` ✖ Killed ${count} session${count === 1 ? '' : 's'}`);
|
|
726
|
+
}
|
|
575
727
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
this.
|
|
580
|
-
|
|
728
|
+
/** Cleanup idle sessions that have exceeded timeout */
|
|
729
|
+
cleanupIdleSessions() {
|
|
730
|
+
const now = Date.now();
|
|
731
|
+
for (const [threadId, session] of this.sessions.entries()) {
|
|
732
|
+
const idleTime = now - session.lastActivityAt.getTime();
|
|
733
|
+
if (idleTime > SESSION_TIMEOUT_MS) {
|
|
734
|
+
const mins = Math.round(idleTime / 60000);
|
|
735
|
+
const shortId = threadId.substring(0, 8);
|
|
736
|
+
console.log(` ⏰ Session (${shortId}…) timed out after ${mins}m idle`);
|
|
737
|
+
this.mattermost.createPost(`⏰ **Session timed out** — no activity for ${mins} minutes`, session.threadId).catch(() => { });
|
|
738
|
+
this.killSession(threadId);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
581
741
|
}
|
|
582
742
|
}
|
package/dist/config.js
CHANGED
|
@@ -15,7 +15,9 @@ function loadEnv() {
|
|
|
15
15
|
];
|
|
16
16
|
for (const envPath of envPaths) {
|
|
17
17
|
if (existsSync(envPath)) {
|
|
18
|
-
|
|
18
|
+
if (process.env.DEBUG === '1' || process.argv.includes('--debug')) {
|
|
19
|
+
console.log(` [config] Loading from: ${envPath}`);
|
|
20
|
+
}
|
|
19
21
|
config({ path: envPath });
|
|
20
22
|
break;
|
|
21
23
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,9 @@ import { dirname, resolve } from 'path';
|
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8'));
|
|
10
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
11
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
12
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
10
13
|
async function main() {
|
|
11
14
|
if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
12
15
|
console.log(pkg.version);
|
|
@@ -20,30 +23,35 @@ Usage: cd /your/project && mm-claude`);
|
|
|
20
23
|
}
|
|
21
24
|
const workingDir = process.cwd();
|
|
22
25
|
const config = loadConfig();
|
|
23
|
-
|
|
24
|
-
console.log(
|
|
25
|
-
console.log(
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Nice startup banner
|
|
27
|
+
console.log('');
|
|
28
|
+
console.log(bold(` 🤖 mm-claude v${pkg.version}`));
|
|
29
|
+
console.log(dim(' ─────────────────────────────────'));
|
|
30
|
+
console.log(` 📂 ${cyan(workingDir)}`);
|
|
31
|
+
console.log(` 💬 ${cyan('@' + config.mattermost.botName)}`);
|
|
32
|
+
console.log(` 🌐 ${dim(config.mattermost.url)}`);
|
|
28
33
|
if (config.skipPermissions) {
|
|
29
|
-
console.log(
|
|
34
|
+
console.log(` ⚠️ ${dim('Permissions disabled')}`);
|
|
30
35
|
}
|
|
31
36
|
else {
|
|
32
|
-
console.log(
|
|
37
|
+
console.log(` 🔐 ${dim('Interactive permissions')}`);
|
|
33
38
|
}
|
|
39
|
+
console.log('');
|
|
40
|
+
const mattermost = new MattermostClient(config);
|
|
41
|
+
const session = new SessionManager(mattermost, workingDir, config.skipPermissions);
|
|
34
42
|
mattermost.on('message', async (post, user) => {
|
|
35
43
|
const username = user?.username || 'unknown';
|
|
36
44
|
const message = post.message;
|
|
37
45
|
const threadRoot = post.root_id || post.id;
|
|
38
46
|
// Follow-up in active thread
|
|
39
|
-
if (session.
|
|
47
|
+
if (session.isInSessionThread(threadRoot)) {
|
|
40
48
|
if (!mattermost.isUserAllowed(username))
|
|
41
49
|
return;
|
|
42
50
|
const content = mattermost.isBotMentioned(message)
|
|
43
51
|
? mattermost.extractPrompt(message)
|
|
44
52
|
: message.trim();
|
|
45
53
|
if (content)
|
|
46
|
-
await session.sendFollowUp(content);
|
|
54
|
+
await session.sendFollowUp(threadRoot, content);
|
|
47
55
|
return;
|
|
48
56
|
}
|
|
49
57
|
// New session requires @mention
|
|
@@ -60,13 +68,15 @@ Usage: cd /your/project && mm-claude`);
|
|
|
60
68
|
}
|
|
61
69
|
await session.startSession({ prompt }, username, threadRoot);
|
|
62
70
|
});
|
|
63
|
-
mattermost.on('connected', () =>
|
|
64
|
-
mattermost.on('error', (e) => console.error('❌', e));
|
|
71
|
+
mattermost.on('connected', () => { });
|
|
72
|
+
mattermost.on('error', (e) => console.error(' ❌ Error:', e));
|
|
65
73
|
await mattermost.connect();
|
|
66
|
-
console.log(
|
|
74
|
+
console.log(` ✅ ${bold('Ready!')} Waiting for @${config.mattermost.botName} mentions...`);
|
|
75
|
+
console.log('');
|
|
67
76
|
const shutdown = () => {
|
|
68
|
-
console.log('
|
|
69
|
-
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(` 👋 ${dim('Shutting down...')}`);
|
|
79
|
+
session.killAllSessions();
|
|
70
80
|
mattermost.disconnect();
|
|
71
81
|
process.exit(0);
|
|
72
82
|
};
|
|
@@ -16,7 +16,9 @@ export declare class MattermostClient extends EventEmitter {
|
|
|
16
16
|
private reconnectDelay;
|
|
17
17
|
private userCache;
|
|
18
18
|
private botUserId;
|
|
19
|
+
private debug;
|
|
19
20
|
constructor(config: Config);
|
|
21
|
+
private log;
|
|
20
22
|
private api;
|
|
21
23
|
getBotUser(): Promise<MattermostUser>;
|
|
22
24
|
getUser(userId: string): Promise<MattermostUser | null>;
|
|
@@ -8,10 +8,15 @@ export class MattermostClient extends EventEmitter {
|
|
|
8
8
|
reconnectDelay = 1000;
|
|
9
9
|
userCache = new Map();
|
|
10
10
|
botUserId = null;
|
|
11
|
+
debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
|
|
11
12
|
constructor(config) {
|
|
12
13
|
super();
|
|
13
14
|
this.config = config;
|
|
14
15
|
}
|
|
16
|
+
log(msg) {
|
|
17
|
+
if (this.debug)
|
|
18
|
+
console.log(` [ws] ${msg}`);
|
|
19
|
+
}
|
|
15
20
|
// REST API helper
|
|
16
21
|
async api(method, path, body) {
|
|
17
22
|
const url = `${this.config.mattermost.url}/api/v4${path}`;
|
|
@@ -78,14 +83,14 @@ export class MattermostClient extends EventEmitter {
|
|
|
78
83
|
async connect() {
|
|
79
84
|
// Get bot user first
|
|
80
85
|
await this.getBotUser();
|
|
81
|
-
|
|
86
|
+
this.log(`Bot user ID: ${this.botUserId}`);
|
|
82
87
|
const wsUrl = this.config.mattermost.url
|
|
83
88
|
.replace(/^http/, 'ws')
|
|
84
89
|
.concat('/api/v4/websocket');
|
|
85
90
|
return new Promise((resolve, reject) => {
|
|
86
91
|
this.ws = new WebSocket(wsUrl);
|
|
87
92
|
this.ws.on('open', () => {
|
|
88
|
-
|
|
93
|
+
this.log('WebSocket connected');
|
|
89
94
|
// Authenticate
|
|
90
95
|
this.ws.send(JSON.stringify({
|
|
91
96
|
seq: 1,
|
|
@@ -105,16 +110,16 @@ export class MattermostClient extends EventEmitter {
|
|
|
105
110
|
}
|
|
106
111
|
}
|
|
107
112
|
catch (err) {
|
|
108
|
-
|
|
113
|
+
this.log(`Failed to parse message: ${err}`);
|
|
109
114
|
}
|
|
110
115
|
});
|
|
111
116
|
this.ws.on('close', () => {
|
|
112
|
-
|
|
117
|
+
this.log('WebSocket disconnected');
|
|
113
118
|
this.emit('disconnected');
|
|
114
119
|
this.scheduleReconnect();
|
|
115
120
|
});
|
|
116
121
|
this.ws.on('error', (err) => {
|
|
117
|
-
|
|
122
|
+
this.log(`WebSocket error: ${err}`);
|
|
118
123
|
this.emit('error', err);
|
|
119
124
|
reject(err);
|
|
120
125
|
});
|
|
@@ -140,7 +145,7 @@ export class MattermostClient extends EventEmitter {
|
|
|
140
145
|
});
|
|
141
146
|
}
|
|
142
147
|
catch (err) {
|
|
143
|
-
|
|
148
|
+
this.log(`Failed to parse post: ${err}`);
|
|
144
149
|
}
|
|
145
150
|
return;
|
|
146
151
|
}
|
|
@@ -160,21 +165,21 @@ export class MattermostClient extends EventEmitter {
|
|
|
160
165
|
});
|
|
161
166
|
}
|
|
162
167
|
catch (err) {
|
|
163
|
-
|
|
168
|
+
this.log(`Failed to parse reaction: ${err}`);
|
|
164
169
|
}
|
|
165
170
|
}
|
|
166
171
|
}
|
|
167
172
|
scheduleReconnect() {
|
|
168
173
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
169
|
-
console.error('
|
|
174
|
+
console.error(' ⚠️ Max reconnection attempts reached');
|
|
170
175
|
return;
|
|
171
176
|
}
|
|
172
177
|
this.reconnectAttempts++;
|
|
173
178
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
174
|
-
console.log(`
|
|
179
|
+
console.log(` 🔄 Reconnecting... (attempt ${this.reconnectAttempts})`);
|
|
175
180
|
setTimeout(() => {
|
|
176
181
|
this.connect().catch((err) => {
|
|
177
|
-
console.error(
|
|
182
|
+
console.error(` ❌ Reconnection failed: ${err}`);
|
|
178
183
|
});
|
|
179
184
|
}, delay);
|
|
180
185
|
}
|