mattermost-claude-code 0.2.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/LICENSE +21 -0
- package/README.md +170 -0
- package/dist/claude/cli.d.ts +24 -0
- package/dist/claude/cli.js +139 -0
- package/dist/claude/session.d.ts +41 -0
- package/dist/claude/session.js +582 -0
- package/dist/claude/types.d.ts +76 -0
- package/dist/claude/types.js +3 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +67 -0
- package/dist/mattermost/client.d.ts +35 -0
- package/dist/mattermost/client.js +226 -0
- package/dist/mattermost/message-formatter.d.ts +28 -0
- package/dist/mattermost/message-formatter.js +244 -0
- package/dist/mattermost/types.d.ts +62 -0
- package/dist/mattermost/types.js +1 -0
- package/dist/mcp/permission-server.d.ts +2 -0
- package/dist/mcp/permission-server.js +272 -0
- package/package.json +56 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { ClaudeCli } from './cli.js';
|
|
2
|
+
const REACTION_EMOJIS = ['one', 'two', 'three', 'four'];
|
|
3
|
+
const EMOJI_TO_INDEX = {
|
|
4
|
+
'one': 0, '1️⃣': 0,
|
|
5
|
+
'two': 1, '2️⃣': 1,
|
|
6
|
+
'three': 2, '3️⃣': 2,
|
|
7
|
+
'four': 3, '4️⃣': 3,
|
|
8
|
+
};
|
|
9
|
+
export class SessionManager {
|
|
10
|
+
claude = null;
|
|
11
|
+
mattermost;
|
|
12
|
+
workingDir;
|
|
13
|
+
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
|
+
debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
|
|
23
|
+
constructor(mattermost, workingDir, skipPermissions = false) {
|
|
24
|
+
this.mattermost = mattermost;
|
|
25
|
+
this.workingDir = workingDir;
|
|
26
|
+
this.skipPermissions = skipPermissions;
|
|
27
|
+
// Listen for reactions to answer questions
|
|
28
|
+
this.mattermost.on('reaction', (reaction, user) => {
|
|
29
|
+
this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async startSession(options, username, replyToPostId) {
|
|
33
|
+
// Start Claude if not running
|
|
34
|
+
if (!this.claude?.isRunning()) {
|
|
35
|
+
const msg = `🚀 **Session started**\n> Working directory: \`${this.workingDir}\``;
|
|
36
|
+
const post = await this.mattermost.createPost(msg, replyToPostId);
|
|
37
|
+
const threadId = replyToPostId || post.id;
|
|
38
|
+
this.session = { threadId, postId: null, content: '' };
|
|
39
|
+
this.planApproved = false; // Reset for new session
|
|
40
|
+
this.tasksPostId = null; // Reset tasks display
|
|
41
|
+
this.activeSubagents.clear(); // Clear subagent tracking
|
|
42
|
+
// Create Claude CLI with options (including threadId for permissions)
|
|
43
|
+
const cliOptions = {
|
|
44
|
+
workingDir: this.workingDir,
|
|
45
|
+
threadId: threadId,
|
|
46
|
+
skipPermissions: this.skipPermissions,
|
|
47
|
+
};
|
|
48
|
+
this.claude = new ClaudeCli(cliOptions);
|
|
49
|
+
this.claude.on('event', (e) => this.handleEvent(e));
|
|
50
|
+
this.claude.on('exit', (code) => this.handleExit(code));
|
|
51
|
+
try {
|
|
52
|
+
this.claude.start();
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
console.error('[Session] Start error:', err);
|
|
56
|
+
await this.mattermost.createPost(`❌ ${err}`, threadId);
|
|
57
|
+
this.session = null;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Send the message and start typing indicator
|
|
62
|
+
this.claude.sendMessage(options.prompt);
|
|
63
|
+
this.startTyping();
|
|
64
|
+
}
|
|
65
|
+
handleEvent(event) {
|
|
66
|
+
// Check for special tool uses that need custom handling
|
|
67
|
+
if (event.type === 'assistant') {
|
|
68
|
+
const msg = event.message;
|
|
69
|
+
let hasSpecialTool = false;
|
|
70
|
+
for (const block of msg?.content || []) {
|
|
71
|
+
if (block.type === 'tool_use') {
|
|
72
|
+
if (block.name === 'ExitPlanMode') {
|
|
73
|
+
this.handleExitPlanMode();
|
|
74
|
+
hasSpecialTool = true;
|
|
75
|
+
}
|
|
76
|
+
else if (block.name === 'TodoWrite') {
|
|
77
|
+
this.handleTodoWrite(block.input);
|
|
78
|
+
// Don't set hasSpecialTool - let other content through
|
|
79
|
+
}
|
|
80
|
+
else if (block.name === 'Task') {
|
|
81
|
+
this.handleTaskStart(block.id, block.input);
|
|
82
|
+
// Don't set hasSpecialTool - let other content through
|
|
83
|
+
}
|
|
84
|
+
else if (block.name === 'AskUserQuestion') {
|
|
85
|
+
this.handleAskUserQuestion(block.id, block.input);
|
|
86
|
+
hasSpecialTool = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Skip normal output if we handled a special tool (we post it ourselves)
|
|
91
|
+
if (hasSpecialTool)
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Check for tool_result to update subagent status
|
|
95
|
+
if (event.type === 'user') {
|
|
96
|
+
const msg = event.message;
|
|
97
|
+
for (const block of msg?.content || []) {
|
|
98
|
+
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
99
|
+
const postId = this.activeSubagents.get(block.tool_use_id);
|
|
100
|
+
if (postId) {
|
|
101
|
+
this.handleTaskComplete(block.tool_use_id, postId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const formatted = this.formatEvent(event);
|
|
107
|
+
if (this.debug) {
|
|
108
|
+
console.log(`[DEBUG] handleEvent: ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
|
|
109
|
+
}
|
|
110
|
+
if (formatted)
|
|
111
|
+
this.appendContent(formatted);
|
|
112
|
+
}
|
|
113
|
+
async handleTaskComplete(toolUseId, postId) {
|
|
114
|
+
try {
|
|
115
|
+
await this.mattermost.updatePost(postId, this.activeSubagents.has(toolUseId)
|
|
116
|
+
? `🤖 **Subagent** ✅ *completed*`
|
|
117
|
+
: `🤖 **Subagent** ✅`);
|
|
118
|
+
this.activeSubagents.delete(toolUseId);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
console.error('[Session] Failed to update subagent completion:', err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async handleExitPlanMode() {
|
|
125
|
+
if (!this.session)
|
|
126
|
+
return;
|
|
127
|
+
// If already approved in this session, auto-continue
|
|
128
|
+
if (this.planApproved) {
|
|
129
|
+
console.log('[Session] Plan already approved, auto-continuing...');
|
|
130
|
+
if (this.claude?.isRunning()) {
|
|
131
|
+
this.claude.sendMessage('Continue with the implementation.');
|
|
132
|
+
this.startTyping();
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// If we already have a pending approval, don't post another one
|
|
137
|
+
if (this.pendingApproval && this.pendingApproval.type === 'plan') {
|
|
138
|
+
console.log('[Session] Plan approval already pending, waiting...');
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Flush any pending content first
|
|
142
|
+
await this.flush();
|
|
143
|
+
this.session.postId = null;
|
|
144
|
+
this.session.content = '';
|
|
145
|
+
// Post approval message with reactions
|
|
146
|
+
const message = `✅ **Plan ready for approval**\n\n` +
|
|
147
|
+
`👍 Approve and start building\n` +
|
|
148
|
+
`👎 Request changes\n\n` +
|
|
149
|
+
`*React to respond*`;
|
|
150
|
+
const post = await this.mattermost.createPost(message, this.session.threadId);
|
|
151
|
+
// Add approval reactions
|
|
152
|
+
try {
|
|
153
|
+
await this.mattermost.addReaction(post.id, '+1');
|
|
154
|
+
await this.mattermost.addReaction(post.id, '-1');
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
console.error('[Session] Failed to add approval reactions:', err);
|
|
158
|
+
}
|
|
159
|
+
// Track this for reaction handling
|
|
160
|
+
this.pendingApproval = { postId: post.id, type: 'plan' };
|
|
161
|
+
// Stop typing while waiting
|
|
162
|
+
this.stopTyping();
|
|
163
|
+
}
|
|
164
|
+
async handleTodoWrite(input) {
|
|
165
|
+
if (!this.session)
|
|
166
|
+
return;
|
|
167
|
+
const todos = input.todos;
|
|
168
|
+
if (!todos || todos.length === 0) {
|
|
169
|
+
// Clear tasks display if empty
|
|
170
|
+
if (this.tasksPostId) {
|
|
171
|
+
try {
|
|
172
|
+
await this.mattermost.updatePost(this.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
console.error('[Session] Failed to update tasks:', err);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Format tasks nicely
|
|
181
|
+
let message = '📋 **Tasks**\n\n';
|
|
182
|
+
for (const todo of todos) {
|
|
183
|
+
let icon;
|
|
184
|
+
let text;
|
|
185
|
+
switch (todo.status) {
|
|
186
|
+
case 'completed':
|
|
187
|
+
icon = '✅';
|
|
188
|
+
text = `~~${todo.content}~~`;
|
|
189
|
+
break;
|
|
190
|
+
case 'in_progress':
|
|
191
|
+
icon = '🔄';
|
|
192
|
+
text = `**${todo.activeForm}**`;
|
|
193
|
+
break;
|
|
194
|
+
default: // pending
|
|
195
|
+
icon = '⬜';
|
|
196
|
+
text = todo.content;
|
|
197
|
+
}
|
|
198
|
+
message += `${icon} ${text}\n`;
|
|
199
|
+
}
|
|
200
|
+
// Update or create tasks post
|
|
201
|
+
try {
|
|
202
|
+
if (this.tasksPostId) {
|
|
203
|
+
await this.mattermost.updatePost(this.tasksPostId, message);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
const post = await this.mattermost.createPost(message, this.session.threadId);
|
|
207
|
+
this.tasksPostId = post.id;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
console.error('[Session] Failed to update tasks:', err);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async handleTaskStart(toolUseId, input) {
|
|
215
|
+
if (!this.session)
|
|
216
|
+
return;
|
|
217
|
+
const description = input.description || 'Working...';
|
|
218
|
+
const subagentType = input.subagent_type || 'general';
|
|
219
|
+
// Post subagent status
|
|
220
|
+
const message = `🤖 **Subagent** *(${subagentType})*\n` +
|
|
221
|
+
`> ${description}\n` +
|
|
222
|
+
`⏳ Running...`;
|
|
223
|
+
try {
|
|
224
|
+
const post = await this.mattermost.createPost(message, this.session.threadId);
|
|
225
|
+
this.activeSubagents.set(toolUseId, post.id);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
console.error('[Session] Failed to post subagent status:', err);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async handleAskUserQuestion(toolUseId, input) {
|
|
232
|
+
if (!this.session)
|
|
233
|
+
return;
|
|
234
|
+
// If we already have pending questions, don't start another set
|
|
235
|
+
if (this.pendingQuestionSet) {
|
|
236
|
+
console.log('[Session] Questions already pending, waiting...');
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// Flush any pending content first
|
|
240
|
+
await this.flush();
|
|
241
|
+
this.session.postId = null;
|
|
242
|
+
this.session.content = '';
|
|
243
|
+
const questions = input.questions;
|
|
244
|
+
if (!questions || questions.length === 0)
|
|
245
|
+
return;
|
|
246
|
+
// Create a new question set - we'll ask one at a time
|
|
247
|
+
this.pendingQuestionSet = {
|
|
248
|
+
toolUseId,
|
|
249
|
+
currentIndex: 0,
|
|
250
|
+
currentPostId: null,
|
|
251
|
+
questions: questions.map(q => ({
|
|
252
|
+
header: q.header,
|
|
253
|
+
question: q.question,
|
|
254
|
+
options: q.options,
|
|
255
|
+
answer: null,
|
|
256
|
+
})),
|
|
257
|
+
};
|
|
258
|
+
// Post the first question
|
|
259
|
+
await this.postCurrentQuestion();
|
|
260
|
+
// Stop typing while waiting for answer
|
|
261
|
+
this.stopTyping();
|
|
262
|
+
}
|
|
263
|
+
async postCurrentQuestion() {
|
|
264
|
+
if (!this.session || !this.pendingQuestionSet)
|
|
265
|
+
return;
|
|
266
|
+
const { currentIndex, questions } = this.pendingQuestionSet;
|
|
267
|
+
if (currentIndex >= questions.length)
|
|
268
|
+
return;
|
|
269
|
+
const q = questions[currentIndex];
|
|
270
|
+
const total = questions.length;
|
|
271
|
+
// Format the question message - show "Question (1/3)" not the header
|
|
272
|
+
let message = `❓ **Question** *(${currentIndex + 1}/${total})*\n`;
|
|
273
|
+
message += `**${q.header}:** ${q.question}\n\n`;
|
|
274
|
+
for (let i = 0; i < q.options.length && i < 4; i++) {
|
|
275
|
+
const emoji = ['1️⃣', '2️⃣', '3️⃣', '4️⃣'][i];
|
|
276
|
+
message += `${emoji} **${q.options[i].label}**`;
|
|
277
|
+
if (q.options[i].description) {
|
|
278
|
+
message += ` - ${q.options[i].description}`;
|
|
279
|
+
}
|
|
280
|
+
message += '\n';
|
|
281
|
+
}
|
|
282
|
+
// Post the question
|
|
283
|
+
const post = await this.mattermost.createPost(message, this.session.threadId);
|
|
284
|
+
this.pendingQuestionSet.currentPostId = post.id;
|
|
285
|
+
// Add reaction emojis
|
|
286
|
+
for (let i = 0; i < q.options.length && i < 4; i++) {
|
|
287
|
+
try {
|
|
288
|
+
await this.mattermost.addReaction(post.id, REACTION_EMOJIS[i]);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
console.error(`[Session] Failed to add reaction ${REACTION_EMOJIS[i]}:`, err);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async handleReaction(postId, emojiName, username) {
|
|
296
|
+
// Check if user is allowed
|
|
297
|
+
if (!this.mattermost.isUserAllowed(username))
|
|
298
|
+
return;
|
|
299
|
+
// Handle approval reactions
|
|
300
|
+
if (this.pendingApproval && this.pendingApproval.postId === postId) {
|
|
301
|
+
await this.handleApprovalReaction(emojiName, username);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// Handle question reactions - must be for current question
|
|
305
|
+
if (!this.pendingQuestionSet || this.pendingQuestionSet.currentPostId !== postId)
|
|
306
|
+
return;
|
|
307
|
+
const { currentIndex, questions } = this.pendingQuestionSet;
|
|
308
|
+
const question = questions[currentIndex];
|
|
309
|
+
if (!question)
|
|
310
|
+
return;
|
|
311
|
+
const optionIndex = EMOJI_TO_INDEX[emojiName];
|
|
312
|
+
if (optionIndex === undefined || optionIndex >= question.options.length)
|
|
313
|
+
return;
|
|
314
|
+
const selectedOption = question.options[optionIndex];
|
|
315
|
+
question.answer = selectedOption.label;
|
|
316
|
+
console.log(`[Session] User ${username} answered "${question.header}": ${selectedOption.label}`);
|
|
317
|
+
// Update the post to show answer
|
|
318
|
+
try {
|
|
319
|
+
await this.mattermost.updatePost(postId, `✅ **${question.header}**: ${selectedOption.label}`);
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
console.error('[Session] Failed to update answered question:', err);
|
|
323
|
+
}
|
|
324
|
+
// Move to next question or finish
|
|
325
|
+
this.pendingQuestionSet.currentIndex++;
|
|
326
|
+
if (this.pendingQuestionSet.currentIndex < questions.length) {
|
|
327
|
+
// Post next question
|
|
328
|
+
await this.postCurrentQuestion();
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
// All questions answered - send as follow-up message
|
|
332
|
+
// (CLI auto-responds with error to AskUserQuestion, so tool_result won't work)
|
|
333
|
+
let answersText = 'Here are my answers:\n';
|
|
334
|
+
for (const q of questions) {
|
|
335
|
+
answersText += `- **${q.header}**: ${q.answer}\n`;
|
|
336
|
+
}
|
|
337
|
+
console.log(`[Session] All questions answered, sending as message:`, answersText);
|
|
338
|
+
// Clear and send as regular message
|
|
339
|
+
this.pendingQuestionSet = null;
|
|
340
|
+
if (this.claude?.isRunning()) {
|
|
341
|
+
this.claude.sendMessage(answersText);
|
|
342
|
+
this.startTyping();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async handleApprovalReaction(emojiName, username) {
|
|
347
|
+
if (!this.pendingApproval)
|
|
348
|
+
return;
|
|
349
|
+
const isApprove = emojiName === '+1' || emojiName === 'thumbsup';
|
|
350
|
+
const isReject = emojiName === '-1' || emojiName === 'thumbsdown';
|
|
351
|
+
if (!isApprove && !isReject)
|
|
352
|
+
return;
|
|
353
|
+
const postId = this.pendingApproval.postId;
|
|
354
|
+
console.log(`[Session] User ${username} ${isApprove ? 'approved' : 'rejected'} the plan`);
|
|
355
|
+
// Update the post to show the decision
|
|
356
|
+
try {
|
|
357
|
+
const statusMessage = isApprove
|
|
358
|
+
? `✅ **Plan approved** by @${username} - starting implementation...`
|
|
359
|
+
: `❌ **Changes requested** by @${username}`;
|
|
360
|
+
await this.mattermost.updatePost(postId, statusMessage);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
console.error('[Session] Failed to update approval post:', err);
|
|
364
|
+
}
|
|
365
|
+
// Clear pending approval and mark as approved
|
|
366
|
+
this.pendingApproval = null;
|
|
367
|
+
if (isApprove) {
|
|
368
|
+
this.planApproved = true;
|
|
369
|
+
}
|
|
370
|
+
// Send response to Claude
|
|
371
|
+
if (this.claude?.isRunning()) {
|
|
372
|
+
const response = isApprove
|
|
373
|
+
? 'Approved. Please proceed with the implementation.'
|
|
374
|
+
: 'Please revise the plan. I would like some changes.';
|
|
375
|
+
this.claude.sendMessage(response);
|
|
376
|
+
this.startTyping();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
formatEvent(e) {
|
|
380
|
+
switch (e.type) {
|
|
381
|
+
case 'assistant': {
|
|
382
|
+
const msg = e.message;
|
|
383
|
+
const parts = [];
|
|
384
|
+
for (const block of msg?.content || []) {
|
|
385
|
+
if (block.type === 'text' && block.text) {
|
|
386
|
+
parts.push(block.text);
|
|
387
|
+
}
|
|
388
|
+
else if (block.type === 'tool_use' && block.name) {
|
|
389
|
+
const formatted = this.formatToolUse(block.name, block.input || {});
|
|
390
|
+
if (formatted)
|
|
391
|
+
parts.push(formatted);
|
|
392
|
+
}
|
|
393
|
+
else if (block.type === 'thinking' && block.thinking) {
|
|
394
|
+
// Extended thinking - show abbreviated version
|
|
395
|
+
const thinking = block.thinking;
|
|
396
|
+
const preview = thinking.length > 100 ? thinking.substring(0, 100) + '...' : thinking;
|
|
397
|
+
parts.push(`💭 *Thinking: ${preview}*`);
|
|
398
|
+
}
|
|
399
|
+
else if (block.type === 'server_tool_use' && block.name) {
|
|
400
|
+
// Server-managed tools like web search
|
|
401
|
+
parts.push(`🌐 **${block.name}** ${block.input ? JSON.stringify(block.input).substring(0, 50) : ''}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
405
|
+
}
|
|
406
|
+
case 'tool_use': {
|
|
407
|
+
const tool = e.tool_use;
|
|
408
|
+
return this.formatToolUse(tool.name, tool.input || {}) || null;
|
|
409
|
+
}
|
|
410
|
+
case 'tool_result': {
|
|
411
|
+
const result = e.tool_result;
|
|
412
|
+
if (result.is_error)
|
|
413
|
+
return ` ↳ ❌ Error`;
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
case 'result': {
|
|
417
|
+
// Response complete - stop typing and start new post for next message
|
|
418
|
+
this.stopTyping();
|
|
419
|
+
if (this.session) {
|
|
420
|
+
this.flush();
|
|
421
|
+
this.session.postId = null;
|
|
422
|
+
this.session.content = '';
|
|
423
|
+
}
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
case 'system':
|
|
427
|
+
if (e.subtype === 'error')
|
|
428
|
+
return `❌ ${e.error}`;
|
|
429
|
+
return null;
|
|
430
|
+
default:
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
formatToolUse(name, input) {
|
|
435
|
+
const short = (p) => {
|
|
436
|
+
const home = process.env.HOME || '';
|
|
437
|
+
return p?.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
438
|
+
};
|
|
439
|
+
switch (name) {
|
|
440
|
+
case 'Read': return `📄 **Read** \`${short(input.file_path)}\``;
|
|
441
|
+
case 'Edit': {
|
|
442
|
+
const filePath = short(input.file_path);
|
|
443
|
+
const oldStr = (input.old_string || '').trim();
|
|
444
|
+
const newStr = (input.new_string || '').trim();
|
|
445
|
+
// Show diff if we have old/new strings
|
|
446
|
+
if (oldStr || newStr) {
|
|
447
|
+
const maxLines = 8;
|
|
448
|
+
const oldLines = oldStr.split('\n').slice(0, maxLines);
|
|
449
|
+
const newLines = newStr.split('\n').slice(0, maxLines);
|
|
450
|
+
let diff = `✏️ **Edit** \`${filePath}\`\n\`\`\`diff\n`;
|
|
451
|
+
for (const line of oldLines) {
|
|
452
|
+
diff += `- ${line}\n`;
|
|
453
|
+
}
|
|
454
|
+
if (oldStr.split('\n').length > maxLines)
|
|
455
|
+
diff += `- ... (${oldStr.split('\n').length - maxLines} more lines)\n`;
|
|
456
|
+
for (const line of newLines) {
|
|
457
|
+
diff += `+ ${line}\n`;
|
|
458
|
+
}
|
|
459
|
+
if (newStr.split('\n').length > maxLines)
|
|
460
|
+
diff += `+ ... (${newStr.split('\n').length - maxLines} more lines)\n`;
|
|
461
|
+
diff += '```';
|
|
462
|
+
return diff;
|
|
463
|
+
}
|
|
464
|
+
return `✏️ **Edit** \`${filePath}\``;
|
|
465
|
+
}
|
|
466
|
+
case 'Write': {
|
|
467
|
+
const filePath = short(input.file_path);
|
|
468
|
+
const content = input.content || '';
|
|
469
|
+
const lines = content.split('\n');
|
|
470
|
+
const lineCount = lines.length;
|
|
471
|
+
// Show preview of content
|
|
472
|
+
if (content && lineCount > 0) {
|
|
473
|
+
const maxLines = 6;
|
|
474
|
+
const previewLines = lines.slice(0, maxLines);
|
|
475
|
+
let preview = `📝 **Write** \`${filePath}\` *(${lineCount} lines)*\n\`\`\`\n`;
|
|
476
|
+
preview += previewLines.join('\n');
|
|
477
|
+
if (lineCount > maxLines)
|
|
478
|
+
preview += `\n... (${lineCount - maxLines} more lines)`;
|
|
479
|
+
preview += '\n```';
|
|
480
|
+
return preview;
|
|
481
|
+
}
|
|
482
|
+
return `📝 **Write** \`${filePath}\``;
|
|
483
|
+
}
|
|
484
|
+
case 'Bash': {
|
|
485
|
+
const cmd = (input.command || '').substring(0, 50);
|
|
486
|
+
return `💻 **Bash** \`${cmd}${cmd.length >= 50 ? '...' : ''}\``;
|
|
487
|
+
}
|
|
488
|
+
case 'Glob': return `🔍 **Glob** \`${input.pattern}\``;
|
|
489
|
+
case 'Grep': return `🔎 **Grep** \`${input.pattern}\``;
|
|
490
|
+
case 'Task': return null; // Handled specially with subagent display
|
|
491
|
+
case 'EnterPlanMode': return `📋 **Planning...**`;
|
|
492
|
+
case 'ExitPlanMode': return null; // Handled specially with approval buttons
|
|
493
|
+
case 'AskUserQuestion': return null; // Don't show, the question text follows
|
|
494
|
+
case 'TodoWrite': return null; // Handled specially with task list display
|
|
495
|
+
case 'WebFetch': return `🌐 **Fetching** \`${(input.url || '').substring(0, 40)}\``;
|
|
496
|
+
case 'WebSearch': return `🔍 **Searching** \`${input.query}\``;
|
|
497
|
+
default: {
|
|
498
|
+
// Handle MCP tools: mcp__server__tool -> 🔌 tool (server)
|
|
499
|
+
if (name.startsWith('mcp__')) {
|
|
500
|
+
const parts = name.split('__');
|
|
501
|
+
if (parts.length >= 3) {
|
|
502
|
+
const server = parts[1];
|
|
503
|
+
const tool = parts.slice(2).join('__');
|
|
504
|
+
return `🔌 **${tool}** *(${server})*`;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return `● **${name}**`;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
appendContent(text) {
|
|
512
|
+
if (!this.session || !text)
|
|
513
|
+
return;
|
|
514
|
+
this.session.content += text + '\n';
|
|
515
|
+
this.scheduleUpdate();
|
|
516
|
+
}
|
|
517
|
+
scheduleUpdate() {
|
|
518
|
+
if (this.updateTimer)
|
|
519
|
+
return;
|
|
520
|
+
this.updateTimer = setTimeout(() => {
|
|
521
|
+
this.updateTimer = null;
|
|
522
|
+
this.flush();
|
|
523
|
+
}, 500);
|
|
524
|
+
}
|
|
525
|
+
startTyping() {
|
|
526
|
+
if (this.typingTimer)
|
|
527
|
+
return;
|
|
528
|
+
// Send typing immediately, then every 3 seconds
|
|
529
|
+
this.mattermost.sendTyping(this.session?.threadId);
|
|
530
|
+
this.typingTimer = setInterval(() => {
|
|
531
|
+
this.mattermost.sendTyping(this.session?.threadId);
|
|
532
|
+
}, 3000);
|
|
533
|
+
}
|
|
534
|
+
stopTyping() {
|
|
535
|
+
if (this.typingTimer) {
|
|
536
|
+
clearInterval(this.typingTimer);
|
|
537
|
+
this.typingTimer = null;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
async flush() {
|
|
541
|
+
if (!this.session || !this.session.content.trim())
|
|
542
|
+
return;
|
|
543
|
+
const content = this.session.content.replace(/\n{3,}/g, '\n\n').trim();
|
|
544
|
+
if (this.session.postId) {
|
|
545
|
+
await this.mattermost.updatePost(this.session.postId, content);
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
const post = await this.mattermost.createPost(content, this.session.threadId);
|
|
549
|
+
this.session.postId = post.id;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async handleExit(code) {
|
|
553
|
+
this.stopTyping();
|
|
554
|
+
if (this.updateTimer) {
|
|
555
|
+
clearTimeout(this.updateTimer);
|
|
556
|
+
this.updateTimer = null;
|
|
557
|
+
}
|
|
558
|
+
await this.flush();
|
|
559
|
+
if (code !== 0 && this.session) {
|
|
560
|
+
await this.mattermost.createPost(`**[Exited: ${code}]**`, this.session.threadId);
|
|
561
|
+
}
|
|
562
|
+
this.session = null;
|
|
563
|
+
}
|
|
564
|
+
isSessionActive() {
|
|
565
|
+
return this.session !== null;
|
|
566
|
+
}
|
|
567
|
+
isInCurrentSessionThread(threadRoot) {
|
|
568
|
+
return this.session?.threadId === threadRoot;
|
|
569
|
+
}
|
|
570
|
+
async sendFollowUp(message) {
|
|
571
|
+
if (!this.claude?.isRunning() || !this.session)
|
|
572
|
+
return;
|
|
573
|
+
this.claude.sendMessage(message);
|
|
574
|
+
this.startTyping();
|
|
575
|
+
}
|
|
576
|
+
killSession() {
|
|
577
|
+
this.stopTyping();
|
|
578
|
+
this.claude?.kill();
|
|
579
|
+
this.claude = null;
|
|
580
|
+
this.session = null;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
}
|