mattermost-claude-code 0.2.2 → 0.3.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 +66 -12
- package/dist/claude/session.js +302 -173
- package/dist/index.js +3 -3
- package/package.json +1 -1
- package/dist/claude/types.d.ts +0 -76
- package/dist/claude/types.js +0 -3
- package/dist/mattermost/message-formatter.d.ts +0 -28
- package/dist/mattermost/message-formatter.js +0 -244
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
|
@@ -6,20 +6,25 @@ const EMOJI_TO_INDEX = {
|
|
|
6
6
|
'three': 2, '3️⃣': 2,
|
|
7
7
|
'four': 3, '4️⃣': 3,
|
|
8
8
|
};
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// Configuration
|
|
11
|
+
// =============================================================================
|
|
12
|
+
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '5', 10);
|
|
13
|
+
const SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS || '1800000', 10); // 30 min
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// SessionManager - Manages multiple concurrent Claude Code sessions
|
|
16
|
+
// =============================================================================
|
|
9
17
|
export class SessionManager {
|
|
10
|
-
|
|
18
|
+
// Shared state
|
|
11
19
|
mattermost;
|
|
12
20
|
workingDir;
|
|
13
21
|
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
22
|
debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
|
|
23
|
+
// Multi-session storage
|
|
24
|
+
sessions = new Map(); // threadId -> Session
|
|
25
|
+
postIndex = new Map(); // postId -> threadId (for reaction routing)
|
|
26
|
+
// Cleanup timer
|
|
27
|
+
cleanupTimer = null;
|
|
23
28
|
constructor(mattermost, workingDir, skipPermissions = false) {
|
|
24
29
|
this.mattermost = mattermost;
|
|
25
30
|
this.workingDir = workingDir;
|
|
@@ -28,41 +33,103 @@ export class SessionManager {
|
|
|
28
33
|
this.mattermost.on('reaction', (reaction, user) => {
|
|
29
34
|
this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
|
|
30
35
|
});
|
|
36
|
+
// Start periodic cleanup of idle sessions
|
|
37
|
+
this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
|
|
31
38
|
}
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Session Lookup Methods
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** Get a session by thread ID */
|
|
43
|
+
getSession(threadId) {
|
|
44
|
+
return this.sessions.get(threadId);
|
|
45
|
+
}
|
|
46
|
+
/** Check if a session exists for this thread */
|
|
47
|
+
hasSession(threadId) {
|
|
48
|
+
return this.sessions.has(threadId);
|
|
49
|
+
}
|
|
50
|
+
/** Get the number of active sessions */
|
|
51
|
+
getSessionCount() {
|
|
52
|
+
return this.sessions.size;
|
|
53
|
+
}
|
|
54
|
+
/** Register a post for reaction routing */
|
|
55
|
+
registerPost(postId, threadId) {
|
|
56
|
+
this.postIndex.set(postId, threadId);
|
|
57
|
+
}
|
|
58
|
+
/** Find session by post ID (for reaction routing) */
|
|
59
|
+
getSessionByPost(postId) {
|
|
60
|
+
const threadId = this.postIndex.get(postId);
|
|
61
|
+
return threadId ? this.sessions.get(threadId) : undefined;
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Session Lifecycle
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
32
66
|
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
|
-
|
|
67
|
+
const threadId = replyToPostId || '';
|
|
68
|
+
// Check if session already exists for this thread
|
|
69
|
+
const existingSession = this.sessions.get(threadId);
|
|
70
|
+
if (existingSession && existingSession.claude.isRunning()) {
|
|
71
|
+
// Send as follow-up instead
|
|
72
|
+
await this.sendFollowUp(threadId, options.prompt);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Check max sessions limit
|
|
76
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
77
|
+
await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Post session start message
|
|
81
|
+
const msg = `🚀 **Session started**\n> Working directory: \`${this.workingDir}\``;
|
|
82
|
+
const post = await this.mattermost.createPost(msg, replyToPostId);
|
|
83
|
+
const actualThreadId = replyToPostId || post.id;
|
|
84
|
+
// Create Claude CLI with options
|
|
85
|
+
const cliOptions = {
|
|
86
|
+
workingDir: this.workingDir,
|
|
87
|
+
threadId: actualThreadId,
|
|
88
|
+
skipPermissions: this.skipPermissions,
|
|
89
|
+
};
|
|
90
|
+
const claude = new ClaudeCli(cliOptions);
|
|
91
|
+
// Create the session object
|
|
92
|
+
const session = {
|
|
93
|
+
threadId: actualThreadId,
|
|
94
|
+
startedBy: username,
|
|
95
|
+
startedAt: new Date(),
|
|
96
|
+
lastActivityAt: new Date(),
|
|
97
|
+
claude,
|
|
98
|
+
currentPostId: null,
|
|
99
|
+
pendingContent: '',
|
|
100
|
+
pendingApproval: null,
|
|
101
|
+
pendingQuestionSet: null,
|
|
102
|
+
planApproved: false,
|
|
103
|
+
tasksPostId: null,
|
|
104
|
+
activeSubagents: new Map(),
|
|
105
|
+
updateTimer: null,
|
|
106
|
+
typingTimer: null,
|
|
107
|
+
};
|
|
108
|
+
// Register session
|
|
109
|
+
this.sessions.set(actualThreadId, session);
|
|
110
|
+
console.log(`[Sessions] Started session for thread ${actualThreadId} by ${username} (active: ${this.sessions.size})`);
|
|
111
|
+
// Bind event handlers with closure over threadId
|
|
112
|
+
claude.on('event', (e) => this.handleEvent(actualThreadId, e));
|
|
113
|
+
claude.on('exit', (code) => this.handleExit(actualThreadId, code));
|
|
114
|
+
try {
|
|
115
|
+
claude.start();
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
console.error('[Session] Start error:', err);
|
|
119
|
+
await this.mattermost.createPost(`❌ ${err}`, actualThreadId);
|
|
120
|
+
this.sessions.delete(actualThreadId);
|
|
121
|
+
return;
|
|
60
122
|
}
|
|
61
123
|
// Send the message and start typing indicator
|
|
62
|
-
|
|
63
|
-
this.startTyping();
|
|
124
|
+
claude.sendMessage(options.prompt);
|
|
125
|
+
this.startTyping(session);
|
|
64
126
|
}
|
|
65
|
-
handleEvent(event) {
|
|
127
|
+
handleEvent(threadId, event) {
|
|
128
|
+
const session = this.sessions.get(threadId);
|
|
129
|
+
if (!session)
|
|
130
|
+
return;
|
|
131
|
+
// Update last activity
|
|
132
|
+
session.lastActivityAt = new Date();
|
|
66
133
|
// Check for special tool uses that need custom handling
|
|
67
134
|
if (event.type === 'assistant') {
|
|
68
135
|
const msg = event.message;
|
|
@@ -70,24 +137,21 @@ export class SessionManager {
|
|
|
70
137
|
for (const block of msg?.content || []) {
|
|
71
138
|
if (block.type === 'tool_use') {
|
|
72
139
|
if (block.name === 'ExitPlanMode') {
|
|
73
|
-
this.handleExitPlanMode();
|
|
140
|
+
this.handleExitPlanMode(session);
|
|
74
141
|
hasSpecialTool = true;
|
|
75
142
|
}
|
|
76
143
|
else if (block.name === 'TodoWrite') {
|
|
77
|
-
this.handleTodoWrite(block.input);
|
|
78
|
-
// Don't set hasSpecialTool - let other content through
|
|
144
|
+
this.handleTodoWrite(session, block.input);
|
|
79
145
|
}
|
|
80
146
|
else if (block.name === 'Task') {
|
|
81
|
-
this.handleTaskStart(block.id, block.input);
|
|
82
|
-
// Don't set hasSpecialTool - let other content through
|
|
147
|
+
this.handleTaskStart(session, block.id, block.input);
|
|
83
148
|
}
|
|
84
149
|
else if (block.name === 'AskUserQuestion') {
|
|
85
|
-
this.handleAskUserQuestion(block.id, block.input);
|
|
150
|
+
this.handleAskUserQuestion(session, block.id, block.input);
|
|
86
151
|
hasSpecialTool = true;
|
|
87
152
|
}
|
|
88
153
|
}
|
|
89
154
|
}
|
|
90
|
-
// Skip normal output if we handled a special tool (we post it ourselves)
|
|
91
155
|
if (hasSpecialTool)
|
|
92
156
|
return;
|
|
93
157
|
}
|
|
@@ -96,58 +160,58 @@ export class SessionManager {
|
|
|
96
160
|
const msg = event.message;
|
|
97
161
|
for (const block of msg?.content || []) {
|
|
98
162
|
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
99
|
-
const postId =
|
|
163
|
+
const postId = session.activeSubagents.get(block.tool_use_id);
|
|
100
164
|
if (postId) {
|
|
101
|
-
this.handleTaskComplete(block.tool_use_id, postId);
|
|
165
|
+
this.handleTaskComplete(session, block.tool_use_id, postId);
|
|
102
166
|
}
|
|
103
167
|
}
|
|
104
168
|
}
|
|
105
169
|
}
|
|
106
|
-
const formatted = this.formatEvent(event);
|
|
170
|
+
const formatted = this.formatEvent(session, event);
|
|
107
171
|
if (this.debug) {
|
|
108
|
-
console.log(`[DEBUG] handleEvent: ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
|
|
172
|
+
console.log(`[DEBUG] handleEvent(${threadId}): ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
|
|
109
173
|
}
|
|
110
174
|
if (formatted)
|
|
111
|
-
this.appendContent(formatted);
|
|
175
|
+
this.appendContent(session, formatted);
|
|
112
176
|
}
|
|
113
|
-
async handleTaskComplete(toolUseId, postId) {
|
|
177
|
+
async handleTaskComplete(session, toolUseId, postId) {
|
|
114
178
|
try {
|
|
115
|
-
await this.mattermost.updatePost(postId,
|
|
179
|
+
await this.mattermost.updatePost(postId, session.activeSubagents.has(toolUseId)
|
|
116
180
|
? `🤖 **Subagent** ✅ *completed*`
|
|
117
181
|
: `🤖 **Subagent** ✅`);
|
|
118
|
-
|
|
182
|
+
session.activeSubagents.delete(toolUseId);
|
|
119
183
|
}
|
|
120
184
|
catch (err) {
|
|
121
185
|
console.error('[Session] Failed to update subagent completion:', err);
|
|
122
186
|
}
|
|
123
187
|
}
|
|
124
|
-
async handleExitPlanMode() {
|
|
125
|
-
if (!this.session)
|
|
126
|
-
return;
|
|
188
|
+
async handleExitPlanMode(session) {
|
|
127
189
|
// If already approved in this session, auto-continue
|
|
128
|
-
if (
|
|
190
|
+
if (session.planApproved) {
|
|
129
191
|
console.log('[Session] Plan already approved, auto-continuing...');
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
this.startTyping();
|
|
192
|
+
if (session.claude.isRunning()) {
|
|
193
|
+
session.claude.sendMessage('Continue with the implementation.');
|
|
194
|
+
this.startTyping(session);
|
|
133
195
|
}
|
|
134
196
|
return;
|
|
135
197
|
}
|
|
136
198
|
// If we already have a pending approval, don't post another one
|
|
137
|
-
if (
|
|
199
|
+
if (session.pendingApproval && session.pendingApproval.type === 'plan') {
|
|
138
200
|
console.log('[Session] Plan approval already pending, waiting...');
|
|
139
201
|
return;
|
|
140
202
|
}
|
|
141
203
|
// Flush any pending content first
|
|
142
|
-
await this.flush();
|
|
143
|
-
|
|
144
|
-
|
|
204
|
+
await this.flush(session);
|
|
205
|
+
session.currentPostId = null;
|
|
206
|
+
session.pendingContent = '';
|
|
145
207
|
// Post approval message with reactions
|
|
146
208
|
const message = `✅ **Plan ready for approval**\n\n` +
|
|
147
209
|
`👍 Approve and start building\n` +
|
|
148
210
|
`👎 Request changes\n\n` +
|
|
149
211
|
`*React to respond*`;
|
|
150
|
-
const post = await this.mattermost.createPost(message,
|
|
212
|
+
const post = await this.mattermost.createPost(message, session.threadId);
|
|
213
|
+
// Register post for reaction routing
|
|
214
|
+
this.registerPost(post.id, session.threadId);
|
|
151
215
|
// Add approval reactions
|
|
152
216
|
try {
|
|
153
217
|
await this.mattermost.addReaction(post.id, '+1');
|
|
@@ -157,19 +221,17 @@ export class SessionManager {
|
|
|
157
221
|
console.error('[Session] Failed to add approval reactions:', err);
|
|
158
222
|
}
|
|
159
223
|
// Track this for reaction handling
|
|
160
|
-
|
|
224
|
+
session.pendingApproval = { postId: post.id, type: 'plan' };
|
|
161
225
|
// Stop typing while waiting
|
|
162
|
-
this.stopTyping();
|
|
226
|
+
this.stopTyping(session);
|
|
163
227
|
}
|
|
164
|
-
async handleTodoWrite(input) {
|
|
165
|
-
if (!this.session)
|
|
166
|
-
return;
|
|
228
|
+
async handleTodoWrite(session, input) {
|
|
167
229
|
const todos = input.todos;
|
|
168
230
|
if (!todos || todos.length === 0) {
|
|
169
231
|
// Clear tasks display if empty
|
|
170
|
-
if (
|
|
232
|
+
if (session.tasksPostId) {
|
|
171
233
|
try {
|
|
172
|
-
await this.mattermost.updatePost(
|
|
234
|
+
await this.mattermost.updatePost(session.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
|
|
173
235
|
}
|
|
174
236
|
catch (err) {
|
|
175
237
|
console.error('[Session] Failed to update tasks:', err);
|
|
@@ -199,21 +261,19 @@ export class SessionManager {
|
|
|
199
261
|
}
|
|
200
262
|
// Update or create tasks post
|
|
201
263
|
try {
|
|
202
|
-
if (
|
|
203
|
-
await this.mattermost.updatePost(
|
|
264
|
+
if (session.tasksPostId) {
|
|
265
|
+
await this.mattermost.updatePost(session.tasksPostId, message);
|
|
204
266
|
}
|
|
205
267
|
else {
|
|
206
|
-
const post = await this.mattermost.createPost(message,
|
|
207
|
-
|
|
268
|
+
const post = await this.mattermost.createPost(message, session.threadId);
|
|
269
|
+
session.tasksPostId = post.id;
|
|
208
270
|
}
|
|
209
271
|
}
|
|
210
272
|
catch (err) {
|
|
211
273
|
console.error('[Session] Failed to update tasks:', err);
|
|
212
274
|
}
|
|
213
275
|
}
|
|
214
|
-
async handleTaskStart(toolUseId, input) {
|
|
215
|
-
if (!this.session)
|
|
216
|
-
return;
|
|
276
|
+
async handleTaskStart(session, toolUseId, input) {
|
|
217
277
|
const description = input.description || 'Working...';
|
|
218
278
|
const subagentType = input.subagent_type || 'general';
|
|
219
279
|
// Post subagent status
|
|
@@ -221,30 +281,28 @@ export class SessionManager {
|
|
|
221
281
|
`> ${description}\n` +
|
|
222
282
|
`⏳ Running...`;
|
|
223
283
|
try {
|
|
224
|
-
const post = await this.mattermost.createPost(message,
|
|
225
|
-
|
|
284
|
+
const post = await this.mattermost.createPost(message, session.threadId);
|
|
285
|
+
session.activeSubagents.set(toolUseId, post.id);
|
|
226
286
|
}
|
|
227
287
|
catch (err) {
|
|
228
288
|
console.error('[Session] Failed to post subagent status:', err);
|
|
229
289
|
}
|
|
230
290
|
}
|
|
231
|
-
async handleAskUserQuestion(toolUseId, input) {
|
|
232
|
-
if (!this.session)
|
|
233
|
-
return;
|
|
291
|
+
async handleAskUserQuestion(session, toolUseId, input) {
|
|
234
292
|
// If we already have pending questions, don't start another set
|
|
235
|
-
if (
|
|
293
|
+
if (session.pendingQuestionSet) {
|
|
236
294
|
console.log('[Session] Questions already pending, waiting...');
|
|
237
295
|
return;
|
|
238
296
|
}
|
|
239
297
|
// Flush any pending content first
|
|
240
|
-
await this.flush();
|
|
241
|
-
|
|
242
|
-
|
|
298
|
+
await this.flush(session);
|
|
299
|
+
session.currentPostId = null;
|
|
300
|
+
session.pendingContent = '';
|
|
243
301
|
const questions = input.questions;
|
|
244
302
|
if (!questions || questions.length === 0)
|
|
245
303
|
return;
|
|
246
304
|
// Create a new question set - we'll ask one at a time
|
|
247
|
-
|
|
305
|
+
session.pendingQuestionSet = {
|
|
248
306
|
toolUseId,
|
|
249
307
|
currentIndex: 0,
|
|
250
308
|
currentPostId: null,
|
|
@@ -256,19 +314,19 @@ export class SessionManager {
|
|
|
256
314
|
})),
|
|
257
315
|
};
|
|
258
316
|
// Post the first question
|
|
259
|
-
await this.postCurrentQuestion();
|
|
317
|
+
await this.postCurrentQuestion(session);
|
|
260
318
|
// Stop typing while waiting for answer
|
|
261
|
-
this.stopTyping();
|
|
319
|
+
this.stopTyping(session);
|
|
262
320
|
}
|
|
263
|
-
async postCurrentQuestion() {
|
|
264
|
-
if (!
|
|
321
|
+
async postCurrentQuestion(session) {
|
|
322
|
+
if (!session.pendingQuestionSet)
|
|
265
323
|
return;
|
|
266
|
-
const { currentIndex, questions } =
|
|
324
|
+
const { currentIndex, questions } = session.pendingQuestionSet;
|
|
267
325
|
if (currentIndex >= questions.length)
|
|
268
326
|
return;
|
|
269
327
|
const q = questions[currentIndex];
|
|
270
328
|
const total = questions.length;
|
|
271
|
-
// Format the question message
|
|
329
|
+
// Format the question message
|
|
272
330
|
let message = `❓ **Question** *(${currentIndex + 1}/${total})*\n`;
|
|
273
331
|
message += `**${q.header}:** ${q.question}\n\n`;
|
|
274
332
|
for (let i = 0; i < q.options.length && i < 4; i++) {
|
|
@@ -280,8 +338,10 @@ export class SessionManager {
|
|
|
280
338
|
message += '\n';
|
|
281
339
|
}
|
|
282
340
|
// Post the question
|
|
283
|
-
const post = await this.mattermost.createPost(message,
|
|
284
|
-
|
|
341
|
+
const post = await this.mattermost.createPost(message, session.threadId);
|
|
342
|
+
session.pendingQuestionSet.currentPostId = post.id;
|
|
343
|
+
// Register post for reaction routing
|
|
344
|
+
this.registerPost(post.id, session.threadId);
|
|
285
345
|
// Add reaction emojis
|
|
286
346
|
for (let i = 0; i < q.options.length && i < 4; i++) {
|
|
287
347
|
try {
|
|
@@ -292,19 +352,32 @@ export class SessionManager {
|
|
|
292
352
|
}
|
|
293
353
|
}
|
|
294
354
|
}
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Reaction Handling
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
295
358
|
async handleReaction(postId, emojiName, username) {
|
|
296
359
|
// Check if user is allowed
|
|
297
360
|
if (!this.mattermost.isUserAllowed(username))
|
|
298
361
|
return;
|
|
362
|
+
// Find the session this post belongs to
|
|
363
|
+
const session = this.getSessionByPost(postId);
|
|
364
|
+
if (!session)
|
|
365
|
+
return;
|
|
299
366
|
// Handle approval reactions
|
|
300
|
-
if (
|
|
301
|
-
await this.handleApprovalReaction(emojiName, username);
|
|
367
|
+
if (session.pendingApproval && session.pendingApproval.postId === postId) {
|
|
368
|
+
await this.handleApprovalReaction(session, emojiName, username);
|
|
302
369
|
return;
|
|
303
370
|
}
|
|
304
|
-
// Handle question reactions
|
|
305
|
-
if (
|
|
371
|
+
// Handle question reactions
|
|
372
|
+
if (session.pendingQuestionSet && session.pendingQuestionSet.currentPostId === postId) {
|
|
373
|
+
await this.handleQuestionReaction(session, postId, emojiName, username);
|
|
306
374
|
return;
|
|
307
|
-
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async handleQuestionReaction(session, postId, emojiName, username) {
|
|
378
|
+
if (!session.pendingQuestionSet)
|
|
379
|
+
return;
|
|
380
|
+
const { currentIndex, questions } = session.pendingQuestionSet;
|
|
308
381
|
const question = questions[currentIndex];
|
|
309
382
|
if (!question)
|
|
310
383
|
return;
|
|
@@ -322,35 +395,34 @@ export class SessionManager {
|
|
|
322
395
|
console.error('[Session] Failed to update answered question:', err);
|
|
323
396
|
}
|
|
324
397
|
// Move to next question or finish
|
|
325
|
-
|
|
326
|
-
if (
|
|
398
|
+
session.pendingQuestionSet.currentIndex++;
|
|
399
|
+
if (session.pendingQuestionSet.currentIndex < questions.length) {
|
|
327
400
|
// Post next question
|
|
328
|
-
await this.postCurrentQuestion();
|
|
401
|
+
await this.postCurrentQuestion(session);
|
|
329
402
|
}
|
|
330
403
|
else {
|
|
331
404
|
// All questions answered - send as follow-up message
|
|
332
|
-
// (CLI auto-responds with error to AskUserQuestion, so tool_result won't work)
|
|
333
405
|
let answersText = 'Here are my answers:\n';
|
|
334
406
|
for (const q of questions) {
|
|
335
407
|
answersText += `- **${q.header}**: ${q.answer}\n`;
|
|
336
408
|
}
|
|
337
409
|
console.log(`[Session] All questions answered, sending as message:`, answersText);
|
|
338
410
|
// Clear and send as regular message
|
|
339
|
-
|
|
340
|
-
if (
|
|
341
|
-
|
|
342
|
-
this.startTyping();
|
|
411
|
+
session.pendingQuestionSet = null;
|
|
412
|
+
if (session.claude.isRunning()) {
|
|
413
|
+
session.claude.sendMessage(answersText);
|
|
414
|
+
this.startTyping(session);
|
|
343
415
|
}
|
|
344
416
|
}
|
|
345
417
|
}
|
|
346
|
-
async handleApprovalReaction(emojiName, username) {
|
|
347
|
-
if (!
|
|
418
|
+
async handleApprovalReaction(session, emojiName, username) {
|
|
419
|
+
if (!session.pendingApproval)
|
|
348
420
|
return;
|
|
349
421
|
const isApprove = emojiName === '+1' || emojiName === 'thumbsup';
|
|
350
422
|
const isReject = emojiName === '-1' || emojiName === 'thumbsdown';
|
|
351
423
|
if (!isApprove && !isReject)
|
|
352
424
|
return;
|
|
353
|
-
const postId =
|
|
425
|
+
const postId = session.pendingApproval.postId;
|
|
354
426
|
console.log(`[Session] User ${username} ${isApprove ? 'approved' : 'rejected'} the plan`);
|
|
355
427
|
// Update the post to show the decision
|
|
356
428
|
try {
|
|
@@ -363,20 +435,20 @@ export class SessionManager {
|
|
|
363
435
|
console.error('[Session] Failed to update approval post:', err);
|
|
364
436
|
}
|
|
365
437
|
// Clear pending approval and mark as approved
|
|
366
|
-
|
|
438
|
+
session.pendingApproval = null;
|
|
367
439
|
if (isApprove) {
|
|
368
|
-
|
|
440
|
+
session.planApproved = true;
|
|
369
441
|
}
|
|
370
442
|
// Send response to Claude
|
|
371
|
-
if (
|
|
443
|
+
if (session.claude.isRunning()) {
|
|
372
444
|
const response = isApprove
|
|
373
445
|
? 'Approved. Please proceed with the implementation.'
|
|
374
446
|
: 'Please revise the plan. I would like some changes.';
|
|
375
|
-
|
|
376
|
-
this.startTyping();
|
|
447
|
+
session.claude.sendMessage(response);
|
|
448
|
+
this.startTyping(session);
|
|
377
449
|
}
|
|
378
450
|
}
|
|
379
|
-
formatEvent(e) {
|
|
451
|
+
formatEvent(session, e) {
|
|
380
452
|
switch (e.type) {
|
|
381
453
|
case 'assistant': {
|
|
382
454
|
const msg = e.message;
|
|
@@ -415,12 +487,10 @@ export class SessionManager {
|
|
|
415
487
|
}
|
|
416
488
|
case 'result': {
|
|
417
489
|
// Response complete - stop typing and start new post for next message
|
|
418
|
-
this.stopTyping();
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
this.session.content = '';
|
|
423
|
-
}
|
|
490
|
+
this.stopTyping(session);
|
|
491
|
+
this.flush(session);
|
|
492
|
+
session.currentPostId = null;
|
|
493
|
+
session.pendingContent = '';
|
|
424
494
|
return null;
|
|
425
495
|
}
|
|
426
496
|
case 'system':
|
|
@@ -508,75 +578,134 @@ export class SessionManager {
|
|
|
508
578
|
}
|
|
509
579
|
}
|
|
510
580
|
}
|
|
511
|
-
appendContent(text) {
|
|
512
|
-
if (!
|
|
581
|
+
appendContent(session, text) {
|
|
582
|
+
if (!text)
|
|
513
583
|
return;
|
|
514
|
-
|
|
515
|
-
this.scheduleUpdate();
|
|
584
|
+
session.pendingContent += text + '\n';
|
|
585
|
+
this.scheduleUpdate(session);
|
|
516
586
|
}
|
|
517
|
-
scheduleUpdate() {
|
|
518
|
-
if (
|
|
587
|
+
scheduleUpdate(session) {
|
|
588
|
+
if (session.updateTimer)
|
|
519
589
|
return;
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
this.flush();
|
|
590
|
+
session.updateTimer = setTimeout(() => {
|
|
591
|
+
session.updateTimer = null;
|
|
592
|
+
this.flush(session);
|
|
523
593
|
}, 500);
|
|
524
594
|
}
|
|
525
|
-
startTyping() {
|
|
526
|
-
if (
|
|
595
|
+
startTyping(session) {
|
|
596
|
+
if (session.typingTimer)
|
|
527
597
|
return;
|
|
528
598
|
// Send typing immediately, then every 3 seconds
|
|
529
|
-
this.mattermost.sendTyping(
|
|
530
|
-
|
|
531
|
-
this.mattermost.sendTyping(
|
|
599
|
+
this.mattermost.sendTyping(session.threadId);
|
|
600
|
+
session.typingTimer = setInterval(() => {
|
|
601
|
+
this.mattermost.sendTyping(session.threadId);
|
|
532
602
|
}, 3000);
|
|
533
603
|
}
|
|
534
|
-
stopTyping() {
|
|
535
|
-
if (
|
|
536
|
-
clearInterval(
|
|
537
|
-
|
|
604
|
+
stopTyping(session) {
|
|
605
|
+
if (session.typingTimer) {
|
|
606
|
+
clearInterval(session.typingTimer);
|
|
607
|
+
session.typingTimer = null;
|
|
538
608
|
}
|
|
539
609
|
}
|
|
540
|
-
async flush() {
|
|
541
|
-
if (!
|
|
610
|
+
async flush(session) {
|
|
611
|
+
if (!session.pendingContent.trim())
|
|
542
612
|
return;
|
|
543
|
-
const content =
|
|
544
|
-
if (
|
|
545
|
-
await this.mattermost.updatePost(
|
|
613
|
+
const content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
|
|
614
|
+
if (session.currentPostId) {
|
|
615
|
+
await this.mattermost.updatePost(session.currentPostId, content);
|
|
546
616
|
}
|
|
547
617
|
else {
|
|
548
|
-
const post = await this.mattermost.createPost(content,
|
|
549
|
-
|
|
618
|
+
const post = await this.mattermost.createPost(content, session.threadId);
|
|
619
|
+
session.currentPostId = post.id;
|
|
620
|
+
// Register post for reaction routing
|
|
621
|
+
this.registerPost(post.id, session.threadId);
|
|
550
622
|
}
|
|
551
623
|
}
|
|
552
|
-
async handleExit(code) {
|
|
553
|
-
this.
|
|
554
|
-
if (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
624
|
+
async handleExit(threadId, code) {
|
|
625
|
+
const session = this.sessions.get(threadId);
|
|
626
|
+
if (!session)
|
|
627
|
+
return;
|
|
628
|
+
this.stopTyping(session);
|
|
629
|
+
if (session.updateTimer) {
|
|
630
|
+
clearTimeout(session.updateTimer);
|
|
631
|
+
session.updateTimer = null;
|
|
632
|
+
}
|
|
633
|
+
await this.flush(session);
|
|
634
|
+
if (code !== 0) {
|
|
635
|
+
await this.mattermost.createPost(`**[Exited: ${code}]**`, session.threadId);
|
|
636
|
+
}
|
|
637
|
+
// Clean up session from maps
|
|
638
|
+
this.sessions.delete(threadId);
|
|
639
|
+
// Clean up post index entries for this session
|
|
640
|
+
for (const [postId, tid] of this.postIndex.entries()) {
|
|
641
|
+
if (tid === threadId) {
|
|
642
|
+
this.postIndex.delete(postId);
|
|
643
|
+
}
|
|
561
644
|
}
|
|
562
|
-
|
|
645
|
+
console.log(`[Sessions] Session ended for thread ${threadId} (remaining: ${this.sessions.size})`);
|
|
563
646
|
}
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// Public Session API
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
/** Check if any sessions are active */
|
|
564
651
|
isSessionActive() {
|
|
565
|
-
return this.
|
|
652
|
+
return this.sessions.size > 0;
|
|
566
653
|
}
|
|
567
|
-
|
|
568
|
-
|
|
654
|
+
/** Check if a session exists for this thread */
|
|
655
|
+
isInSessionThread(threadRoot) {
|
|
656
|
+
const session = this.sessions.get(threadRoot);
|
|
657
|
+
return session !== undefined && session.claude.isRunning();
|
|
658
|
+
}
|
|
659
|
+
/** Send a follow-up message to an existing session */
|
|
660
|
+
async sendFollowUp(threadId, message) {
|
|
661
|
+
const session = this.sessions.get(threadId);
|
|
662
|
+
if (!session || !session.claude.isRunning())
|
|
663
|
+
return;
|
|
664
|
+
session.claude.sendMessage(message);
|
|
665
|
+
session.lastActivityAt = new Date();
|
|
666
|
+
this.startTyping(session);
|
|
569
667
|
}
|
|
570
|
-
|
|
571
|
-
|
|
668
|
+
/** Kill a specific session */
|
|
669
|
+
killSession(threadId) {
|
|
670
|
+
const session = this.sessions.get(threadId);
|
|
671
|
+
if (!session)
|
|
572
672
|
return;
|
|
573
|
-
this.
|
|
574
|
-
|
|
673
|
+
this.stopTyping(session);
|
|
674
|
+
session.claude.kill();
|
|
675
|
+
// Clean up session from maps
|
|
676
|
+
this.sessions.delete(threadId);
|
|
677
|
+
for (const [postId, tid] of this.postIndex.entries()) {
|
|
678
|
+
if (tid === threadId) {
|
|
679
|
+
this.postIndex.delete(postId);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
console.log(`[Sessions] Session killed for thread ${threadId} (remaining: ${this.sessions.size})`);
|
|
683
|
+
}
|
|
684
|
+
/** Kill all active sessions (for graceful shutdown) */
|
|
685
|
+
killAllSessions() {
|
|
686
|
+
for (const [threadId, session] of this.sessions.entries()) {
|
|
687
|
+
this.stopTyping(session);
|
|
688
|
+
session.claude.kill();
|
|
689
|
+
console.log(`[Sessions] Killed session for thread ${threadId}`);
|
|
690
|
+
}
|
|
691
|
+
this.sessions.clear();
|
|
692
|
+
this.postIndex.clear();
|
|
693
|
+
if (this.cleanupTimer) {
|
|
694
|
+
clearInterval(this.cleanupTimer);
|
|
695
|
+
this.cleanupTimer = null;
|
|
696
|
+
}
|
|
697
|
+
console.log(`[Sessions] All sessions killed`);
|
|
575
698
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
this.
|
|
580
|
-
|
|
699
|
+
/** Cleanup idle sessions that have exceeded timeout */
|
|
700
|
+
cleanupIdleSessions() {
|
|
701
|
+
const now = Date.now();
|
|
702
|
+
for (const [threadId, session] of this.sessions.entries()) {
|
|
703
|
+
const idleTime = now - session.lastActivityAt.getTime();
|
|
704
|
+
if (idleTime > SESSION_TIMEOUT_MS) {
|
|
705
|
+
console.log(`[Sessions] Session ${threadId} timed out after ${Math.round(idleTime / 60000)} minutes`);
|
|
706
|
+
this.mattermost.createPost(`⏰ **Session timed out** - no activity for ${Math.round(idleTime / 60000)} minutes`, session.threadId).catch(err => console.error('[Sessions] Failed to post timeout message:', err));
|
|
707
|
+
this.killSession(threadId);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
581
710
|
}
|
|
582
711
|
}
|
package/dist/index.js
CHANGED
|
@@ -36,14 +36,14 @@ Usage: cd /your/project && mm-claude`);
|
|
|
36
36
|
const message = post.message;
|
|
37
37
|
const threadRoot = post.root_id || post.id;
|
|
38
38
|
// Follow-up in active thread
|
|
39
|
-
if (session.
|
|
39
|
+
if (session.isInSessionThread(threadRoot)) {
|
|
40
40
|
if (!mattermost.isUserAllowed(username))
|
|
41
41
|
return;
|
|
42
42
|
const content = mattermost.isBotMentioned(message)
|
|
43
43
|
? mattermost.extractPrompt(message)
|
|
44
44
|
: message.trim();
|
|
45
45
|
if (content)
|
|
46
|
-
await session.sendFollowUp(content);
|
|
46
|
+
await session.sendFollowUp(threadRoot, content);
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
49
|
// New session requires @mention
|
|
@@ -66,7 +66,7 @@ Usage: cd /your/project && mm-claude`);
|
|
|
66
66
|
console.log(`🎉 Ready! @${config.mattermost.botName}`);
|
|
67
67
|
const shutdown = () => {
|
|
68
68
|
console.log('\n👋 Bye');
|
|
69
|
-
session.
|
|
69
|
+
session.killAllSessions();
|
|
70
70
|
mattermost.disconnect();
|
|
71
71
|
process.exit(0);
|
|
72
72
|
};
|
package/package.json
CHANGED
package/dist/claude/types.d.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
export type ClaudeStreamEvent = SystemEvent | AssistantEvent | UserEvent | ToolUseEvent | ToolResultEvent | ResultEvent;
|
|
2
|
-
export interface SystemEvent {
|
|
3
|
-
type: 'system';
|
|
4
|
-
subtype: 'init' | 'error';
|
|
5
|
-
session_id?: string;
|
|
6
|
-
message?: string;
|
|
7
|
-
error?: string;
|
|
8
|
-
}
|
|
9
|
-
export interface AssistantEvent {
|
|
10
|
-
type: 'assistant';
|
|
11
|
-
message: {
|
|
12
|
-
id: string;
|
|
13
|
-
type: 'message';
|
|
14
|
-
role: 'assistant';
|
|
15
|
-
content: ContentBlock[];
|
|
16
|
-
model: string;
|
|
17
|
-
stop_reason: string | null;
|
|
18
|
-
stop_sequence: string | null;
|
|
19
|
-
};
|
|
20
|
-
session_id: string;
|
|
21
|
-
}
|
|
22
|
-
export interface UserEvent {
|
|
23
|
-
type: 'user';
|
|
24
|
-
message: {
|
|
25
|
-
role: 'user';
|
|
26
|
-
content: string;
|
|
27
|
-
};
|
|
28
|
-
session_id: string;
|
|
29
|
-
}
|
|
30
|
-
export interface ToolUseEvent {
|
|
31
|
-
type: 'tool_use';
|
|
32
|
-
tool_use: {
|
|
33
|
-
id: string;
|
|
34
|
-
name: string;
|
|
35
|
-
input: Record<string, unknown>;
|
|
36
|
-
};
|
|
37
|
-
session_id: string;
|
|
38
|
-
}
|
|
39
|
-
export interface ToolResultEvent {
|
|
40
|
-
type: 'tool_result';
|
|
41
|
-
tool_result: {
|
|
42
|
-
tool_use_id: string;
|
|
43
|
-
content: string | ContentBlock[];
|
|
44
|
-
is_error?: boolean;
|
|
45
|
-
};
|
|
46
|
-
session_id: string;
|
|
47
|
-
}
|
|
48
|
-
export interface ResultEvent {
|
|
49
|
-
type: 'result';
|
|
50
|
-
result: string;
|
|
51
|
-
session_id: string;
|
|
52
|
-
cost_usd?: number;
|
|
53
|
-
duration_ms?: number;
|
|
54
|
-
is_error?: boolean;
|
|
55
|
-
}
|
|
56
|
-
export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock;
|
|
57
|
-
export interface TextBlock {
|
|
58
|
-
type: 'text';
|
|
59
|
-
text: string;
|
|
60
|
-
}
|
|
61
|
-
export interface ToolUseBlock {
|
|
62
|
-
type: 'tool_use';
|
|
63
|
-
id: string;
|
|
64
|
-
name: string;
|
|
65
|
-
input: Record<string, unknown>;
|
|
66
|
-
}
|
|
67
|
-
export interface ToolResultBlock {
|
|
68
|
-
type: 'tool_result';
|
|
69
|
-
tool_use_id: string;
|
|
70
|
-
content: string;
|
|
71
|
-
is_error?: boolean;
|
|
72
|
-
}
|
|
73
|
-
export interface ClaudeUserInput {
|
|
74
|
-
type: 'user';
|
|
75
|
-
content: string;
|
|
76
|
-
}
|
package/dist/claude/types.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { AssistantEvent, ToolUseEvent, ToolResultEvent, ResultEvent } from '../claude/types.js';
|
|
2
|
-
/**
|
|
3
|
-
* Format Claude events into Mattermost-friendly messages
|
|
4
|
-
* Styled to look similar to Claude Code CLI output
|
|
5
|
-
*/
|
|
6
|
-
export declare class MessageFormatter {
|
|
7
|
-
private pendingToolUses;
|
|
8
|
-
formatUserPrompt(username: string, prompt: string): string;
|
|
9
|
-
formatSessionStart(workingDir: string): string;
|
|
10
|
-
formatSessionEnd(result?: ResultEvent): string;
|
|
11
|
-
formatAssistantMessage(event: AssistantEvent): string;
|
|
12
|
-
formatToolUse(event: ToolUseEvent): string;
|
|
13
|
-
private formatReadTool;
|
|
14
|
-
private formatEditTool;
|
|
15
|
-
private formatWriteTool;
|
|
16
|
-
private formatBashTool;
|
|
17
|
-
private formatGlobTool;
|
|
18
|
-
private formatGrepTool;
|
|
19
|
-
private formatTaskTool;
|
|
20
|
-
private formatWebSearchTool;
|
|
21
|
-
private formatWebFetchTool;
|
|
22
|
-
private formatTodoWriteTool;
|
|
23
|
-
formatToolResult(event: ToolResultEvent): string | null;
|
|
24
|
-
formatUnauthorized(username: string): string;
|
|
25
|
-
formatError(error: string): string;
|
|
26
|
-
private truncate;
|
|
27
|
-
private shortenPath;
|
|
28
|
-
}
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Format Claude events into Mattermost-friendly messages
|
|
3
|
-
* Styled to look similar to Claude Code CLI output
|
|
4
|
-
*/
|
|
5
|
-
export class MessageFormatter {
|
|
6
|
-
// Store tool results to show line counts, etc.
|
|
7
|
-
pendingToolUses = new Map();
|
|
8
|
-
// Format a user's prompt for display (like Claude Code's user input)
|
|
9
|
-
formatUserPrompt(username, prompt) {
|
|
10
|
-
return `> **${username}:** ${prompt}`;
|
|
11
|
-
}
|
|
12
|
-
// Format session start
|
|
13
|
-
formatSessionStart(workingDir) {
|
|
14
|
-
const shortPath = this.shortenPath(workingDir);
|
|
15
|
-
return [
|
|
16
|
-
'```',
|
|
17
|
-
`🚀 Session started`,
|
|
18
|
-
` Working directory: ${shortPath}`,
|
|
19
|
-
'```',
|
|
20
|
-
].join('\n');
|
|
21
|
-
}
|
|
22
|
-
// Format session end with stats
|
|
23
|
-
formatSessionEnd(result) {
|
|
24
|
-
if (result?.cost_usd) {
|
|
25
|
-
const duration = result.duration_ms
|
|
26
|
-
? `${(result.duration_ms / 1000).toFixed(1)}s`
|
|
27
|
-
: '';
|
|
28
|
-
const cost = `$${result.cost_usd.toFixed(4)}`;
|
|
29
|
-
return [
|
|
30
|
-
'```',
|
|
31
|
-
`✓ Session completed`,
|
|
32
|
-
` Duration: ${duration} | Cost: ${cost}`,
|
|
33
|
-
'```',
|
|
34
|
-
].join('\n');
|
|
35
|
-
}
|
|
36
|
-
return '```\n✓ Session completed\n```';
|
|
37
|
-
}
|
|
38
|
-
// Format assistant message content
|
|
39
|
-
formatAssistantMessage(event) {
|
|
40
|
-
const content = event.message.content;
|
|
41
|
-
const parts = [];
|
|
42
|
-
for (const block of content) {
|
|
43
|
-
if (block.type === 'text') {
|
|
44
|
-
parts.push(block.text);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return parts.join('\n');
|
|
48
|
-
}
|
|
49
|
-
// Format tool use - Claude Code style: ● ToolName(args)
|
|
50
|
-
formatToolUse(event) {
|
|
51
|
-
const { id, name, input } = event.tool_use;
|
|
52
|
-
// Store for later when we get the result
|
|
53
|
-
this.pendingToolUses.set(id, event);
|
|
54
|
-
switch (name) {
|
|
55
|
-
case 'Read':
|
|
56
|
-
return this.formatReadTool(input);
|
|
57
|
-
case 'Edit':
|
|
58
|
-
return this.formatEditTool(input);
|
|
59
|
-
case 'Write':
|
|
60
|
-
return this.formatWriteTool(input);
|
|
61
|
-
case 'Bash':
|
|
62
|
-
return this.formatBashTool(input);
|
|
63
|
-
case 'Glob':
|
|
64
|
-
return this.formatGlobTool(input);
|
|
65
|
-
case 'Grep':
|
|
66
|
-
return this.formatGrepTool(input);
|
|
67
|
-
case 'Task':
|
|
68
|
-
return this.formatTaskTool(input);
|
|
69
|
-
case 'WebSearch':
|
|
70
|
-
return this.formatWebSearchTool(input);
|
|
71
|
-
case 'WebFetch':
|
|
72
|
-
return this.formatWebFetchTool(input);
|
|
73
|
-
case 'TodoWrite':
|
|
74
|
-
return this.formatTodoWriteTool(input);
|
|
75
|
-
default:
|
|
76
|
-
return `● **${name}**`;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
formatReadTool(input) {
|
|
80
|
-
const path = input.file_path;
|
|
81
|
-
const shortPath = this.shortenPath(path);
|
|
82
|
-
return `● **Read**(\`${shortPath}\`)`;
|
|
83
|
-
}
|
|
84
|
-
formatEditTool(input) {
|
|
85
|
-
const path = input.file_path;
|
|
86
|
-
const shortPath = this.shortenPath(path);
|
|
87
|
-
const oldStr = input.old_string;
|
|
88
|
-
const newStr = input.new_string;
|
|
89
|
-
// Count lines changed
|
|
90
|
-
const oldLines = oldStr.split('\n').length;
|
|
91
|
-
const newLines = newStr.split('\n').length;
|
|
92
|
-
// Create diff preview
|
|
93
|
-
const diffLines = [];
|
|
94
|
-
const oldPreview = oldStr.split('\n').slice(0, 3);
|
|
95
|
-
const newPreview = newStr.split('\n').slice(0, 3);
|
|
96
|
-
for (const line of oldPreview) {
|
|
97
|
-
diffLines.push(`- ${line}`);
|
|
98
|
-
}
|
|
99
|
-
if (oldLines > 3)
|
|
100
|
-
diffLines.push(` ... (${oldLines - 3} more lines)`);
|
|
101
|
-
for (const line of newPreview) {
|
|
102
|
-
diffLines.push(`+ ${line}`);
|
|
103
|
-
}
|
|
104
|
-
if (newLines > 3)
|
|
105
|
-
diffLines.push(` ... (${newLines - 3} more lines)`);
|
|
106
|
-
return [
|
|
107
|
-
`● **Edit**(\`${shortPath}\`)`,
|
|
108
|
-
'```diff',
|
|
109
|
-
...diffLines,
|
|
110
|
-
'```',
|
|
111
|
-
].join('\n');
|
|
112
|
-
}
|
|
113
|
-
formatWriteTool(input) {
|
|
114
|
-
const path = input.file_path;
|
|
115
|
-
const shortPath = this.shortenPath(path);
|
|
116
|
-
const content = input.content;
|
|
117
|
-
const lines = content.split('\n').length;
|
|
118
|
-
return `● **Write**(\`${shortPath}\`) - ${lines} lines`;
|
|
119
|
-
}
|
|
120
|
-
formatBashTool(input) {
|
|
121
|
-
const command = input.command;
|
|
122
|
-
const desc = input.description;
|
|
123
|
-
// Truncate long commands
|
|
124
|
-
const cmdPreview = command.length > 80
|
|
125
|
-
? command.substring(0, 77) + '...'
|
|
126
|
-
: command;
|
|
127
|
-
if (desc) {
|
|
128
|
-
return [
|
|
129
|
-
`● **Bash**(${desc})`,
|
|
130
|
-
'```bash',
|
|
131
|
-
cmdPreview,
|
|
132
|
-
'```',
|
|
133
|
-
].join('\n');
|
|
134
|
-
}
|
|
135
|
-
return [
|
|
136
|
-
`● **Bash**`,
|
|
137
|
-
'```bash',
|
|
138
|
-
cmdPreview,
|
|
139
|
-
'```',
|
|
140
|
-
].join('\n');
|
|
141
|
-
}
|
|
142
|
-
formatGlobTool(input) {
|
|
143
|
-
const pattern = input.pattern;
|
|
144
|
-
return `● **Glob**(\`${pattern}\`)`;
|
|
145
|
-
}
|
|
146
|
-
formatGrepTool(input) {
|
|
147
|
-
const pattern = input.pattern;
|
|
148
|
-
const path = input.path;
|
|
149
|
-
if (path) {
|
|
150
|
-
return `● **Grep**(\`${pattern}\` in \`${this.shortenPath(path)}\`)`;
|
|
151
|
-
}
|
|
152
|
-
return `● **Grep**(\`${pattern}\`)`;
|
|
153
|
-
}
|
|
154
|
-
formatTaskTool(input) {
|
|
155
|
-
const desc = input.description;
|
|
156
|
-
const type = input.subagent_type;
|
|
157
|
-
if (desc) {
|
|
158
|
-
return `● **Task**(${type || 'agent'}: ${desc})`;
|
|
159
|
-
}
|
|
160
|
-
return `● **Task**(${type || 'agent'})`;
|
|
161
|
-
}
|
|
162
|
-
formatWebSearchTool(input) {
|
|
163
|
-
const query = input.query;
|
|
164
|
-
return `● **WebSearch**(\`${query}\`)`;
|
|
165
|
-
}
|
|
166
|
-
formatWebFetchTool(input) {
|
|
167
|
-
const url = input.url;
|
|
168
|
-
const shortUrl = url.length > 50 ? url.substring(0, 47) + '...' : url;
|
|
169
|
-
return `● **WebFetch**(\`${shortUrl}\`)`;
|
|
170
|
-
}
|
|
171
|
-
formatTodoWriteTool(input) {
|
|
172
|
-
const todos = input.todos;
|
|
173
|
-
if (todos && todos.length > 0) {
|
|
174
|
-
const summary = todos.slice(0, 2).map(t => t.content).join(', ');
|
|
175
|
-
const more = todos.length > 2 ? ` +${todos.length - 2} more` : '';
|
|
176
|
-
return `● **TodoWrite**(${summary}${more})`;
|
|
177
|
-
}
|
|
178
|
-
return `● **TodoWrite**`;
|
|
179
|
-
}
|
|
180
|
-
// Format tool result with line counts, etc.
|
|
181
|
-
formatToolResult(event) {
|
|
182
|
-
const { tool_use_id, content, is_error } = event.tool_result;
|
|
183
|
-
const toolUse = this.pendingToolUses.get(tool_use_id);
|
|
184
|
-
if (is_error) {
|
|
185
|
-
const errorMsg = typeof content === 'string'
|
|
186
|
-
? content
|
|
187
|
-
: JSON.stringify(content);
|
|
188
|
-
return ` ↳ ❌ ${this.truncate(errorMsg, 150)}`;
|
|
189
|
-
}
|
|
190
|
-
// Get result as string
|
|
191
|
-
const resultStr = typeof content === 'string'
|
|
192
|
-
? content
|
|
193
|
-
: JSON.stringify(content, null, 2);
|
|
194
|
-
// For Read tool, show line count
|
|
195
|
-
if (toolUse?.tool_use.name === 'Read') {
|
|
196
|
-
const lines = resultStr.split('\n').length;
|
|
197
|
-
return ` ↳ Read ${lines} lines`;
|
|
198
|
-
}
|
|
199
|
-
// For Glob, show file count
|
|
200
|
-
if (toolUse?.tool_use.name === 'Glob') {
|
|
201
|
-
const files = resultStr.split('\n').filter(l => l.trim()).length;
|
|
202
|
-
return ` ↳ Found ${files} files`;
|
|
203
|
-
}
|
|
204
|
-
// For Grep, show match count
|
|
205
|
-
if (toolUse?.tool_use.name === 'Grep') {
|
|
206
|
-
const matches = resultStr.split('\n').filter(l => l.trim()).length;
|
|
207
|
-
return ` ↳ Found ${matches} matches`;
|
|
208
|
-
}
|
|
209
|
-
// For Bash, show truncated output if not too long
|
|
210
|
-
if (toolUse?.tool_use.name === 'Bash' && resultStr.length > 0) {
|
|
211
|
-
const lines = resultStr.split('\n');
|
|
212
|
-
if (lines.length <= 5 && resultStr.length < 300) {
|
|
213
|
-
return ' ↳ ```\n' + resultStr + '\n ```';
|
|
214
|
-
}
|
|
215
|
-
return ` ↳ Output: ${lines.length} lines`;
|
|
216
|
-
}
|
|
217
|
-
// Skip showing most results to avoid noise
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
// Format unauthorized user message
|
|
221
|
-
formatUnauthorized(username) {
|
|
222
|
-
return `⚠️ Sorry @${username}, you're not authorized to use this bot.`;
|
|
223
|
-
}
|
|
224
|
-
// Format error message
|
|
225
|
-
formatError(error) {
|
|
226
|
-
return `❌ **Error:** ${error}`;
|
|
227
|
-
}
|
|
228
|
-
// Helper to truncate strings
|
|
229
|
-
truncate(str, maxLength) {
|
|
230
|
-
const clean = str.replace(/\n/g, ' ').trim();
|
|
231
|
-
if (clean.length <= maxLength)
|
|
232
|
-
return clean;
|
|
233
|
-
return clean.substring(0, maxLength - 3) + '...';
|
|
234
|
-
}
|
|
235
|
-
// Helper to shorten file paths
|
|
236
|
-
shortenPath(path) {
|
|
237
|
-
// Replace home directory with ~
|
|
238
|
-
const home = process.env.HOME || '/Users/anneschuth';
|
|
239
|
-
if (path.startsWith(home)) {
|
|
240
|
-
return '~' + path.substring(home.length);
|
|
241
|
-
}
|
|
242
|
-
return path;
|
|
243
|
-
}
|
|
244
|
-
}
|