claude-threads 0.12.1 → 0.14.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/CHANGELOG.md +53 -0
- package/README.md +142 -37
- package/dist/claude/cli.d.ts +8 -0
- package/dist/claude/cli.js +16 -8
- package/dist/config/migration.d.ts +45 -0
- package/dist/config/migration.js +35 -0
- package/dist/config.d.ts +12 -18
- package/dist/config.js +7 -94
- package/dist/git/worktree.d.ts +0 -4
- package/dist/git/worktree.js +1 -1
- package/dist/index.js +39 -15
- package/dist/logo.d.ts +3 -20
- package/dist/logo.js +7 -23
- package/dist/mcp/permission-server.js +61 -112
- package/dist/onboarding.d.ts +1 -1
- package/dist/onboarding.js +271 -69
- package/dist/persistence/session-store.d.ts +8 -2
- package/dist/persistence/session-store.js +41 -16
- package/dist/platform/client.d.ts +140 -0
- package/dist/platform/formatter.d.ts +74 -0
- package/dist/platform/index.d.ts +11 -0
- package/dist/platform/index.js +8 -0
- package/dist/platform/mattermost/client.d.ts +70 -0
- package/dist/{mattermost → platform/mattermost}/client.js +117 -34
- package/dist/platform/mattermost/formatter.d.ts +20 -0
- package/dist/platform/mattermost/formatter.js +46 -0
- package/dist/platform/mattermost/permission-api.d.ts +10 -0
- package/dist/platform/mattermost/permission-api.js +139 -0
- package/dist/platform/mattermost/types.js +1 -0
- package/dist/platform/permission-api-factory.d.ts +11 -0
- package/dist/platform/permission-api-factory.js +21 -0
- package/dist/platform/permission-api.d.ts +67 -0
- package/dist/platform/permission-api.js +8 -0
- package/dist/platform/types.d.ts +70 -0
- package/dist/platform/types.js +7 -0
- package/dist/session/commands.d.ts +52 -0
- package/dist/session/commands.js +323 -0
- package/dist/session/events.d.ts +25 -0
- package/dist/session/events.js +368 -0
- package/dist/session/index.d.ts +7 -0
- package/dist/session/index.js +6 -0
- package/dist/session/lifecycle.d.ts +70 -0
- package/dist/session/lifecycle.js +456 -0
- package/dist/session/manager.d.ts +96 -0
- package/dist/session/manager.js +537 -0
- package/dist/session/reactions.d.ts +25 -0
- package/dist/session/reactions.js +151 -0
- package/dist/session/streaming.d.ts +47 -0
- package/dist/session/streaming.js +152 -0
- package/dist/session/types.d.ts +78 -0
- package/dist/session/types.js +9 -0
- package/dist/session/worktree.d.ts +56 -0
- package/dist/session/worktree.js +339 -0
- package/dist/{mattermost → utils}/emoji.d.ts +3 -3
- package/dist/{mattermost → utils}/emoji.js +3 -3
- package/dist/utils/emoji.test.d.ts +1 -0
- package/dist/utils/tool-formatter.d.ts +10 -13
- package/dist/utils/tool-formatter.js +48 -43
- package/dist/utils/tool-formatter.test.js +67 -52
- package/package.json +2 -3
- package/dist/claude/session.d.ts +0 -256
- package/dist/claude/session.js +0 -1964
- package/dist/mattermost/client.d.ts +0 -56
- /package/dist/{mattermost/emoji.test.d.ts → platform/client.js} +0 -0
- /package/dist/{mattermost/types.js → platform/formatter.js} +0 -0
- /package/dist/{mattermost → platform/mattermost}/types.d.ts +0 -0
- /package/dist/{mattermost → utils}/emoji.test.js +0 -0
package/dist/claude/session.js
DELETED
|
@@ -1,1964 +0,0 @@
|
|
|
1
|
-
import { ClaudeCli } from './cli.js';
|
|
2
|
-
import { isApprovalEmoji, isDenialEmoji, isAllowAllEmoji, isCancelEmoji, isEscapeEmoji, getNumberEmojiIndex, NUMBER_EMOJIS, APPROVAL_EMOJIS, DENIAL_EMOJIS, ALLOW_ALL_EMOJIS, } from '../mattermost/emoji.js';
|
|
3
|
-
import { formatToolUse as sharedFormatToolUse } from '../utils/tool-formatter.js';
|
|
4
|
-
import { getUpdateInfo } from '../update-notifier.js';
|
|
5
|
-
import { getReleaseNotes, getWhatsNewSummary } from '../changelog.js';
|
|
6
|
-
import { SessionStore } from '../persistence/session-store.js';
|
|
7
|
-
import { getMattermostLogo } from '../logo.js';
|
|
8
|
-
import { isGitRepository, getRepositoryRoot, hasUncommittedChanges, listWorktrees, createWorktree, removeWorktree as removeGitWorktree, getWorktreeDir, findWorktreeByBranch, isValidBranchName, } from '../git/worktree.js';
|
|
9
|
-
import { randomUUID } from 'crypto';
|
|
10
|
-
import { readFileSync } from 'fs';
|
|
11
|
-
import { dirname, resolve } from 'path';
|
|
12
|
-
import { fileURLToPath } from 'url';
|
|
13
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
|
-
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
15
|
-
// =============================================================================
|
|
16
|
-
// Configuration
|
|
17
|
-
// =============================================================================
|
|
18
|
-
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || '5', 10);
|
|
19
|
-
const SESSION_TIMEOUT_MS = parseInt(process.env.SESSION_TIMEOUT_MS || '1800000', 10); // 30 min
|
|
20
|
-
const SESSION_WARNING_MS = 5 * 60 * 1000; // Warn 5 minutes before timeout
|
|
21
|
-
// =============================================================================
|
|
22
|
-
// SessionManager - Manages multiple concurrent Claude Code sessions
|
|
23
|
-
// =============================================================================
|
|
24
|
-
export class SessionManager {
|
|
25
|
-
// Shared state
|
|
26
|
-
mattermost;
|
|
27
|
-
workingDir;
|
|
28
|
-
skipPermissions;
|
|
29
|
-
chromeEnabled;
|
|
30
|
-
worktreeMode;
|
|
31
|
-
debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
|
|
32
|
-
// Multi-session storage
|
|
33
|
-
sessions = new Map(); // threadId -> Session
|
|
34
|
-
postIndex = new Map(); // postId -> threadId (for reaction routing)
|
|
35
|
-
// Persistence
|
|
36
|
-
sessionStore = new SessionStore();
|
|
37
|
-
// Cleanup timer
|
|
38
|
-
cleanupTimer = null;
|
|
39
|
-
// Shutdown flag to suppress exit messages during graceful shutdown
|
|
40
|
-
isShuttingDown = false;
|
|
41
|
-
constructor(mattermost, workingDir, skipPermissions = false, chromeEnabled = false, worktreeMode = 'prompt') {
|
|
42
|
-
this.mattermost = mattermost;
|
|
43
|
-
this.workingDir = workingDir;
|
|
44
|
-
this.skipPermissions = skipPermissions;
|
|
45
|
-
this.chromeEnabled = chromeEnabled;
|
|
46
|
-
this.worktreeMode = worktreeMode;
|
|
47
|
-
// Listen for reactions to answer questions
|
|
48
|
-
this.mattermost.on('reaction', async (reaction, user) => {
|
|
49
|
-
try {
|
|
50
|
-
await this.handleReaction(reaction.post_id, reaction.emoji_name, user?.username || 'unknown');
|
|
51
|
-
}
|
|
52
|
-
catch (err) {
|
|
53
|
-
console.error(' ❌ Error handling reaction:', err);
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
// Start periodic cleanup of idle sessions
|
|
57
|
-
this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), 60000);
|
|
58
|
-
}
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
// Session Initialization (Resume)
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
/**
|
|
63
|
-
* Initialize session manager by resuming any persisted sessions.
|
|
64
|
-
* Should be called before starting to listen for new messages.
|
|
65
|
-
*/
|
|
66
|
-
async initialize() {
|
|
67
|
-
// Load persisted sessions FIRST (before cleaning stale ones)
|
|
68
|
-
// This way we can resume sessions that were active when the bot stopped,
|
|
69
|
-
// even if the bot was down for longer than SESSION_TIMEOUT_MS
|
|
70
|
-
const persisted = this.sessionStore.load();
|
|
71
|
-
if (this.debug) {
|
|
72
|
-
console.log(` [persist] Found ${persisted.size} persisted session(s)`);
|
|
73
|
-
for (const [threadId, state] of persisted) {
|
|
74
|
-
const age = Date.now() - new Date(state.lastActivityAt).getTime();
|
|
75
|
-
const ageMins = Math.round(age / 60000);
|
|
76
|
-
console.log(` [persist] - ${threadId.substring(0, 8)}... by @${state.startedBy}, age: ${ageMins}m`);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
// Note: We intentionally do NOT clean stale sessions on startup anymore.
|
|
80
|
-
// Sessions are cleaned during normal operation by cleanupIdleSessions().
|
|
81
|
-
// This allows sessions to survive bot restarts even if the bot was down
|
|
82
|
-
// for longer than SESSION_TIMEOUT_MS.
|
|
83
|
-
if (persisted.size === 0) {
|
|
84
|
-
if (this.debug)
|
|
85
|
-
console.log(' [resume] No sessions to resume');
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
console.log(` 📂 Found ${persisted.size} session(s) to resume...`);
|
|
89
|
-
// Resume each session
|
|
90
|
-
for (const [_threadId, state] of persisted) {
|
|
91
|
-
await this.resumeSession(state);
|
|
92
|
-
}
|
|
93
|
-
console.log(` ✅ Resumed ${this.sessions.size} session(s)`);
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Resume a single session from persisted state
|
|
97
|
-
*/
|
|
98
|
-
async resumeSession(state) {
|
|
99
|
-
const shortId = state.threadId.substring(0, 8);
|
|
100
|
-
// Verify thread still exists
|
|
101
|
-
const post = await this.mattermost.getPost(state.threadId);
|
|
102
|
-
if (!post) {
|
|
103
|
-
console.log(` ⚠️ Thread ${shortId}... deleted, skipping resume`);
|
|
104
|
-
this.sessionStore.remove(state.threadId);
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
// Check max sessions limit
|
|
108
|
-
if (this.sessions.size >= MAX_SESSIONS) {
|
|
109
|
-
console.log(` ⚠️ Max sessions reached, skipping resume for ${shortId}...`);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
// Create Claude CLI with resume flag
|
|
113
|
-
const skipPerms = this.skipPermissions && !state.forceInteractivePermissions;
|
|
114
|
-
const cliOptions = {
|
|
115
|
-
workingDir: state.workingDir,
|
|
116
|
-
threadId: state.threadId,
|
|
117
|
-
skipPermissions: skipPerms,
|
|
118
|
-
sessionId: state.claudeSessionId,
|
|
119
|
-
resume: true,
|
|
120
|
-
chrome: this.chromeEnabled,
|
|
121
|
-
};
|
|
122
|
-
const claude = new ClaudeCli(cliOptions);
|
|
123
|
-
// Rebuild Session object from persisted state
|
|
124
|
-
const session = {
|
|
125
|
-
threadId: state.threadId,
|
|
126
|
-
claudeSessionId: state.claudeSessionId,
|
|
127
|
-
startedBy: state.startedBy,
|
|
128
|
-
startedAt: new Date(state.startedAt),
|
|
129
|
-
lastActivityAt: new Date(),
|
|
130
|
-
sessionNumber: state.sessionNumber,
|
|
131
|
-
workingDir: state.workingDir,
|
|
132
|
-
claude,
|
|
133
|
-
currentPostId: null,
|
|
134
|
-
pendingContent: '',
|
|
135
|
-
pendingApproval: null,
|
|
136
|
-
pendingQuestionSet: null,
|
|
137
|
-
pendingMessageApproval: null,
|
|
138
|
-
planApproved: state.planApproved,
|
|
139
|
-
sessionAllowedUsers: new Set(state.sessionAllowedUsers),
|
|
140
|
-
forceInteractivePermissions: state.forceInteractivePermissions,
|
|
141
|
-
sessionStartPostId: state.sessionStartPostId,
|
|
142
|
-
tasksPostId: state.tasksPostId,
|
|
143
|
-
activeSubagents: new Map(),
|
|
144
|
-
updateTimer: null,
|
|
145
|
-
typingTimer: null,
|
|
146
|
-
timeoutWarningPosted: false,
|
|
147
|
-
isRestarting: false,
|
|
148
|
-
isResumed: true,
|
|
149
|
-
wasInterrupted: false,
|
|
150
|
-
inProgressTaskStart: null,
|
|
151
|
-
activeToolStarts: new Map(),
|
|
152
|
-
// Worktree state from persistence
|
|
153
|
-
worktreeInfo: state.worktreeInfo,
|
|
154
|
-
pendingWorktreePrompt: state.pendingWorktreePrompt,
|
|
155
|
-
worktreePromptDisabled: state.worktreePromptDisabled,
|
|
156
|
-
queuedPrompt: state.queuedPrompt,
|
|
157
|
-
};
|
|
158
|
-
// Register session
|
|
159
|
-
this.sessions.set(state.threadId, session);
|
|
160
|
-
if (state.sessionStartPostId) {
|
|
161
|
-
this.registerPost(state.sessionStartPostId, state.threadId);
|
|
162
|
-
}
|
|
163
|
-
// Bind event handlers
|
|
164
|
-
claude.on('event', (e) => this.handleEvent(state.threadId, e));
|
|
165
|
-
claude.on('exit', (code) => this.handleExit(state.threadId, code));
|
|
166
|
-
try {
|
|
167
|
-
claude.start();
|
|
168
|
-
console.log(` 🔄 Resumed session ${shortId}... (@${state.startedBy})`);
|
|
169
|
-
// Post resume message
|
|
170
|
-
await this.mattermost.createPost(`🔄 **Session resumed** after bot restart (v${pkg.version})\n*Reconnected to Claude session. You can continue where you left off.*`, state.threadId);
|
|
171
|
-
// Update session header
|
|
172
|
-
await this.updateSessionHeader(session);
|
|
173
|
-
// Update persistence with new activity time
|
|
174
|
-
this.persistSession(session);
|
|
175
|
-
}
|
|
176
|
-
catch (err) {
|
|
177
|
-
console.error(` ❌ Failed to resume session ${shortId}...:`, err);
|
|
178
|
-
this.sessions.delete(state.threadId);
|
|
179
|
-
this.sessionStore.remove(state.threadId);
|
|
180
|
-
// Try to notify user
|
|
181
|
-
try {
|
|
182
|
-
await this.mattermost.createPost(`⚠️ **Could not resume previous session.** Starting fresh.\n*Your previous conversation context is preserved, but Claude needs to re-read it.*`, state.threadId);
|
|
183
|
-
}
|
|
184
|
-
catch {
|
|
185
|
-
// Ignore if we can't post
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Persist a session to disk
|
|
191
|
-
*/
|
|
192
|
-
persistSession(session) {
|
|
193
|
-
const shortId = session.threadId.substring(0, 8);
|
|
194
|
-
console.log(` [persist] Saving session ${shortId}...`);
|
|
195
|
-
const state = {
|
|
196
|
-
threadId: session.threadId,
|
|
197
|
-
claudeSessionId: session.claudeSessionId,
|
|
198
|
-
startedBy: session.startedBy,
|
|
199
|
-
startedAt: session.startedAt.toISOString(),
|
|
200
|
-
sessionNumber: session.sessionNumber,
|
|
201
|
-
workingDir: session.workingDir,
|
|
202
|
-
sessionAllowedUsers: [...session.sessionAllowedUsers],
|
|
203
|
-
forceInteractivePermissions: session.forceInteractivePermissions,
|
|
204
|
-
sessionStartPostId: session.sessionStartPostId,
|
|
205
|
-
tasksPostId: session.tasksPostId,
|
|
206
|
-
lastActivityAt: session.lastActivityAt.toISOString(),
|
|
207
|
-
planApproved: session.planApproved,
|
|
208
|
-
// Worktree state
|
|
209
|
-
worktreeInfo: session.worktreeInfo,
|
|
210
|
-
pendingWorktreePrompt: session.pendingWorktreePrompt,
|
|
211
|
-
worktreePromptDisabled: session.worktreePromptDisabled,
|
|
212
|
-
queuedPrompt: session.queuedPrompt,
|
|
213
|
-
};
|
|
214
|
-
this.sessionStore.save(session.threadId, state);
|
|
215
|
-
console.log(` [persist] Saved session ${shortId}... (claudeId: ${session.claudeSessionId.substring(0, 8)}...)`);
|
|
216
|
-
}
|
|
217
|
-
/**
|
|
218
|
-
* Remove a session from persistence
|
|
219
|
-
*/
|
|
220
|
-
unpersistSession(threadId) {
|
|
221
|
-
const shortId = threadId.substring(0, 8);
|
|
222
|
-
console.log(` [persist] REMOVING session ${shortId}... (this should NOT happen during shutdown!)`);
|
|
223
|
-
this.sessionStore.remove(threadId);
|
|
224
|
-
}
|
|
225
|
-
// ---------------------------------------------------------------------------
|
|
226
|
-
// Session Lookup Methods
|
|
227
|
-
// ---------------------------------------------------------------------------
|
|
228
|
-
/** Get a session by thread ID */
|
|
229
|
-
getSession(threadId) {
|
|
230
|
-
return this.sessions.get(threadId);
|
|
231
|
-
}
|
|
232
|
-
/** Check if a session exists for this thread */
|
|
233
|
-
hasSession(threadId) {
|
|
234
|
-
return this.sessions.has(threadId);
|
|
235
|
-
}
|
|
236
|
-
/** Get the number of active sessions */
|
|
237
|
-
getSessionCount() {
|
|
238
|
-
return this.sessions.size;
|
|
239
|
-
}
|
|
240
|
-
/** Get all active session thread IDs */
|
|
241
|
-
getActiveThreadIds() {
|
|
242
|
-
return [...this.sessions.keys()];
|
|
243
|
-
}
|
|
244
|
-
/** Mark that we're shutting down (prevents cleanup of persisted sessions) */
|
|
245
|
-
setShuttingDown() {
|
|
246
|
-
console.log(' [shutdown] Setting isShuttingDown = true');
|
|
247
|
-
this.isShuttingDown = true;
|
|
248
|
-
}
|
|
249
|
-
/** Register a post for reaction routing */
|
|
250
|
-
registerPost(postId, threadId) {
|
|
251
|
-
this.postIndex.set(postId, threadId);
|
|
252
|
-
}
|
|
253
|
-
/** Find session by post ID (for reaction routing) */
|
|
254
|
-
getSessionByPost(postId) {
|
|
255
|
-
const threadId = this.postIndex.get(postId);
|
|
256
|
-
return threadId ? this.sessions.get(threadId) : undefined;
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Check if a user is allowed in a specific session.
|
|
260
|
-
* Checks global allowlist first, then session-specific allowlist.
|
|
261
|
-
*/
|
|
262
|
-
isUserAllowedInSession(threadId, username) {
|
|
263
|
-
// Check global allowlist first
|
|
264
|
-
if (this.mattermost.isUserAllowed(username))
|
|
265
|
-
return true;
|
|
266
|
-
// Check session-specific allowlist
|
|
267
|
-
const session = this.sessions.get(threadId);
|
|
268
|
-
if (session?.sessionAllowedUsers.has(username))
|
|
269
|
-
return true;
|
|
270
|
-
return false;
|
|
271
|
-
}
|
|
272
|
-
// ---------------------------------------------------------------------------
|
|
273
|
-
// Session Lifecycle
|
|
274
|
-
// ---------------------------------------------------------------------------
|
|
275
|
-
async startSession(options, username, replyToPostId) {
|
|
276
|
-
const threadId = replyToPostId || '';
|
|
277
|
-
// Check if session already exists for this thread
|
|
278
|
-
const existingSession = this.sessions.get(threadId);
|
|
279
|
-
if (existingSession && existingSession.claude.isRunning()) {
|
|
280
|
-
// Send as follow-up instead
|
|
281
|
-
await this.sendFollowUp(threadId, options.prompt, options.files);
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
// Check max sessions limit
|
|
285
|
-
if (this.sessions.size >= MAX_SESSIONS) {
|
|
286
|
-
await this.mattermost.createPost(`⚠️ **Too busy** - ${this.sessions.size} sessions active. Please try again later.`, replyToPostId);
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
// Post initial session message (will be updated by updateSessionHeader)
|
|
290
|
-
let post;
|
|
291
|
-
try {
|
|
292
|
-
post = await this.mattermost.createPost(`${getMattermostLogo(pkg.version)}\n\n*Starting session...*`, replyToPostId);
|
|
293
|
-
}
|
|
294
|
-
catch (err) {
|
|
295
|
-
console.error(` ❌ Failed to create session post:`, err);
|
|
296
|
-
// If we can't post to the thread, we can't start a session
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
const actualThreadId = replyToPostId || post.id;
|
|
300
|
-
// Generate a unique session ID for this Claude session
|
|
301
|
-
const claudeSessionId = randomUUID();
|
|
302
|
-
// Create Claude CLI with options
|
|
303
|
-
const cliOptions = {
|
|
304
|
-
workingDir: this.workingDir,
|
|
305
|
-
threadId: actualThreadId,
|
|
306
|
-
skipPermissions: this.skipPermissions,
|
|
307
|
-
sessionId: claudeSessionId,
|
|
308
|
-
resume: false,
|
|
309
|
-
chrome: this.chromeEnabled,
|
|
310
|
-
};
|
|
311
|
-
const claude = new ClaudeCli(cliOptions);
|
|
312
|
-
// Create the session object
|
|
313
|
-
const session = {
|
|
314
|
-
threadId: actualThreadId,
|
|
315
|
-
claudeSessionId,
|
|
316
|
-
startedBy: username,
|
|
317
|
-
startedAt: new Date(),
|
|
318
|
-
lastActivityAt: new Date(),
|
|
319
|
-
sessionNumber: this.sessions.size + 1,
|
|
320
|
-
workingDir: this.workingDir,
|
|
321
|
-
claude,
|
|
322
|
-
currentPostId: null,
|
|
323
|
-
pendingContent: '',
|
|
324
|
-
pendingApproval: null,
|
|
325
|
-
pendingQuestionSet: null,
|
|
326
|
-
pendingMessageApproval: null,
|
|
327
|
-
planApproved: false,
|
|
328
|
-
sessionAllowedUsers: new Set([username]), // Owner is always allowed
|
|
329
|
-
forceInteractivePermissions: false, // Can be enabled via /permissions interactive
|
|
330
|
-
sessionStartPostId: post.id, // Track for updating participants
|
|
331
|
-
tasksPostId: null,
|
|
332
|
-
activeSubagents: new Map(),
|
|
333
|
-
updateTimer: null,
|
|
334
|
-
typingTimer: null,
|
|
335
|
-
timeoutWarningPosted: false,
|
|
336
|
-
isRestarting: false,
|
|
337
|
-
isResumed: false,
|
|
338
|
-
wasInterrupted: false,
|
|
339
|
-
inProgressTaskStart: null,
|
|
340
|
-
activeToolStarts: new Map(),
|
|
341
|
-
};
|
|
342
|
-
// Register session
|
|
343
|
-
this.sessions.set(actualThreadId, session);
|
|
344
|
-
this.registerPost(post.id, actualThreadId); // For cancel reactions on session start post
|
|
345
|
-
const shortId = actualThreadId.substring(0, 8);
|
|
346
|
-
console.log(` ▶ Session #${this.sessions.size} started (${shortId}…) by @${username}`);
|
|
347
|
-
// Update the header with full session info
|
|
348
|
-
await this.updateSessionHeader(session);
|
|
349
|
-
// Start typing indicator immediately so user sees activity
|
|
350
|
-
this.startTyping(session);
|
|
351
|
-
// Bind event handlers with closure over threadId
|
|
352
|
-
claude.on('event', (e) => this.handleEvent(actualThreadId, e));
|
|
353
|
-
claude.on('exit', (code) => this.handleExit(actualThreadId, code));
|
|
354
|
-
try {
|
|
355
|
-
claude.start();
|
|
356
|
-
}
|
|
357
|
-
catch (err) {
|
|
358
|
-
console.error(' ❌ Failed to start Claude:', err);
|
|
359
|
-
this.stopTyping(session);
|
|
360
|
-
await this.mattermost.createPost(`❌ ${err}`, actualThreadId);
|
|
361
|
-
this.sessions.delete(actualThreadId);
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
// Check if we should prompt for worktree
|
|
365
|
-
const shouldPrompt = await this.shouldPromptForWorktree(session);
|
|
366
|
-
if (shouldPrompt) {
|
|
367
|
-
// Queue the original message and prompt for branch name
|
|
368
|
-
session.queuedPrompt = options.prompt;
|
|
369
|
-
session.pendingWorktreePrompt = true;
|
|
370
|
-
await this.postWorktreePrompt(session, shouldPrompt);
|
|
371
|
-
// Persist session with pending state
|
|
372
|
-
this.persistSession(session);
|
|
373
|
-
return; // Don't send message to Claude yet
|
|
374
|
-
}
|
|
375
|
-
// Send the message to Claude (with images if present)
|
|
376
|
-
const content = await this.buildMessageContent(options.prompt, options.files);
|
|
377
|
-
claude.sendMessage(content);
|
|
378
|
-
// Persist session for resume after restart
|
|
379
|
-
this.persistSession(session);
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* Start a session with an initial worktree specified.
|
|
383
|
-
* Used when user specifies "on branch X" or "!worktree X" in their initial message.
|
|
384
|
-
*/
|
|
385
|
-
async startSessionWithWorktree(options, branch, username, replyToPostId) {
|
|
386
|
-
// Start the session normally first
|
|
387
|
-
await this.startSession(options, username, replyToPostId);
|
|
388
|
-
// Get the thread ID
|
|
389
|
-
const threadId = replyToPostId || '';
|
|
390
|
-
const session = this.sessions.get(threadId);
|
|
391
|
-
if (!session)
|
|
392
|
-
return;
|
|
393
|
-
// If session has a pending worktree prompt (from startSession), skip it
|
|
394
|
-
if (session.pendingWorktreePrompt) {
|
|
395
|
-
session.pendingWorktreePrompt = false;
|
|
396
|
-
if (session.worktreePromptPostId) {
|
|
397
|
-
try {
|
|
398
|
-
await this.mattermost.updatePost(session.worktreePromptPostId, `✅ Using branch \`${branch}\` (specified in message)`);
|
|
399
|
-
}
|
|
400
|
-
catch (err) {
|
|
401
|
-
console.error(' ⚠️ Failed to update worktree prompt:', err);
|
|
402
|
-
}
|
|
403
|
-
session.worktreePromptPostId = undefined;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
// Create the worktree
|
|
407
|
-
await this.createAndSwitchToWorktree(threadId, branch, username);
|
|
408
|
-
}
|
|
409
|
-
/**
|
|
410
|
-
* Check if we should prompt for a worktree before starting work.
|
|
411
|
-
* Returns the reason string if we should prompt, or null if not.
|
|
412
|
-
*/
|
|
413
|
-
async shouldPromptForWorktree(session) {
|
|
414
|
-
// Skip if worktree mode is off
|
|
415
|
-
if (this.worktreeMode === 'off')
|
|
416
|
-
return null;
|
|
417
|
-
// Skip if user disabled prompts for this session
|
|
418
|
-
if (session.worktreePromptDisabled)
|
|
419
|
-
return null;
|
|
420
|
-
// Skip if already in a worktree
|
|
421
|
-
if (session.worktreeInfo)
|
|
422
|
-
return null;
|
|
423
|
-
// Check if we're in a git repository
|
|
424
|
-
const isRepo = await isGitRepository(session.workingDir);
|
|
425
|
-
if (!isRepo)
|
|
426
|
-
return null;
|
|
427
|
-
// For 'require' mode, always prompt
|
|
428
|
-
if (this.worktreeMode === 'require') {
|
|
429
|
-
return 'require';
|
|
430
|
-
}
|
|
431
|
-
// For 'prompt' mode, check conditions
|
|
432
|
-
// Condition 1: uncommitted changes
|
|
433
|
-
const hasChanges = await hasUncommittedChanges(session.workingDir);
|
|
434
|
-
if (hasChanges)
|
|
435
|
-
return 'uncommitted';
|
|
436
|
-
// Condition 2: another session using the same repo
|
|
437
|
-
const repoRoot = await getRepositoryRoot(session.workingDir);
|
|
438
|
-
const hasConcurrent = this.hasOtherSessionInRepo(repoRoot, session.threadId);
|
|
439
|
-
if (hasConcurrent)
|
|
440
|
-
return 'concurrent';
|
|
441
|
-
return null;
|
|
442
|
-
}
|
|
443
|
-
/**
|
|
444
|
-
* Check if another session is using the same repository
|
|
445
|
-
*/
|
|
446
|
-
hasOtherSessionInRepo(repoRoot, excludeThreadId) {
|
|
447
|
-
for (const [threadId, session] of this.sessions) {
|
|
448
|
-
if (threadId === excludeThreadId)
|
|
449
|
-
continue;
|
|
450
|
-
// Check if session's working directory is in the same repo
|
|
451
|
-
// (either the repo root or a worktree of the same repo)
|
|
452
|
-
if (session.workingDir === repoRoot)
|
|
453
|
-
return true;
|
|
454
|
-
if (session.worktreeInfo?.repoRoot === repoRoot)
|
|
455
|
-
return true;
|
|
456
|
-
}
|
|
457
|
-
return false;
|
|
458
|
-
}
|
|
459
|
-
/**
|
|
460
|
-
* Post the worktree prompt message
|
|
461
|
-
*/
|
|
462
|
-
async postWorktreePrompt(session, reason) {
|
|
463
|
-
let message;
|
|
464
|
-
switch (reason) {
|
|
465
|
-
case 'uncommitted':
|
|
466
|
-
message = `🌿 **This repo has uncommitted changes.**\n` +
|
|
467
|
-
`Reply with a branch name to work in an isolated worktree, or react with ❌ to continue in the main repo.`;
|
|
468
|
-
break;
|
|
469
|
-
case 'concurrent':
|
|
470
|
-
message = `⚠️ **Another session is already using this repo.**\n` +
|
|
471
|
-
`Reply with a branch name to work in an isolated worktree, or react with ❌ to continue anyway.`;
|
|
472
|
-
break;
|
|
473
|
-
case 'require':
|
|
474
|
-
message = `🌿 **This deployment requires working in a worktree.**\n` +
|
|
475
|
-
`Please reply with a branch name to continue.`;
|
|
476
|
-
break;
|
|
477
|
-
default:
|
|
478
|
-
message = `🌿 **Would you like to work in an isolated worktree?**\n` +
|
|
479
|
-
`Reply with a branch name, or react with ❌ to continue in the main repo.`;
|
|
480
|
-
}
|
|
481
|
-
// Create post with ❌ reaction option (except for 'require' mode)
|
|
482
|
-
// Use 'x' emoji name, not Unicode ❌ character
|
|
483
|
-
const reactionOptions = reason === 'require' ? [] : ['x'];
|
|
484
|
-
const post = await this.mattermost.createInteractivePost(message, reactionOptions, session.threadId);
|
|
485
|
-
// Track the post for reaction handling
|
|
486
|
-
session.worktreePromptPostId = post.id;
|
|
487
|
-
this.registerPost(post.id, session.threadId);
|
|
488
|
-
// Stop typing while waiting for response
|
|
489
|
-
this.stopTyping(session);
|
|
490
|
-
}
|
|
491
|
-
handleEvent(threadId, event) {
|
|
492
|
-
const session = this.sessions.get(threadId);
|
|
493
|
-
if (!session)
|
|
494
|
-
return;
|
|
495
|
-
// Update last activity and reset timeout warning
|
|
496
|
-
session.lastActivityAt = new Date();
|
|
497
|
-
session.timeoutWarningPosted = false;
|
|
498
|
-
// Check for special tool uses that need custom handling
|
|
499
|
-
if (event.type === 'assistant') {
|
|
500
|
-
const msg = event.message;
|
|
501
|
-
let hasSpecialTool = false;
|
|
502
|
-
for (const block of msg?.content || []) {
|
|
503
|
-
if (block.type === 'tool_use') {
|
|
504
|
-
if (block.name === 'ExitPlanMode') {
|
|
505
|
-
this.handleExitPlanMode(session, block.id);
|
|
506
|
-
hasSpecialTool = true;
|
|
507
|
-
}
|
|
508
|
-
else if (block.name === 'TodoWrite') {
|
|
509
|
-
this.handleTodoWrite(session, block.input);
|
|
510
|
-
}
|
|
511
|
-
else if (block.name === 'Task') {
|
|
512
|
-
this.handleTaskStart(session, block.id, block.input);
|
|
513
|
-
}
|
|
514
|
-
else if (block.name === 'AskUserQuestion') {
|
|
515
|
-
this.handleAskUserQuestion(session, block.id, block.input);
|
|
516
|
-
hasSpecialTool = true;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
if (hasSpecialTool)
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
// Check for tool_result to update subagent status
|
|
524
|
-
if (event.type === 'user') {
|
|
525
|
-
const msg = event.message;
|
|
526
|
-
for (const block of msg?.content || []) {
|
|
527
|
-
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
528
|
-
const postId = session.activeSubagents.get(block.tool_use_id);
|
|
529
|
-
if (postId) {
|
|
530
|
-
this.handleTaskComplete(session, block.tool_use_id, postId);
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
const formatted = this.formatEvent(session, event);
|
|
536
|
-
if (this.debug) {
|
|
537
|
-
console.log(`[DEBUG] handleEvent(${threadId}): ${event.type} -> ${formatted ? formatted.substring(0, 100) : '(null)'}`);
|
|
538
|
-
}
|
|
539
|
-
if (formatted)
|
|
540
|
-
this.appendContent(session, formatted);
|
|
541
|
-
}
|
|
542
|
-
async handleTaskComplete(session, toolUseId, postId) {
|
|
543
|
-
try {
|
|
544
|
-
await this.mattermost.updatePost(postId, session.activeSubagents.has(toolUseId)
|
|
545
|
-
? `🤖 **Subagent** ✅ *completed*`
|
|
546
|
-
: `🤖 **Subagent** ✅`);
|
|
547
|
-
session.activeSubagents.delete(toolUseId);
|
|
548
|
-
}
|
|
549
|
-
catch (err) {
|
|
550
|
-
console.error(' ⚠️ Failed to update subagent completion:', err);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
async handleExitPlanMode(session, toolUseId) {
|
|
554
|
-
// If already approved in this session, send empty tool result to acknowledge
|
|
555
|
-
// (Claude needs a response to continue)
|
|
556
|
-
if (session.planApproved) {
|
|
557
|
-
if (this.debug)
|
|
558
|
-
console.log(' ↪ Plan already approved, sending acknowledgment');
|
|
559
|
-
if (session.claude.isRunning()) {
|
|
560
|
-
session.claude.sendToolResult(toolUseId, 'Plan already approved. Proceeding.');
|
|
561
|
-
}
|
|
562
|
-
return;
|
|
563
|
-
}
|
|
564
|
-
// If we already have a pending approval, don't post another one
|
|
565
|
-
if (session.pendingApproval && session.pendingApproval.type === 'plan') {
|
|
566
|
-
if (this.debug)
|
|
567
|
-
console.log(' ↪ Plan approval already pending, waiting');
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
// Flush any pending content first
|
|
571
|
-
await this.flush(session);
|
|
572
|
-
session.currentPostId = null;
|
|
573
|
-
session.pendingContent = '';
|
|
574
|
-
// Post approval message with reactions
|
|
575
|
-
const message = `✅ **Plan ready for approval**\n\n` +
|
|
576
|
-
`👍 Approve and start building\n` +
|
|
577
|
-
`👎 Request changes\n\n` +
|
|
578
|
-
`*React to respond*`;
|
|
579
|
-
const post = await this.mattermost.createInteractivePost(message, [APPROVAL_EMOJIS[0], DENIAL_EMOJIS[0]], session.threadId);
|
|
580
|
-
// Register post for reaction routing
|
|
581
|
-
this.registerPost(post.id, session.threadId);
|
|
582
|
-
// Track this for reaction handling - include toolUseId for proper response
|
|
583
|
-
session.pendingApproval = { postId: post.id, type: 'plan', toolUseId };
|
|
584
|
-
// Stop typing while waiting
|
|
585
|
-
this.stopTyping(session);
|
|
586
|
-
}
|
|
587
|
-
async handleTodoWrite(session, input) {
|
|
588
|
-
const todos = input.todos;
|
|
589
|
-
if (!todos || todos.length === 0) {
|
|
590
|
-
// Clear tasks display if empty
|
|
591
|
-
if (session.tasksPostId) {
|
|
592
|
-
try {
|
|
593
|
-
await this.mattermost.updatePost(session.tasksPostId, '📋 ~~Tasks~~ *(completed)*');
|
|
594
|
-
}
|
|
595
|
-
catch (err) {
|
|
596
|
-
console.error(' ⚠️ Failed to update tasks:', err);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
// Count progress
|
|
602
|
-
const completed = todos.filter(t => t.status === 'completed').length;
|
|
603
|
-
const total = todos.length;
|
|
604
|
-
const pct = Math.round((completed / total) * 100);
|
|
605
|
-
// Check if there's an in_progress task and track timing
|
|
606
|
-
const hasInProgress = todos.some(t => t.status === 'in_progress');
|
|
607
|
-
if (hasInProgress && !session.inProgressTaskStart) {
|
|
608
|
-
session.inProgressTaskStart = Date.now();
|
|
609
|
-
}
|
|
610
|
-
else if (!hasInProgress) {
|
|
611
|
-
session.inProgressTaskStart = null;
|
|
612
|
-
}
|
|
613
|
-
// Format tasks nicely with progress header
|
|
614
|
-
let message = `📋 **Tasks** (${completed}/${total} · ${pct}%)\n\n`;
|
|
615
|
-
for (const todo of todos) {
|
|
616
|
-
let icon;
|
|
617
|
-
let text;
|
|
618
|
-
switch (todo.status) {
|
|
619
|
-
case 'completed':
|
|
620
|
-
icon = '✅';
|
|
621
|
-
text = `~~${todo.content}~~`;
|
|
622
|
-
break;
|
|
623
|
-
case 'in_progress': {
|
|
624
|
-
icon = '🔄';
|
|
625
|
-
// Add elapsed time if we have a start time
|
|
626
|
-
let elapsed = '';
|
|
627
|
-
if (session.inProgressTaskStart) {
|
|
628
|
-
const secs = Math.round((Date.now() - session.inProgressTaskStart) / 1000);
|
|
629
|
-
if (secs >= 5) { // Only show if >= 5 seconds
|
|
630
|
-
elapsed = ` (${secs}s)`;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
text = `**${todo.activeForm}**${elapsed}`;
|
|
634
|
-
break;
|
|
635
|
-
}
|
|
636
|
-
default: // pending
|
|
637
|
-
icon = '○';
|
|
638
|
-
text = todo.content;
|
|
639
|
-
}
|
|
640
|
-
message += `${icon} ${text}\n`;
|
|
641
|
-
}
|
|
642
|
-
// Update or create tasks post
|
|
643
|
-
try {
|
|
644
|
-
if (session.tasksPostId) {
|
|
645
|
-
await this.mattermost.updatePost(session.tasksPostId, message);
|
|
646
|
-
}
|
|
647
|
-
else {
|
|
648
|
-
const post = await this.mattermost.createPost(message, session.threadId);
|
|
649
|
-
session.tasksPostId = post.id;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
catch (err) {
|
|
653
|
-
console.error(' ⚠️ Failed to update tasks:', err);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
async handleTaskStart(session, toolUseId, input) {
|
|
657
|
-
const description = input.description || 'Working...';
|
|
658
|
-
const subagentType = input.subagent_type || 'general';
|
|
659
|
-
// Post subagent status
|
|
660
|
-
const message = `🤖 **Subagent** *(${subagentType})*\n` +
|
|
661
|
-
`> ${description}\n` +
|
|
662
|
-
`⏳ Running...`;
|
|
663
|
-
try {
|
|
664
|
-
const post = await this.mattermost.createPost(message, session.threadId);
|
|
665
|
-
session.activeSubagents.set(toolUseId, post.id);
|
|
666
|
-
}
|
|
667
|
-
catch (err) {
|
|
668
|
-
console.error(' ⚠️ Failed to post subagent status:', err);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
async handleAskUserQuestion(session, toolUseId, input) {
|
|
672
|
-
// If we already have pending questions, don't start another set
|
|
673
|
-
if (session.pendingQuestionSet) {
|
|
674
|
-
if (this.debug)
|
|
675
|
-
console.log(' ↪ Questions already pending, waiting');
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
// Flush any pending content first
|
|
679
|
-
await this.flush(session);
|
|
680
|
-
session.currentPostId = null;
|
|
681
|
-
session.pendingContent = '';
|
|
682
|
-
const questions = input.questions;
|
|
683
|
-
if (!questions || questions.length === 0)
|
|
684
|
-
return;
|
|
685
|
-
// Create a new question set - we'll ask one at a time
|
|
686
|
-
session.pendingQuestionSet = {
|
|
687
|
-
toolUseId,
|
|
688
|
-
currentIndex: 0,
|
|
689
|
-
currentPostId: null,
|
|
690
|
-
questions: questions.map(q => ({
|
|
691
|
-
header: q.header,
|
|
692
|
-
question: q.question,
|
|
693
|
-
options: q.options,
|
|
694
|
-
answer: null,
|
|
695
|
-
})),
|
|
696
|
-
};
|
|
697
|
-
// Post the first question
|
|
698
|
-
await this.postCurrentQuestion(session);
|
|
699
|
-
// Stop typing while waiting for answer
|
|
700
|
-
this.stopTyping(session);
|
|
701
|
-
}
|
|
702
|
-
async postCurrentQuestion(session) {
|
|
703
|
-
if (!session.pendingQuestionSet)
|
|
704
|
-
return;
|
|
705
|
-
const { currentIndex, questions } = session.pendingQuestionSet;
|
|
706
|
-
if (currentIndex >= questions.length)
|
|
707
|
-
return;
|
|
708
|
-
const q = questions[currentIndex];
|
|
709
|
-
const total = questions.length;
|
|
710
|
-
// Format the question message
|
|
711
|
-
let message = `❓ **Question** *(${currentIndex + 1}/${total})*\n`;
|
|
712
|
-
message += `**${q.header}:** ${q.question}\n\n`;
|
|
713
|
-
for (let i = 0; i < q.options.length && i < 4; i++) {
|
|
714
|
-
const emoji = ['1️⃣', '2️⃣', '3️⃣', '4️⃣'][i];
|
|
715
|
-
message += `${emoji} **${q.options[i].label}**`;
|
|
716
|
-
if (q.options[i].description) {
|
|
717
|
-
message += ` - ${q.options[i].description}`;
|
|
718
|
-
}
|
|
719
|
-
message += '\n';
|
|
720
|
-
}
|
|
721
|
-
// Post the question with reaction options
|
|
722
|
-
const reactionOptions = NUMBER_EMOJIS.slice(0, q.options.length);
|
|
723
|
-
const post = await this.mattermost.createInteractivePost(message, reactionOptions, session.threadId);
|
|
724
|
-
session.pendingQuestionSet.currentPostId = post.id;
|
|
725
|
-
// Register post for reaction routing
|
|
726
|
-
this.registerPost(post.id, session.threadId);
|
|
727
|
-
}
|
|
728
|
-
// ---------------------------------------------------------------------------
|
|
729
|
-
// Reaction Handling
|
|
730
|
-
// ---------------------------------------------------------------------------
|
|
731
|
-
async handleReaction(postId, emojiName, username) {
|
|
732
|
-
// Check if user is allowed
|
|
733
|
-
if (!this.mattermost.isUserAllowed(username))
|
|
734
|
-
return;
|
|
735
|
-
// Find the session this post belongs to
|
|
736
|
-
const session = this.getSessionByPost(postId);
|
|
737
|
-
if (!session)
|
|
738
|
-
return;
|
|
739
|
-
// Handle ❌ on worktree prompt (skip worktree, continue in main repo)
|
|
740
|
-
// Must be checked BEFORE cancel reaction handler since ❌ is also a cancel emoji
|
|
741
|
-
if (session.worktreePromptPostId === postId && emojiName === 'x') {
|
|
742
|
-
await this.handleWorktreeSkip(session.threadId, username);
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
// Handle cancel reactions (❌ or 🛑) on any post in the session
|
|
746
|
-
if (isCancelEmoji(emojiName)) {
|
|
747
|
-
await this.cancelSession(session.threadId, username);
|
|
748
|
-
return;
|
|
749
|
-
}
|
|
750
|
-
// Handle interrupt reactions (⏸️) on any post in the session
|
|
751
|
-
if (isEscapeEmoji(emojiName)) {
|
|
752
|
-
await this.interruptSession(session.threadId, username);
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
// Handle approval reactions
|
|
756
|
-
if (session.pendingApproval && session.pendingApproval.postId === postId) {
|
|
757
|
-
await this.handleApprovalReaction(session, emojiName, username);
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
// Handle question reactions
|
|
761
|
-
if (session.pendingQuestionSet && session.pendingQuestionSet.currentPostId === postId) {
|
|
762
|
-
await this.handleQuestionReaction(session, postId, emojiName, username);
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
// Handle message approval reactions
|
|
766
|
-
if (session.pendingMessageApproval && session.pendingMessageApproval.postId === postId) {
|
|
767
|
-
await this.handleMessageApprovalReaction(session, emojiName, username);
|
|
768
|
-
return;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
async handleQuestionReaction(session, postId, emojiName, username) {
|
|
772
|
-
if (!session.pendingQuestionSet)
|
|
773
|
-
return;
|
|
774
|
-
const { currentIndex, questions } = session.pendingQuestionSet;
|
|
775
|
-
const question = questions[currentIndex];
|
|
776
|
-
if (!question)
|
|
777
|
-
return;
|
|
778
|
-
const optionIndex = getNumberEmojiIndex(emojiName);
|
|
779
|
-
if (optionIndex < 0 || optionIndex >= question.options.length)
|
|
780
|
-
return;
|
|
781
|
-
const selectedOption = question.options[optionIndex];
|
|
782
|
-
question.answer = selectedOption.label;
|
|
783
|
-
if (this.debug)
|
|
784
|
-
console.log(` 💬 @${username} answered "${question.header}": ${selectedOption.label}`);
|
|
785
|
-
// Update the post to show answer
|
|
786
|
-
try {
|
|
787
|
-
await this.mattermost.updatePost(postId, `✅ **${question.header}**: ${selectedOption.label}`);
|
|
788
|
-
}
|
|
789
|
-
catch (err) {
|
|
790
|
-
console.error(' ⚠️ Failed to update answered question:', err);
|
|
791
|
-
}
|
|
792
|
-
// Move to next question or finish
|
|
793
|
-
session.pendingQuestionSet.currentIndex++;
|
|
794
|
-
if (session.pendingQuestionSet.currentIndex < questions.length) {
|
|
795
|
-
// Post next question
|
|
796
|
-
await this.postCurrentQuestion(session);
|
|
797
|
-
}
|
|
798
|
-
else {
|
|
799
|
-
// All questions answered - send tool result
|
|
800
|
-
let answersText = 'Here are my answers:\n';
|
|
801
|
-
for (const q of questions) {
|
|
802
|
-
answersText += `- **${q.header}**: ${q.answer}\n`;
|
|
803
|
-
}
|
|
804
|
-
if (this.debug)
|
|
805
|
-
console.log(' ✅ All questions answered');
|
|
806
|
-
// Get the toolUseId before clearing
|
|
807
|
-
const toolUseId = session.pendingQuestionSet.toolUseId;
|
|
808
|
-
// Clear pending questions
|
|
809
|
-
session.pendingQuestionSet = null;
|
|
810
|
-
// Send tool result to Claude (AskUserQuestion expects a tool_result, not a user message)
|
|
811
|
-
if (session.claude.isRunning()) {
|
|
812
|
-
session.claude.sendToolResult(toolUseId, answersText);
|
|
813
|
-
this.startTyping(session);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
async handleApprovalReaction(session, emojiName, username) {
|
|
818
|
-
if (!session.pendingApproval)
|
|
819
|
-
return;
|
|
820
|
-
const isApprove = isApprovalEmoji(emojiName);
|
|
821
|
-
const isReject = isDenialEmoji(emojiName);
|
|
822
|
-
if (!isApprove && !isReject)
|
|
823
|
-
return;
|
|
824
|
-
const { postId, toolUseId } = session.pendingApproval;
|
|
825
|
-
const shortId = session.threadId.substring(0, 8);
|
|
826
|
-
console.log(` ${isApprove ? '✅' : '❌'} Plan ${isApprove ? 'approved' : 'rejected'} (${shortId}…) by @${username}`);
|
|
827
|
-
// Update the post to show the decision
|
|
828
|
-
try {
|
|
829
|
-
const statusMessage = isApprove
|
|
830
|
-
? `✅ **Plan approved** by @${username} - starting implementation...`
|
|
831
|
-
: `❌ **Changes requested** by @${username}`;
|
|
832
|
-
await this.mattermost.updatePost(postId, statusMessage);
|
|
833
|
-
}
|
|
834
|
-
catch (err) {
|
|
835
|
-
console.error(' ⚠️ Failed to update approval post:', err);
|
|
836
|
-
}
|
|
837
|
-
// Clear pending approval and mark as approved
|
|
838
|
-
session.pendingApproval = null;
|
|
839
|
-
if (isApprove) {
|
|
840
|
-
session.planApproved = true;
|
|
841
|
-
}
|
|
842
|
-
// Send tool result to Claude (ExitPlanMode expects a tool_result, not a user message)
|
|
843
|
-
if (session.claude.isRunning()) {
|
|
844
|
-
const response = isApprove
|
|
845
|
-
? 'Approved. Please proceed with the implementation.'
|
|
846
|
-
: 'Please revise the plan. I would like some changes.';
|
|
847
|
-
session.claude.sendToolResult(toolUseId, response);
|
|
848
|
-
this.startTyping(session);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
async handleMessageApprovalReaction(session, emoji, approver) {
|
|
852
|
-
const pending = session.pendingMessageApproval;
|
|
853
|
-
if (!pending)
|
|
854
|
-
return;
|
|
855
|
-
// Only session owner or globally allowed users can approve
|
|
856
|
-
if (session.startedBy !== approver && !this.mattermost.isUserAllowed(approver)) {
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
const isAllow = isApprovalEmoji(emoji);
|
|
860
|
-
const isInvite = isAllowAllEmoji(emoji);
|
|
861
|
-
const isDeny = isDenialEmoji(emoji);
|
|
862
|
-
if (!isAllow && !isInvite && !isDeny)
|
|
863
|
-
return;
|
|
864
|
-
if (isAllow) {
|
|
865
|
-
// Allow this single message
|
|
866
|
-
await this.mattermost.updatePost(pending.postId, `✅ Message from @${pending.fromUser} approved by @${approver}`);
|
|
867
|
-
session.claude.sendMessage(pending.originalMessage);
|
|
868
|
-
session.lastActivityAt = new Date();
|
|
869
|
-
this.startTyping(session);
|
|
870
|
-
console.log(` ✅ Message from @${pending.fromUser} approved by @${approver}`);
|
|
871
|
-
}
|
|
872
|
-
else if (isInvite) {
|
|
873
|
-
// Invite user to session
|
|
874
|
-
session.sessionAllowedUsers.add(pending.fromUser);
|
|
875
|
-
await this.mattermost.updatePost(pending.postId, `✅ @${pending.fromUser} invited to session by @${approver}`);
|
|
876
|
-
await this.updateSessionHeader(session);
|
|
877
|
-
session.claude.sendMessage(pending.originalMessage);
|
|
878
|
-
session.lastActivityAt = new Date();
|
|
879
|
-
this.startTyping(session);
|
|
880
|
-
console.log(` 👋 @${pending.fromUser} invited to session by @${approver}`);
|
|
881
|
-
}
|
|
882
|
-
else if (isDeny) {
|
|
883
|
-
// Deny
|
|
884
|
-
await this.mattermost.updatePost(pending.postId, `❌ Message from @${pending.fromUser} denied by @${approver}`);
|
|
885
|
-
console.log(` ❌ Message from @${pending.fromUser} denied by @${approver}`);
|
|
886
|
-
}
|
|
887
|
-
session.pendingMessageApproval = null;
|
|
888
|
-
}
|
|
889
|
-
formatEvent(session, e) {
|
|
890
|
-
switch (e.type) {
|
|
891
|
-
case 'assistant': {
|
|
892
|
-
const msg = e.message;
|
|
893
|
-
const parts = [];
|
|
894
|
-
for (const block of msg?.content || []) {
|
|
895
|
-
if (block.type === 'text' && block.text) {
|
|
896
|
-
// Filter out <thinking> tags that may appear in text content
|
|
897
|
-
const text = block.text.replace(/<thinking>[\s\S]*?<\/thinking>/g, '').trim();
|
|
898
|
-
if (text)
|
|
899
|
-
parts.push(text);
|
|
900
|
-
}
|
|
901
|
-
else if (block.type === 'tool_use' && block.name) {
|
|
902
|
-
const formatted = sharedFormatToolUse(block.name, block.input || {}, { detailed: true });
|
|
903
|
-
if (formatted)
|
|
904
|
-
parts.push(formatted);
|
|
905
|
-
}
|
|
906
|
-
else if (block.type === 'thinking' && block.thinking) {
|
|
907
|
-
// Extended thinking - show abbreviated version
|
|
908
|
-
const thinking = block.thinking;
|
|
909
|
-
const preview = thinking.length > 100 ? thinking.substring(0, 100) + '...' : thinking;
|
|
910
|
-
parts.push(`💭 *Thinking: ${preview}*`);
|
|
911
|
-
}
|
|
912
|
-
else if (block.type === 'server_tool_use' && block.name) {
|
|
913
|
-
// Server-managed tools like web search
|
|
914
|
-
parts.push(`🌐 **${block.name}** ${block.input ? JSON.stringify(block.input).substring(0, 50) : ''}`);
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
return parts.length > 0 ? parts.join('\n') : null;
|
|
918
|
-
}
|
|
919
|
-
case 'tool_use': {
|
|
920
|
-
const tool = e.tool_use;
|
|
921
|
-
// Track tool start time for elapsed display
|
|
922
|
-
if (tool.id) {
|
|
923
|
-
session.activeToolStarts.set(tool.id, Date.now());
|
|
924
|
-
}
|
|
925
|
-
return sharedFormatToolUse(tool.name, tool.input || {}, { detailed: true }) || null;
|
|
926
|
-
}
|
|
927
|
-
case 'tool_result': {
|
|
928
|
-
const result = e.tool_result;
|
|
929
|
-
// Calculate elapsed time
|
|
930
|
-
let elapsed = '';
|
|
931
|
-
if (result.tool_use_id) {
|
|
932
|
-
const startTime = session.activeToolStarts.get(result.tool_use_id);
|
|
933
|
-
if (startTime) {
|
|
934
|
-
const secs = Math.round((Date.now() - startTime) / 1000);
|
|
935
|
-
if (secs >= 3) { // Only show if >= 3 seconds
|
|
936
|
-
elapsed = ` (${secs}s)`;
|
|
937
|
-
}
|
|
938
|
-
session.activeToolStarts.delete(result.tool_use_id);
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
if (result.is_error)
|
|
942
|
-
return ` ↳ ❌ Error${elapsed}`;
|
|
943
|
-
if (elapsed)
|
|
944
|
-
return ` ↳ ✓${elapsed}`;
|
|
945
|
-
return null;
|
|
946
|
-
}
|
|
947
|
-
case 'result': {
|
|
948
|
-
// Response complete - stop typing and start new post for next message
|
|
949
|
-
this.stopTyping(session);
|
|
950
|
-
this.flush(session);
|
|
951
|
-
session.currentPostId = null;
|
|
952
|
-
session.pendingContent = '';
|
|
953
|
-
return null;
|
|
954
|
-
}
|
|
955
|
-
case 'system':
|
|
956
|
-
if (e.subtype === 'error')
|
|
957
|
-
return `❌ ${e.error}`;
|
|
958
|
-
return null;
|
|
959
|
-
case 'user': {
|
|
960
|
-
// Handle local command output (e.g., /context, /cost responses)
|
|
961
|
-
const msg = e.message;
|
|
962
|
-
if (typeof msg?.content === 'string') {
|
|
963
|
-
// Extract content from <local-command-stdout> tags
|
|
964
|
-
const match = msg.content.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
|
|
965
|
-
if (match) {
|
|
966
|
-
return match[1].trim();
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
return null;
|
|
970
|
-
}
|
|
971
|
-
default:
|
|
972
|
-
return null;
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
appendContent(session, text) {
|
|
976
|
-
if (!text)
|
|
977
|
-
return;
|
|
978
|
-
session.pendingContent += text + '\n';
|
|
979
|
-
this.scheduleUpdate(session);
|
|
980
|
-
}
|
|
981
|
-
scheduleUpdate(session) {
|
|
982
|
-
if (session.updateTimer)
|
|
983
|
-
return;
|
|
984
|
-
session.updateTimer = setTimeout(() => {
|
|
985
|
-
session.updateTimer = null;
|
|
986
|
-
this.flush(session);
|
|
987
|
-
}, 500);
|
|
988
|
-
}
|
|
989
|
-
/**
|
|
990
|
-
* Build message content for Claude, including images if present.
|
|
991
|
-
* Returns either a string or an array of content blocks.
|
|
992
|
-
*/
|
|
993
|
-
async buildMessageContent(text, files) {
|
|
994
|
-
// Filter to only image files
|
|
995
|
-
const imageFiles = files?.filter(f => f.mime_type.startsWith('image/') &&
|
|
996
|
-
['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(f.mime_type)) || [];
|
|
997
|
-
// If no images, return plain text
|
|
998
|
-
if (imageFiles.length === 0) {
|
|
999
|
-
return text;
|
|
1000
|
-
}
|
|
1001
|
-
// Build content blocks with images
|
|
1002
|
-
const blocks = [];
|
|
1003
|
-
// Download and add each image
|
|
1004
|
-
for (const file of imageFiles) {
|
|
1005
|
-
try {
|
|
1006
|
-
const buffer = await this.mattermost.downloadFile(file.id);
|
|
1007
|
-
const base64 = buffer.toString('base64');
|
|
1008
|
-
blocks.push({
|
|
1009
|
-
type: 'image',
|
|
1010
|
-
source: {
|
|
1011
|
-
type: 'base64',
|
|
1012
|
-
media_type: file.mime_type,
|
|
1013
|
-
data: base64,
|
|
1014
|
-
},
|
|
1015
|
-
});
|
|
1016
|
-
if (this.debug) {
|
|
1017
|
-
console.log(` 📷 Attached image: ${file.name} (${file.mime_type}, ${Math.round(buffer.length / 1024)}KB)`);
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
catch (err) {
|
|
1021
|
-
console.error(` ⚠️ Failed to download image ${file.name}:`, err);
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
// Add the text message
|
|
1025
|
-
if (text) {
|
|
1026
|
-
blocks.push({
|
|
1027
|
-
type: 'text',
|
|
1028
|
-
text,
|
|
1029
|
-
});
|
|
1030
|
-
}
|
|
1031
|
-
return blocks;
|
|
1032
|
-
}
|
|
1033
|
-
startTyping(session) {
|
|
1034
|
-
if (session.typingTimer)
|
|
1035
|
-
return;
|
|
1036
|
-
// Send typing immediately, then every 3 seconds
|
|
1037
|
-
this.mattermost.sendTyping(session.threadId);
|
|
1038
|
-
session.typingTimer = setInterval(() => {
|
|
1039
|
-
this.mattermost.sendTyping(session.threadId);
|
|
1040
|
-
}, 3000);
|
|
1041
|
-
}
|
|
1042
|
-
stopTyping(session) {
|
|
1043
|
-
if (session.typingTimer) {
|
|
1044
|
-
clearInterval(session.typingTimer);
|
|
1045
|
-
session.typingTimer = null;
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
async flush(session) {
|
|
1049
|
-
if (!session.pendingContent.trim())
|
|
1050
|
-
return;
|
|
1051
|
-
let content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
|
|
1052
|
-
// Mattermost has a 16,383 character limit for posts
|
|
1053
|
-
const MAX_POST_LENGTH = 16000; // Leave some margin
|
|
1054
|
-
const CONTINUATION_THRESHOLD = 14000; // Start new message before we hit the limit
|
|
1055
|
-
// Check if we need to start a new message due to length
|
|
1056
|
-
if (session.currentPostId && content.length > CONTINUATION_THRESHOLD) {
|
|
1057
|
-
// Finalize the current post with what we have up to the threshold
|
|
1058
|
-
// Find a good break point (end of line) near the threshold
|
|
1059
|
-
let breakPoint = content.lastIndexOf('\n', CONTINUATION_THRESHOLD);
|
|
1060
|
-
if (breakPoint < CONTINUATION_THRESHOLD * 0.7) {
|
|
1061
|
-
// If we can't find a good line break, just break at the threshold
|
|
1062
|
-
breakPoint = CONTINUATION_THRESHOLD;
|
|
1063
|
-
}
|
|
1064
|
-
const firstPart = content.substring(0, breakPoint).trim() + '\n\n*... (continued below)*';
|
|
1065
|
-
const remainder = content.substring(breakPoint).trim();
|
|
1066
|
-
// Update the current post with the first part
|
|
1067
|
-
await this.mattermost.updatePost(session.currentPostId, firstPart);
|
|
1068
|
-
// Start a new post for the continuation
|
|
1069
|
-
session.currentPostId = null;
|
|
1070
|
-
session.pendingContent = remainder;
|
|
1071
|
-
// Create the continuation post if there's content
|
|
1072
|
-
if (remainder) {
|
|
1073
|
-
const post = await this.mattermost.createPost('*(continued)*\n\n' + remainder, session.threadId);
|
|
1074
|
-
session.currentPostId = post.id;
|
|
1075
|
-
this.registerPost(post.id, session.threadId);
|
|
1076
|
-
}
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
// Normal case: content fits in current post
|
|
1080
|
-
if (content.length > MAX_POST_LENGTH) {
|
|
1081
|
-
// Safety truncation if we somehow got content that's still too long
|
|
1082
|
-
content = content.substring(0, MAX_POST_LENGTH - 50) + '\n\n*... (truncated)*';
|
|
1083
|
-
}
|
|
1084
|
-
if (session.currentPostId) {
|
|
1085
|
-
await this.mattermost.updatePost(session.currentPostId, content);
|
|
1086
|
-
}
|
|
1087
|
-
else {
|
|
1088
|
-
const post = await this.mattermost.createPost(content, session.threadId);
|
|
1089
|
-
session.currentPostId = post.id;
|
|
1090
|
-
// Register post for reaction routing
|
|
1091
|
-
this.registerPost(post.id, session.threadId);
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
async handleExit(threadId, code) {
|
|
1095
|
-
const session = this.sessions.get(threadId);
|
|
1096
|
-
const shortId = threadId.substring(0, 8);
|
|
1097
|
-
// Always log exit events to trace the flow
|
|
1098
|
-
console.log(` [exit] handleExit called for ${shortId}... code=${code} isShuttingDown=${this.isShuttingDown}`);
|
|
1099
|
-
if (!session) {
|
|
1100
|
-
console.log(` [exit] Session ${shortId}... not found (already cleaned up)`);
|
|
1101
|
-
return;
|
|
1102
|
-
}
|
|
1103
|
-
// If we're intentionally restarting (e.g., !cd), don't clean up or post exit message
|
|
1104
|
-
if (session.isRestarting) {
|
|
1105
|
-
console.log(` [exit] Session ${shortId}... restarting, skipping cleanup`);
|
|
1106
|
-
session.isRestarting = false; // Reset flag here, after the exit event fires
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
// If bot is shutting down, suppress exit messages (shutdown message already sent)
|
|
1110
|
-
// IMPORTANT: Check this flag FIRST before any cleanup. The session should remain
|
|
1111
|
-
// persisted so it can be resumed after restart.
|
|
1112
|
-
if (this.isShuttingDown) {
|
|
1113
|
-
console.log(` [exit] Session ${shortId}... bot shutting down, preserving persistence`);
|
|
1114
|
-
// Still clean up from in-memory maps since we're shutting down anyway
|
|
1115
|
-
this.stopTyping(session);
|
|
1116
|
-
if (session.updateTimer) {
|
|
1117
|
-
clearTimeout(session.updateTimer);
|
|
1118
|
-
session.updateTimer = null;
|
|
1119
|
-
}
|
|
1120
|
-
this.sessions.delete(threadId);
|
|
1121
|
-
return;
|
|
1122
|
-
}
|
|
1123
|
-
// If session was interrupted (SIGINT sent), preserve for resume
|
|
1124
|
-
// Claude CLI exits on SIGINT, but we want to allow resuming the session
|
|
1125
|
-
if (session.wasInterrupted) {
|
|
1126
|
-
console.log(` [exit] Session ${shortId}... exited after interrupt, preserving for resume`);
|
|
1127
|
-
this.stopTyping(session);
|
|
1128
|
-
if (session.updateTimer) {
|
|
1129
|
-
clearTimeout(session.updateTimer);
|
|
1130
|
-
session.updateTimer = null;
|
|
1131
|
-
}
|
|
1132
|
-
// Update persistence with current state before cleanup
|
|
1133
|
-
this.persistSession(session);
|
|
1134
|
-
this.sessions.delete(threadId);
|
|
1135
|
-
// Clean up post index
|
|
1136
|
-
for (const [postId, tid] of this.postIndex.entries()) {
|
|
1137
|
-
if (tid === threadId) {
|
|
1138
|
-
this.postIndex.delete(postId);
|
|
1139
|
-
}
|
|
1140
|
-
}
|
|
1141
|
-
// Notify user they can send a new message to resume
|
|
1142
|
-
try {
|
|
1143
|
-
await this.mattermost.createPost(`ℹ️ Session paused. Send a new message to continue.`, session.threadId);
|
|
1144
|
-
}
|
|
1145
|
-
catch {
|
|
1146
|
-
// Ignore if we can't post
|
|
1147
|
-
}
|
|
1148
|
-
console.log(` ⏸️ Session paused (${shortId}…) — ${this.sessions.size} active`);
|
|
1149
|
-
return;
|
|
1150
|
-
}
|
|
1151
|
-
// For resumed sessions that exit quickly (e.g., Claude --resume fails),
|
|
1152
|
-
// don't unpersist immediately - give it a chance to be retried
|
|
1153
|
-
if (session.isResumed && code !== 0) {
|
|
1154
|
-
console.log(` [exit] Resumed session ${shortId}... failed with code ${code}, preserving for retry`);
|
|
1155
|
-
this.stopTyping(session);
|
|
1156
|
-
if (session.updateTimer) {
|
|
1157
|
-
clearTimeout(session.updateTimer);
|
|
1158
|
-
session.updateTimer = null;
|
|
1159
|
-
}
|
|
1160
|
-
this.sessions.delete(threadId);
|
|
1161
|
-
// Post error message but keep persistence
|
|
1162
|
-
try {
|
|
1163
|
-
await this.mattermost.createPost(`⚠️ **Session resume failed** (exit code ${code}). The session data is preserved - try restarting the bot.`, session.threadId);
|
|
1164
|
-
}
|
|
1165
|
-
catch {
|
|
1166
|
-
// Ignore if we can't post
|
|
1167
|
-
}
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
console.log(` [exit] Session ${shortId}... normal exit, cleaning up`);
|
|
1171
|
-
this.stopTyping(session);
|
|
1172
|
-
if (session.updateTimer) {
|
|
1173
|
-
clearTimeout(session.updateTimer);
|
|
1174
|
-
session.updateTimer = null;
|
|
1175
|
-
}
|
|
1176
|
-
await this.flush(session);
|
|
1177
|
-
if (code !== 0 && code !== null) {
|
|
1178
|
-
await this.mattermost.createPost(`**[Exited: ${code}]**`, session.threadId);
|
|
1179
|
-
}
|
|
1180
|
-
// Clean up session from maps
|
|
1181
|
-
this.sessions.delete(threadId);
|
|
1182
|
-
// Clean up post index entries for this session
|
|
1183
|
-
for (const [postId, tid] of this.postIndex.entries()) {
|
|
1184
|
-
if (tid === threadId) {
|
|
1185
|
-
this.postIndex.delete(postId);
|
|
1186
|
-
}
|
|
1187
|
-
}
|
|
1188
|
-
// Only unpersist for normal exits (code 0 or null means graceful completion)
|
|
1189
|
-
// Non-zero exits might be recoverable, so we keep the session persisted
|
|
1190
|
-
if (code === 0 || code === null) {
|
|
1191
|
-
this.unpersistSession(threadId);
|
|
1192
|
-
}
|
|
1193
|
-
else {
|
|
1194
|
-
console.log(` [exit] Session ${shortId}... non-zero exit, preserving for potential retry`);
|
|
1195
|
-
}
|
|
1196
|
-
console.log(` ■ Session ended (${shortId}…) — ${this.sessions.size} active`);
|
|
1197
|
-
}
|
|
1198
|
-
// ---------------------------------------------------------------------------
|
|
1199
|
-
// Public Session API
|
|
1200
|
-
// ---------------------------------------------------------------------------
|
|
1201
|
-
/** Check if any sessions are active */
|
|
1202
|
-
isSessionActive() {
|
|
1203
|
-
return this.sessions.size > 0;
|
|
1204
|
-
}
|
|
1205
|
-
/** Check if a session exists for this thread */
|
|
1206
|
-
isInSessionThread(threadRoot) {
|
|
1207
|
-
const session = this.sessions.get(threadRoot);
|
|
1208
|
-
return session !== undefined && session.claude.isRunning();
|
|
1209
|
-
}
|
|
1210
|
-
/** Send a follow-up message to an existing session */
|
|
1211
|
-
async sendFollowUp(threadId, message, files) {
|
|
1212
|
-
const session = this.sessions.get(threadId);
|
|
1213
|
-
if (!session || !session.claude.isRunning())
|
|
1214
|
-
return;
|
|
1215
|
-
const content = await this.buildMessageContent(message, files);
|
|
1216
|
-
session.claude.sendMessage(content);
|
|
1217
|
-
session.lastActivityAt = new Date();
|
|
1218
|
-
this.startTyping(session);
|
|
1219
|
-
}
|
|
1220
|
-
/**
|
|
1221
|
-
* Check if there's a paused (persisted but not active) session for this thread.
|
|
1222
|
-
* This is used to detect when we should resume a session instead of ignoring the message.
|
|
1223
|
-
*/
|
|
1224
|
-
hasPausedSession(threadId) {
|
|
1225
|
-
// If there's an active session, it's not paused
|
|
1226
|
-
if (this.sessions.has(threadId))
|
|
1227
|
-
return false;
|
|
1228
|
-
// Check persistence
|
|
1229
|
-
const persisted = this.sessionStore.load();
|
|
1230
|
-
return persisted.has(threadId);
|
|
1231
|
-
}
|
|
1232
|
-
/**
|
|
1233
|
-
* Resume a paused session and send a message to it.
|
|
1234
|
-
* Called when a user sends a message to a thread with a paused session.
|
|
1235
|
-
*/
|
|
1236
|
-
async resumePausedSession(threadId, message, files) {
|
|
1237
|
-
const persisted = this.sessionStore.load();
|
|
1238
|
-
const state = persisted.get(threadId);
|
|
1239
|
-
if (!state) {
|
|
1240
|
-
console.log(` [resume] No persisted session found for ${threadId.substring(0, 8)}...`);
|
|
1241
|
-
return;
|
|
1242
|
-
}
|
|
1243
|
-
const shortId = threadId.substring(0, 8);
|
|
1244
|
-
console.log(` 🔄 Resuming paused session ${shortId}... for new message`);
|
|
1245
|
-
// Resume the session (similar to initialize() but for a single session)
|
|
1246
|
-
await this.resumeSession(state);
|
|
1247
|
-
// Wait a moment for the session to be ready, then send the message
|
|
1248
|
-
const session = this.sessions.get(threadId);
|
|
1249
|
-
if (session && session.claude.isRunning()) {
|
|
1250
|
-
const content = await this.buildMessageContent(message, files);
|
|
1251
|
-
session.claude.sendMessage(content);
|
|
1252
|
-
session.lastActivityAt = new Date();
|
|
1253
|
-
this.startTyping(session);
|
|
1254
|
-
}
|
|
1255
|
-
else {
|
|
1256
|
-
console.log(` ⚠️ Failed to resume session ${shortId}..., could not send message`);
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
/**
|
|
1260
|
-
* Get persisted session info for access control checks
|
|
1261
|
-
*/
|
|
1262
|
-
getPersistedSession(threadId) {
|
|
1263
|
-
const persisted = this.sessionStore.load();
|
|
1264
|
-
return persisted.get(threadId);
|
|
1265
|
-
}
|
|
1266
|
-
/** Kill a specific session */
|
|
1267
|
-
killSession(threadId, unpersist = true) {
|
|
1268
|
-
const session = this.sessions.get(threadId);
|
|
1269
|
-
if (!session)
|
|
1270
|
-
return;
|
|
1271
|
-
const shortId = threadId.substring(0, 8);
|
|
1272
|
-
// Set restarting flag to prevent handleExit from also unpersisting
|
|
1273
|
-
// (we'll do it explicitly here if requested)
|
|
1274
|
-
if (!unpersist) {
|
|
1275
|
-
session.isRestarting = true; // Reuse this flag to skip cleanup in handleExit
|
|
1276
|
-
}
|
|
1277
|
-
this.stopTyping(session);
|
|
1278
|
-
session.claude.kill();
|
|
1279
|
-
// Clean up session from maps
|
|
1280
|
-
this.sessions.delete(threadId);
|
|
1281
|
-
for (const [postId, tid] of this.postIndex.entries()) {
|
|
1282
|
-
if (tid === threadId) {
|
|
1283
|
-
this.postIndex.delete(postId);
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
// Explicitly unpersist if requested (e.g., for timeout, cancel, etc.)
|
|
1287
|
-
if (unpersist) {
|
|
1288
|
-
this.unpersistSession(threadId);
|
|
1289
|
-
}
|
|
1290
|
-
console.log(` ✖ Session killed (${shortId}…) — ${this.sessions.size} active`);
|
|
1291
|
-
}
|
|
1292
|
-
/** Cancel a session with user feedback */
|
|
1293
|
-
async cancelSession(threadId, username) {
|
|
1294
|
-
const session = this.sessions.get(threadId);
|
|
1295
|
-
if (!session)
|
|
1296
|
-
return;
|
|
1297
|
-
const shortId = threadId.substring(0, 8);
|
|
1298
|
-
console.log(` 🛑 Session (${shortId}…) cancelled by @${username}`);
|
|
1299
|
-
await this.mattermost.createPost(`🛑 **Session cancelled** by @${username}`, threadId);
|
|
1300
|
-
this.killSession(threadId);
|
|
1301
|
-
}
|
|
1302
|
-
/** Interrupt current processing but keep session alive (like Escape in CLI) */
|
|
1303
|
-
async interruptSession(threadId, username) {
|
|
1304
|
-
const session = this.sessions.get(threadId);
|
|
1305
|
-
if (!session)
|
|
1306
|
-
return;
|
|
1307
|
-
if (!session.claude.isRunning()) {
|
|
1308
|
-
await this.mattermost.createPost(`ℹ️ Session is idle, nothing to interrupt`, threadId);
|
|
1309
|
-
return;
|
|
1310
|
-
}
|
|
1311
|
-
const shortId = threadId.substring(0, 8);
|
|
1312
|
-
// Set flag BEFORE interrupt - if Claude exits due to SIGINT, we won't unpersist
|
|
1313
|
-
session.wasInterrupted = true;
|
|
1314
|
-
const interrupted = session.claude.interrupt();
|
|
1315
|
-
if (interrupted) {
|
|
1316
|
-
console.log(` ⏸️ Session (${shortId}…) interrupted by @${username}`);
|
|
1317
|
-
await this.mattermost.createPost(`⏸️ **Interrupted** by @${username}`, threadId);
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
/** Change working directory for a session (restarts Claude CLI) */
|
|
1321
|
-
async changeDirectory(threadId, newDir, username) {
|
|
1322
|
-
const session = this.sessions.get(threadId);
|
|
1323
|
-
if (!session)
|
|
1324
|
-
return;
|
|
1325
|
-
// Only session owner or globally allowed users can change directory
|
|
1326
|
-
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1327
|
-
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can change the working directory`, threadId);
|
|
1328
|
-
return;
|
|
1329
|
-
}
|
|
1330
|
-
// Expand ~ to home directory
|
|
1331
|
-
const expandedDir = newDir.startsWith('~')
|
|
1332
|
-
? newDir.replace('~', process.env.HOME || '')
|
|
1333
|
-
: newDir;
|
|
1334
|
-
// Resolve to absolute path
|
|
1335
|
-
const { resolve } = await import('path');
|
|
1336
|
-
const absoluteDir = resolve(expandedDir);
|
|
1337
|
-
// Check if directory exists
|
|
1338
|
-
const { existsSync, statSync } = await import('fs');
|
|
1339
|
-
if (!existsSync(absoluteDir)) {
|
|
1340
|
-
await this.mattermost.createPost(`❌ Directory does not exist: \`${newDir}\``, threadId);
|
|
1341
|
-
return;
|
|
1342
|
-
}
|
|
1343
|
-
if (!statSync(absoluteDir).isDirectory()) {
|
|
1344
|
-
await this.mattermost.createPost(`❌ Not a directory: \`${newDir}\``, threadId);
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
const shortId = threadId.substring(0, 8);
|
|
1348
|
-
const shortDir = absoluteDir.replace(process.env.HOME || '', '~');
|
|
1349
|
-
console.log(` 📂 Session (${shortId}…) changing directory to ${shortDir}`);
|
|
1350
|
-
// Stop the current Claude CLI
|
|
1351
|
-
this.stopTyping(session);
|
|
1352
|
-
session.isRestarting = true; // Suppress exit message during restart
|
|
1353
|
-
session.claude.kill();
|
|
1354
|
-
// Flush any pending content
|
|
1355
|
-
await this.flush(session);
|
|
1356
|
-
session.currentPostId = null;
|
|
1357
|
-
session.pendingContent = '';
|
|
1358
|
-
// Update session working directory
|
|
1359
|
-
session.workingDir = absoluteDir;
|
|
1360
|
-
// Generate new session ID for fresh start in new directory
|
|
1361
|
-
// (Claude CLI sessions are tied to working directory, can't resume across directories)
|
|
1362
|
-
const newSessionId = randomUUID();
|
|
1363
|
-
session.claudeSessionId = newSessionId;
|
|
1364
|
-
const cliOptions = {
|
|
1365
|
-
workingDir: absoluteDir,
|
|
1366
|
-
threadId: threadId,
|
|
1367
|
-
skipPermissions: this.skipPermissions || !session.forceInteractivePermissions,
|
|
1368
|
-
sessionId: newSessionId,
|
|
1369
|
-
resume: false, // Fresh start - can't resume across directories
|
|
1370
|
-
chrome: this.chromeEnabled,
|
|
1371
|
-
};
|
|
1372
|
-
session.claude = new ClaudeCli(cliOptions);
|
|
1373
|
-
// Rebind event handlers
|
|
1374
|
-
session.claude.on('event', (e) => this.handleEvent(threadId, e));
|
|
1375
|
-
session.claude.on('exit', (code) => this.handleExit(threadId, code));
|
|
1376
|
-
// Start the new Claude CLI
|
|
1377
|
-
try {
|
|
1378
|
-
session.claude.start();
|
|
1379
|
-
// Note: isRestarting is reset in handleExit when the old process exit event fires
|
|
1380
|
-
}
|
|
1381
|
-
catch (err) {
|
|
1382
|
-
session.isRestarting = false; // Reset flag on failure since exit won't fire
|
|
1383
|
-
console.error(' ❌ Failed to restart Claude:', err);
|
|
1384
|
-
await this.mattermost.createPost(`❌ Failed to restart Claude: ${err}`, threadId);
|
|
1385
|
-
return;
|
|
1386
|
-
}
|
|
1387
|
-
// Update session header with new directory
|
|
1388
|
-
await this.updateSessionHeader(session);
|
|
1389
|
-
// Post confirmation
|
|
1390
|
-
await this.mattermost.createPost(`📂 **Working directory changed** to \`${shortDir}\`\n*Claude Code restarted in new directory*`, threadId);
|
|
1391
|
-
// Update activity
|
|
1392
|
-
session.lastActivityAt = new Date();
|
|
1393
|
-
session.timeoutWarningPosted = false;
|
|
1394
|
-
// Persist the updated session state
|
|
1395
|
-
this.persistSession(session);
|
|
1396
|
-
}
|
|
1397
|
-
/** Invite a user to participate in a specific session */
|
|
1398
|
-
async inviteUser(threadId, invitedUser, invitedBy) {
|
|
1399
|
-
const session = this.sessions.get(threadId);
|
|
1400
|
-
if (!session)
|
|
1401
|
-
return;
|
|
1402
|
-
// Only session owner or globally allowed users can invite
|
|
1403
|
-
if (session.startedBy !== invitedBy && !this.mattermost.isUserAllowed(invitedBy)) {
|
|
1404
|
-
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can invite others`, threadId);
|
|
1405
|
-
return;
|
|
1406
|
-
}
|
|
1407
|
-
session.sessionAllowedUsers.add(invitedUser);
|
|
1408
|
-
await this.mattermost.createPost(`✅ @${invitedUser} can now participate in this session (invited by @${invitedBy})`, threadId);
|
|
1409
|
-
console.log(` 👋 @${invitedUser} invited to session by @${invitedBy}`);
|
|
1410
|
-
await this.updateSessionHeader(session);
|
|
1411
|
-
this.persistSession(session); // Persist collaboration change
|
|
1412
|
-
}
|
|
1413
|
-
/** Kick a user from a specific session */
|
|
1414
|
-
async kickUser(threadId, kickedUser, kickedBy) {
|
|
1415
|
-
const session = this.sessions.get(threadId);
|
|
1416
|
-
if (!session)
|
|
1417
|
-
return;
|
|
1418
|
-
// Only session owner or globally allowed users can kick
|
|
1419
|
-
if (session.startedBy !== kickedBy && !this.mattermost.isUserAllowed(kickedBy)) {
|
|
1420
|
-
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can kick others`, threadId);
|
|
1421
|
-
return;
|
|
1422
|
-
}
|
|
1423
|
-
// Can't kick session owner
|
|
1424
|
-
if (kickedUser === session.startedBy) {
|
|
1425
|
-
await this.mattermost.createPost(`⚠️ Cannot kick session owner @${session.startedBy}`, threadId);
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
// Can't kick globally allowed users (they'll still have access)
|
|
1429
|
-
if (this.mattermost.isUserAllowed(kickedUser)) {
|
|
1430
|
-
await this.mattermost.createPost(`⚠️ @${kickedUser} is globally allowed and cannot be kicked from individual sessions`, threadId);
|
|
1431
|
-
return;
|
|
1432
|
-
}
|
|
1433
|
-
if (session.sessionAllowedUsers.delete(kickedUser)) {
|
|
1434
|
-
await this.mattermost.createPost(`🚫 @${kickedUser} removed from this session by @${kickedBy}`, threadId);
|
|
1435
|
-
console.log(` 🚫 @${kickedUser} kicked from session by @${kickedBy}`);
|
|
1436
|
-
await this.updateSessionHeader(session);
|
|
1437
|
-
this.persistSession(session); // Persist collaboration change
|
|
1438
|
-
}
|
|
1439
|
-
else {
|
|
1440
|
-
await this.mattermost.createPost(`⚠️ @${kickedUser} was not in this session`, threadId);
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
/**
|
|
1444
|
-
* Enable interactive permissions for a session.
|
|
1445
|
-
* Can only downgrade (skip → interactive), not upgrade.
|
|
1446
|
-
*/
|
|
1447
|
-
async enableInteractivePermissions(threadId, username) {
|
|
1448
|
-
const session = this.sessions.get(threadId);
|
|
1449
|
-
if (!session)
|
|
1450
|
-
return;
|
|
1451
|
-
// Only session owner or globally allowed users can change permissions
|
|
1452
|
-
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1453
|
-
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can change permissions`, threadId);
|
|
1454
|
-
return;
|
|
1455
|
-
}
|
|
1456
|
-
// Can only downgrade, not upgrade
|
|
1457
|
-
if (!this.skipPermissions) {
|
|
1458
|
-
await this.mattermost.createPost(`ℹ️ Permissions are already interactive for this session`, threadId);
|
|
1459
|
-
return;
|
|
1460
|
-
}
|
|
1461
|
-
// Already enabled for this session
|
|
1462
|
-
if (session.forceInteractivePermissions) {
|
|
1463
|
-
await this.mattermost.createPost(`ℹ️ Interactive permissions already enabled for this session`, threadId);
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
// Set the flag
|
|
1467
|
-
session.forceInteractivePermissions = true;
|
|
1468
|
-
const shortId = threadId.substring(0, 8);
|
|
1469
|
-
console.log(` 🔐 Session (${shortId}…) enabling interactive permissions`);
|
|
1470
|
-
// Stop the current Claude CLI and restart with new permission setting
|
|
1471
|
-
this.stopTyping(session);
|
|
1472
|
-
session.isRestarting = true; // Suppress exit message during restart
|
|
1473
|
-
session.claude.kill();
|
|
1474
|
-
// Flush any pending content
|
|
1475
|
-
await this.flush(session);
|
|
1476
|
-
session.currentPostId = null;
|
|
1477
|
-
session.pendingContent = '';
|
|
1478
|
-
// Create new CLI options with interactive permissions (skipPermissions: false)
|
|
1479
|
-
const cliOptions = {
|
|
1480
|
-
workingDir: session.workingDir,
|
|
1481
|
-
threadId: threadId,
|
|
1482
|
-
skipPermissions: false, // Force interactive permissions
|
|
1483
|
-
sessionId: session.claudeSessionId,
|
|
1484
|
-
resume: true, // Resume to keep conversation context
|
|
1485
|
-
chrome: this.chromeEnabled,
|
|
1486
|
-
};
|
|
1487
|
-
session.claude = new ClaudeCli(cliOptions);
|
|
1488
|
-
// Rebind event handlers
|
|
1489
|
-
session.claude.on('event', (e) => this.handleEvent(threadId, e));
|
|
1490
|
-
session.claude.on('exit', (code) => this.handleExit(threadId, code));
|
|
1491
|
-
// Start the new Claude CLI
|
|
1492
|
-
try {
|
|
1493
|
-
session.claude.start();
|
|
1494
|
-
// Note: isRestarting is reset in handleExit when the old process exit event fires
|
|
1495
|
-
}
|
|
1496
|
-
catch (err) {
|
|
1497
|
-
session.isRestarting = false; // Reset flag on failure since exit won't fire
|
|
1498
|
-
console.error(' ❌ Failed to restart Claude:', err);
|
|
1499
|
-
await this.mattermost.createPost(`❌ Failed to enable interactive permissions: ${err}`, threadId);
|
|
1500
|
-
return;
|
|
1501
|
-
}
|
|
1502
|
-
// Update session header with new permission status
|
|
1503
|
-
await this.updateSessionHeader(session);
|
|
1504
|
-
// Post confirmation
|
|
1505
|
-
await this.mattermost.createPost(`🔐 **Interactive permissions enabled** for this session by @${username}\n*Claude Code restarted with permission prompts*`, threadId);
|
|
1506
|
-
console.log(` 🔐 Interactive permissions enabled for session by @${username}`);
|
|
1507
|
-
// Update activity and persist
|
|
1508
|
-
session.lastActivityAt = new Date();
|
|
1509
|
-
session.timeoutWarningPosted = false;
|
|
1510
|
-
this.persistSession(session);
|
|
1511
|
-
}
|
|
1512
|
-
/** Check if a session should use interactive permissions */
|
|
1513
|
-
isSessionInteractive(threadId) {
|
|
1514
|
-
const session = this.sessions.get(threadId);
|
|
1515
|
-
if (!session)
|
|
1516
|
-
return !this.skipPermissions;
|
|
1517
|
-
// If global is interactive, always interactive
|
|
1518
|
-
if (!this.skipPermissions)
|
|
1519
|
-
return true;
|
|
1520
|
-
// If session has forced interactive, use that
|
|
1521
|
-
return session.forceInteractivePermissions;
|
|
1522
|
-
}
|
|
1523
|
-
/** Update the session header post with current participants */
|
|
1524
|
-
async updateSessionHeader(session) {
|
|
1525
|
-
if (!session.sessionStartPostId)
|
|
1526
|
-
return;
|
|
1527
|
-
// Use session's working directory (can be changed via !cd)
|
|
1528
|
-
const shortDir = session.workingDir.replace(process.env.HOME || '', '~');
|
|
1529
|
-
// Check session-level permission override
|
|
1530
|
-
const isInteractive = !this.skipPermissions || session.forceInteractivePermissions;
|
|
1531
|
-
const permMode = isInteractive ? '🔐 Interactive' : '⚡ Auto';
|
|
1532
|
-
// Build participants list (excluding owner who is shown in "Started by")
|
|
1533
|
-
const otherParticipants = [...session.sessionAllowedUsers]
|
|
1534
|
-
.filter(u => u !== session.startedBy)
|
|
1535
|
-
.map(u => `@${u}`)
|
|
1536
|
-
.join(', ');
|
|
1537
|
-
const rows = [
|
|
1538
|
-
`| 📂 **Directory** | \`${shortDir}\` |`,
|
|
1539
|
-
`| 👤 **Started by** | @${session.startedBy} |`,
|
|
1540
|
-
];
|
|
1541
|
-
// Show worktree info if active
|
|
1542
|
-
if (session.worktreeInfo) {
|
|
1543
|
-
const shortRepoRoot = session.worktreeInfo.repoRoot.replace(process.env.HOME || '', '~');
|
|
1544
|
-
rows.push(`| 🌿 **Worktree** | \`${session.worktreeInfo.branch}\` (from \`${shortRepoRoot}\`) |`);
|
|
1545
|
-
}
|
|
1546
|
-
if (otherParticipants) {
|
|
1547
|
-
rows.push(`| 👥 **Participants** | ${otherParticipants} |`);
|
|
1548
|
-
}
|
|
1549
|
-
rows.push(`| 🔢 **Session** | #${session.sessionNumber} of ${MAX_SESSIONS} max |`);
|
|
1550
|
-
rows.push(`| ${permMode.split(' ')[0]} **Permissions** | ${permMode.split(' ')[1]} |`);
|
|
1551
|
-
if (this.chromeEnabled) {
|
|
1552
|
-
rows.push(`| 🌐 **Chrome** | Enabled |`);
|
|
1553
|
-
}
|
|
1554
|
-
// Check for available updates
|
|
1555
|
-
const updateInfo = getUpdateInfo();
|
|
1556
|
-
const updateNotice = updateInfo
|
|
1557
|
-
? `\n> ⚠️ **Update available:** v${updateInfo.current} → v${updateInfo.latest} - Run \`npm install -g claude-threads\`\n`
|
|
1558
|
-
: '';
|
|
1559
|
-
// Get "What's new" from release notes
|
|
1560
|
-
const releaseNotes = getReleaseNotes(pkg.version);
|
|
1561
|
-
const whatsNew = releaseNotes ? getWhatsNewSummary(releaseNotes) : '';
|
|
1562
|
-
const whatsNewLine = whatsNew ? `\n> ✨ **What's new:** ${whatsNew}\n` : '';
|
|
1563
|
-
const msg = [
|
|
1564
|
-
getMattermostLogo(pkg.version),
|
|
1565
|
-
updateNotice,
|
|
1566
|
-
whatsNewLine,
|
|
1567
|
-
`| | |`,
|
|
1568
|
-
`|:--|:--|`,
|
|
1569
|
-
...rows,
|
|
1570
|
-
].join('\n');
|
|
1571
|
-
try {
|
|
1572
|
-
await this.mattermost.updatePost(session.sessionStartPostId, msg);
|
|
1573
|
-
}
|
|
1574
|
-
catch (err) {
|
|
1575
|
-
console.error(' ⚠️ Failed to update session header:', err);
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
/** Request approval for a message from an unauthorized user */
|
|
1579
|
-
async requestMessageApproval(threadId, username, message) {
|
|
1580
|
-
const session = this.sessions.get(threadId);
|
|
1581
|
-
if (!session)
|
|
1582
|
-
return;
|
|
1583
|
-
// If there's already a pending message approval, ignore
|
|
1584
|
-
if (session.pendingMessageApproval) {
|
|
1585
|
-
return;
|
|
1586
|
-
}
|
|
1587
|
-
const preview = message.length > 100 ? message.substring(0, 100) + '…' : message;
|
|
1588
|
-
const post = await this.mattermost.createInteractivePost(`🔒 **@${username}** wants to send a message:\n> ${preview}\n\n` +
|
|
1589
|
-
`React 👍 to allow this message, ✅ to invite them to the session, 👎 to deny`, [APPROVAL_EMOJIS[0], ALLOW_ALL_EMOJIS[0], DENIAL_EMOJIS[0]], threadId);
|
|
1590
|
-
session.pendingMessageApproval = {
|
|
1591
|
-
postId: post.id,
|
|
1592
|
-
originalMessage: message,
|
|
1593
|
-
fromUser: username,
|
|
1594
|
-
};
|
|
1595
|
-
this.registerPost(post.id, threadId);
|
|
1596
|
-
}
|
|
1597
|
-
// ---------------------------------------------------------------------------
|
|
1598
|
-
// Worktree Management
|
|
1599
|
-
// ---------------------------------------------------------------------------
|
|
1600
|
-
/**
|
|
1601
|
-
* Handle a worktree branch response from user.
|
|
1602
|
-
* Called when user replies with a branch name to the worktree prompt.
|
|
1603
|
-
*/
|
|
1604
|
-
async handleWorktreeBranchResponse(threadId, branchName, username) {
|
|
1605
|
-
const session = this.sessions.get(threadId);
|
|
1606
|
-
if (!session || !session.pendingWorktreePrompt)
|
|
1607
|
-
return false;
|
|
1608
|
-
// Only session owner can respond
|
|
1609
|
-
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1610
|
-
return false;
|
|
1611
|
-
}
|
|
1612
|
-
// Validate branch name
|
|
1613
|
-
if (!isValidBranchName(branchName)) {
|
|
1614
|
-
await this.mattermost.createPost(`❌ Invalid branch name: \`${branchName}\`. Please provide a valid git branch name.`, threadId);
|
|
1615
|
-
return true; // We handled it, but need another response
|
|
1616
|
-
}
|
|
1617
|
-
// Create and switch to worktree
|
|
1618
|
-
await this.createAndSwitchToWorktree(threadId, branchName, username);
|
|
1619
|
-
return true;
|
|
1620
|
-
}
|
|
1621
|
-
/**
|
|
1622
|
-
* Handle ❌ reaction on worktree prompt - skip worktree and continue in main repo.
|
|
1623
|
-
*/
|
|
1624
|
-
async handleWorktreeSkip(threadId, username) {
|
|
1625
|
-
const session = this.sessions.get(threadId);
|
|
1626
|
-
if (!session || !session.pendingWorktreePrompt)
|
|
1627
|
-
return;
|
|
1628
|
-
// Only session owner can skip
|
|
1629
|
-
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
// Update the prompt post
|
|
1633
|
-
if (session.worktreePromptPostId) {
|
|
1634
|
-
try {
|
|
1635
|
-
await this.mattermost.updatePost(session.worktreePromptPostId, `✅ Continuing in main repo (skipped by @${username})`);
|
|
1636
|
-
}
|
|
1637
|
-
catch (err) {
|
|
1638
|
-
console.error(' ⚠️ Failed to update worktree prompt:', err);
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
// Clear pending state
|
|
1642
|
-
session.pendingWorktreePrompt = false;
|
|
1643
|
-
session.worktreePromptPostId = undefined;
|
|
1644
|
-
const queuedPrompt = session.queuedPrompt;
|
|
1645
|
-
session.queuedPrompt = undefined;
|
|
1646
|
-
// Persist updated state
|
|
1647
|
-
this.persistSession(session);
|
|
1648
|
-
// Now send the queued message to Claude
|
|
1649
|
-
if (queuedPrompt && session.claude.isRunning()) {
|
|
1650
|
-
session.claude.sendMessage(queuedPrompt);
|
|
1651
|
-
this.startTyping(session);
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
/**
|
|
1655
|
-
* Create a new worktree and switch the session to it.
|
|
1656
|
-
*/
|
|
1657
|
-
async createAndSwitchToWorktree(threadId, branch, username) {
|
|
1658
|
-
const session = this.sessions.get(threadId);
|
|
1659
|
-
if (!session)
|
|
1660
|
-
return;
|
|
1661
|
-
// Only session owner or admins can manage worktrees
|
|
1662
|
-
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1663
|
-
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
|
|
1664
|
-
return;
|
|
1665
|
-
}
|
|
1666
|
-
// Check if we're in a git repo
|
|
1667
|
-
const isRepo = await isGitRepository(session.workingDir);
|
|
1668
|
-
if (!isRepo) {
|
|
1669
|
-
await this.mattermost.createPost(`❌ Current directory is not a git repository`, threadId);
|
|
1670
|
-
return;
|
|
1671
|
-
}
|
|
1672
|
-
// Get repo root
|
|
1673
|
-
const repoRoot = await getRepositoryRoot(session.workingDir);
|
|
1674
|
-
// Check if worktree already exists for this branch
|
|
1675
|
-
const existing = await findWorktreeByBranch(repoRoot, branch);
|
|
1676
|
-
if (existing && !existing.isMain) {
|
|
1677
|
-
await this.mattermost.createPost(`⚠️ Worktree for branch \`${branch}\` already exists at \`${existing.path}\`. Use \`!worktree switch ${branch}\` to switch to it.`, threadId);
|
|
1678
|
-
return;
|
|
1679
|
-
}
|
|
1680
|
-
const shortId = threadId.substring(0, 8);
|
|
1681
|
-
console.log(` 🌿 Session (${shortId}…) creating worktree for branch ${branch}`);
|
|
1682
|
-
// Generate worktree path
|
|
1683
|
-
const worktreePath = getWorktreeDir(repoRoot, branch);
|
|
1684
|
-
try {
|
|
1685
|
-
// Create the worktree
|
|
1686
|
-
await createWorktree(repoRoot, branch, worktreePath);
|
|
1687
|
-
// Update the prompt post if it exists
|
|
1688
|
-
if (session.worktreePromptPostId) {
|
|
1689
|
-
try {
|
|
1690
|
-
await this.mattermost.updatePost(session.worktreePromptPostId, `✅ Created worktree for \`${branch}\``);
|
|
1691
|
-
}
|
|
1692
|
-
catch (err) {
|
|
1693
|
-
console.error(' ⚠️ Failed to update worktree prompt:', err);
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
// Clear pending state
|
|
1697
|
-
const wasPending = session.pendingWorktreePrompt;
|
|
1698
|
-
session.pendingWorktreePrompt = false;
|
|
1699
|
-
session.worktreePromptPostId = undefined;
|
|
1700
|
-
const queuedPrompt = session.queuedPrompt;
|
|
1701
|
-
session.queuedPrompt = undefined;
|
|
1702
|
-
// Store worktree info
|
|
1703
|
-
session.worktreeInfo = {
|
|
1704
|
-
repoRoot,
|
|
1705
|
-
worktreePath,
|
|
1706
|
-
branch,
|
|
1707
|
-
};
|
|
1708
|
-
// Update working directory
|
|
1709
|
-
session.workingDir = worktreePath;
|
|
1710
|
-
// If Claude is already running, restart it in the new directory
|
|
1711
|
-
if (session.claude.isRunning()) {
|
|
1712
|
-
this.stopTyping(session);
|
|
1713
|
-
session.isRestarting = true;
|
|
1714
|
-
session.claude.kill();
|
|
1715
|
-
// Flush any pending content
|
|
1716
|
-
await this.flush(session);
|
|
1717
|
-
session.currentPostId = null;
|
|
1718
|
-
session.pendingContent = '';
|
|
1719
|
-
// Generate new session ID for fresh start in new directory
|
|
1720
|
-
// (Claude CLI sessions are tied to working directory, can't resume across directories)
|
|
1721
|
-
const newSessionId = randomUUID();
|
|
1722
|
-
session.claudeSessionId = newSessionId;
|
|
1723
|
-
// Create new CLI with new working directory
|
|
1724
|
-
const cliOptions = {
|
|
1725
|
-
workingDir: worktreePath,
|
|
1726
|
-
threadId: threadId,
|
|
1727
|
-
skipPermissions: this.skipPermissions || !session.forceInteractivePermissions,
|
|
1728
|
-
sessionId: newSessionId,
|
|
1729
|
-
resume: false, // Fresh start - can't resume across directories
|
|
1730
|
-
chrome: this.chromeEnabled,
|
|
1731
|
-
};
|
|
1732
|
-
session.claude = new ClaudeCli(cliOptions);
|
|
1733
|
-
// Rebind event handlers
|
|
1734
|
-
session.claude.on('event', (e) => this.handleEvent(threadId, e));
|
|
1735
|
-
session.claude.on('exit', (code) => this.handleExit(threadId, code));
|
|
1736
|
-
// Start the new CLI
|
|
1737
|
-
session.claude.start();
|
|
1738
|
-
}
|
|
1739
|
-
// Update session header
|
|
1740
|
-
await this.updateSessionHeader(session);
|
|
1741
|
-
// Post confirmation
|
|
1742
|
-
const shortWorktreePath = worktreePath.replace(process.env.HOME || '', '~');
|
|
1743
|
-
await this.mattermost.createPost(`✅ **Created worktree** for branch \`${branch}\`\n📁 Working directory: \`${shortWorktreePath}\`\n*Claude Code restarted in the new worktree*`, threadId);
|
|
1744
|
-
// Update activity and persist
|
|
1745
|
-
session.lastActivityAt = new Date();
|
|
1746
|
-
session.timeoutWarningPosted = false;
|
|
1747
|
-
this.persistSession(session);
|
|
1748
|
-
// If there was a queued prompt (from initial session start), send it now
|
|
1749
|
-
if (wasPending && queuedPrompt && session.claude.isRunning()) {
|
|
1750
|
-
session.claude.sendMessage(queuedPrompt);
|
|
1751
|
-
this.startTyping(session);
|
|
1752
|
-
}
|
|
1753
|
-
console.log(` 🌿 Session (${shortId}…) switched to worktree ${branch} at ${shortWorktreePath}`);
|
|
1754
|
-
}
|
|
1755
|
-
catch (err) {
|
|
1756
|
-
console.error(` ❌ Failed to create worktree:`, err);
|
|
1757
|
-
await this.mattermost.createPost(`❌ Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`, threadId);
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
/**
|
|
1761
|
-
* Switch to an existing worktree.
|
|
1762
|
-
*/
|
|
1763
|
-
async switchToWorktree(threadId, branchOrPath, username) {
|
|
1764
|
-
const session = this.sessions.get(threadId);
|
|
1765
|
-
if (!session)
|
|
1766
|
-
return;
|
|
1767
|
-
// Only session owner or admins can manage worktrees
|
|
1768
|
-
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1769
|
-
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
|
|
1770
|
-
return;
|
|
1771
|
-
}
|
|
1772
|
-
// Get current repo root
|
|
1773
|
-
const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
|
|
1774
|
-
// Find the worktree
|
|
1775
|
-
const worktrees = await listWorktrees(repoRoot);
|
|
1776
|
-
const target = worktrees.find(wt => wt.branch === branchOrPath ||
|
|
1777
|
-
wt.path === branchOrPath ||
|
|
1778
|
-
wt.path.endsWith(branchOrPath));
|
|
1779
|
-
if (!target) {
|
|
1780
|
-
await this.mattermost.createPost(`❌ Worktree not found: \`${branchOrPath}\`. Use \`!worktree list\` to see available worktrees.`, threadId);
|
|
1781
|
-
return;
|
|
1782
|
-
}
|
|
1783
|
-
// Use changeDirectory logic to switch
|
|
1784
|
-
await this.changeDirectory(threadId, target.path, username);
|
|
1785
|
-
// Update worktree info
|
|
1786
|
-
session.worktreeInfo = {
|
|
1787
|
-
repoRoot,
|
|
1788
|
-
worktreePath: target.path,
|
|
1789
|
-
branch: target.branch,
|
|
1790
|
-
};
|
|
1791
|
-
// Update session header
|
|
1792
|
-
await this.updateSessionHeader(session);
|
|
1793
|
-
this.persistSession(session);
|
|
1794
|
-
}
|
|
1795
|
-
/**
|
|
1796
|
-
* List all worktrees for the current repository.
|
|
1797
|
-
*/
|
|
1798
|
-
async listWorktreesCommand(threadId, _username) {
|
|
1799
|
-
const session = this.sessions.get(threadId);
|
|
1800
|
-
if (!session)
|
|
1801
|
-
return;
|
|
1802
|
-
// Check if we're in a git repo
|
|
1803
|
-
const isRepo = await isGitRepository(session.workingDir);
|
|
1804
|
-
if (!isRepo) {
|
|
1805
|
-
await this.mattermost.createPost(`❌ Current directory is not a git repository`, threadId);
|
|
1806
|
-
return;
|
|
1807
|
-
}
|
|
1808
|
-
// Get repo root (either from worktree info or current dir)
|
|
1809
|
-
const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
|
|
1810
|
-
const worktrees = await listWorktrees(repoRoot);
|
|
1811
|
-
if (worktrees.length === 0) {
|
|
1812
|
-
await this.mattermost.createPost(`📋 No worktrees found for this repository`, threadId);
|
|
1813
|
-
return;
|
|
1814
|
-
}
|
|
1815
|
-
const shortRepoRoot = repoRoot.replace(process.env.HOME || '', '~');
|
|
1816
|
-
let message = `📋 **Worktrees for** \`${shortRepoRoot}\`:\n\n`;
|
|
1817
|
-
for (const wt of worktrees) {
|
|
1818
|
-
const shortPath = wt.path.replace(process.env.HOME || '', '~');
|
|
1819
|
-
const isCurrent = session.workingDir === wt.path;
|
|
1820
|
-
const marker = isCurrent ? ' ← current' : '';
|
|
1821
|
-
const label = wt.isMain ? '(main repository)' : '';
|
|
1822
|
-
message += `• \`${wt.branch}\` → \`${shortPath}\` ${label}${marker}\n`;
|
|
1823
|
-
}
|
|
1824
|
-
await this.mattermost.createPost(message, threadId);
|
|
1825
|
-
}
|
|
1826
|
-
/**
|
|
1827
|
-
* Remove a worktree.
|
|
1828
|
-
*/
|
|
1829
|
-
async removeWorktreeCommand(threadId, branchOrPath, username) {
|
|
1830
|
-
const session = this.sessions.get(threadId);
|
|
1831
|
-
if (!session)
|
|
1832
|
-
return;
|
|
1833
|
-
// Only session owner or admins can manage worktrees
|
|
1834
|
-
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1835
|
-
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
|
|
1836
|
-
return;
|
|
1837
|
-
}
|
|
1838
|
-
// Get current repo root
|
|
1839
|
-
const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
|
|
1840
|
-
// Find the worktree
|
|
1841
|
-
const worktrees = await listWorktrees(repoRoot);
|
|
1842
|
-
const target = worktrees.find(wt => wt.branch === branchOrPath ||
|
|
1843
|
-
wt.path === branchOrPath ||
|
|
1844
|
-
wt.path.endsWith(branchOrPath));
|
|
1845
|
-
if (!target) {
|
|
1846
|
-
await this.mattermost.createPost(`❌ Worktree not found: \`${branchOrPath}\`. Use \`!worktree list\` to see available worktrees.`, threadId);
|
|
1847
|
-
return;
|
|
1848
|
-
}
|
|
1849
|
-
// Can't remove the main repository
|
|
1850
|
-
if (target.isMain) {
|
|
1851
|
-
await this.mattermost.createPost(`❌ Cannot remove the main repository. Use \`!worktree remove\` only for worktrees.`, threadId);
|
|
1852
|
-
return;
|
|
1853
|
-
}
|
|
1854
|
-
// Can't remove the current working directory
|
|
1855
|
-
if (session.workingDir === target.path) {
|
|
1856
|
-
await this.mattermost.createPost(`❌ Cannot remove the current working directory. Switch to another worktree first.`, threadId);
|
|
1857
|
-
return;
|
|
1858
|
-
}
|
|
1859
|
-
try {
|
|
1860
|
-
await removeGitWorktree(repoRoot, target.path);
|
|
1861
|
-
const shortPath = target.path.replace(process.env.HOME || '', '~');
|
|
1862
|
-
await this.mattermost.createPost(`✅ Removed worktree \`${target.branch}\` at \`${shortPath}\``, threadId);
|
|
1863
|
-
console.log(` 🗑️ Removed worktree ${target.branch} at ${shortPath}`);
|
|
1864
|
-
}
|
|
1865
|
-
catch (err) {
|
|
1866
|
-
console.error(` ❌ Failed to remove worktree:`, err);
|
|
1867
|
-
await this.mattermost.createPost(`❌ Failed to remove worktree: ${err instanceof Error ? err.message : String(err)}`, threadId);
|
|
1868
|
-
}
|
|
1869
|
-
}
|
|
1870
|
-
/**
|
|
1871
|
-
* Disable worktree prompts for a session.
|
|
1872
|
-
*/
|
|
1873
|
-
async disableWorktreePrompt(threadId, username) {
|
|
1874
|
-
const session = this.sessions.get(threadId);
|
|
1875
|
-
if (!session)
|
|
1876
|
-
return;
|
|
1877
|
-
// Only session owner or admins can manage worktrees
|
|
1878
|
-
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1879
|
-
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
|
|
1880
|
-
return;
|
|
1881
|
-
}
|
|
1882
|
-
session.worktreePromptDisabled = true;
|
|
1883
|
-
this.persistSession(session);
|
|
1884
|
-
await this.mattermost.createPost(`✅ Worktree prompts disabled for this session`, threadId);
|
|
1885
|
-
}
|
|
1886
|
-
/**
|
|
1887
|
-
* Check if a session has a pending worktree prompt.
|
|
1888
|
-
*/
|
|
1889
|
-
hasPendingWorktreePrompt(threadId) {
|
|
1890
|
-
const session = this.sessions.get(threadId);
|
|
1891
|
-
return session?.pendingWorktreePrompt === true;
|
|
1892
|
-
}
|
|
1893
|
-
/**
|
|
1894
|
-
* Get the worktree prompt post ID for a session.
|
|
1895
|
-
*/
|
|
1896
|
-
getWorktreePromptPostId(threadId) {
|
|
1897
|
-
const session = this.sessions.get(threadId);
|
|
1898
|
-
return session?.worktreePromptPostId;
|
|
1899
|
-
}
|
|
1900
|
-
/** Kill all active sessions (for graceful shutdown) */
|
|
1901
|
-
killAllSessions() {
|
|
1902
|
-
console.log(` [shutdown] killAllSessions called, isShuttingDown already=${this.isShuttingDown}`);
|
|
1903
|
-
// Set shutdown flag to suppress exit messages (should already be true from setShuttingDown)
|
|
1904
|
-
this.isShuttingDown = true;
|
|
1905
|
-
const count = this.sessions.size;
|
|
1906
|
-
console.log(` [shutdown] About to kill ${count} session(s) (preserving persistence for resume)`);
|
|
1907
|
-
// Kill each session WITHOUT unpersisting - we want them to resume after restart
|
|
1908
|
-
for (const [threadId] of this.sessions.entries()) {
|
|
1909
|
-
this.killSession(threadId, false); // false = don't unpersist
|
|
1910
|
-
}
|
|
1911
|
-
// Maps should already be cleared by killSession, but clear again to be safe
|
|
1912
|
-
this.sessions.clear();
|
|
1913
|
-
this.postIndex.clear();
|
|
1914
|
-
if (this.cleanupTimer) {
|
|
1915
|
-
clearInterval(this.cleanupTimer);
|
|
1916
|
-
this.cleanupTimer = null;
|
|
1917
|
-
}
|
|
1918
|
-
if (count > 0) {
|
|
1919
|
-
console.log(` ✖ Killed ${count} session${count === 1 ? '' : 's'} (sessions preserved for resume)`);
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
/** Kill all sessions AND unpersist them (for emergency shutdown - no resume) */
|
|
1923
|
-
killAllSessionsAndUnpersist() {
|
|
1924
|
-
this.isShuttingDown = true;
|
|
1925
|
-
const count = this.sessions.size;
|
|
1926
|
-
// Kill each session WITH unpersisting - emergency shutdown, no resume
|
|
1927
|
-
for (const [threadId] of this.sessions.entries()) {
|
|
1928
|
-
this.killSession(threadId, true); // true = unpersist
|
|
1929
|
-
}
|
|
1930
|
-
this.sessions.clear();
|
|
1931
|
-
this.postIndex.clear();
|
|
1932
|
-
if (this.cleanupTimer) {
|
|
1933
|
-
clearInterval(this.cleanupTimer);
|
|
1934
|
-
this.cleanupTimer = null;
|
|
1935
|
-
}
|
|
1936
|
-
if (count > 0) {
|
|
1937
|
-
console.log(` 🔴 Emergency killed ${count} session${count === 1 ? '' : 's'} (sessions NOT preserved)`);
|
|
1938
|
-
}
|
|
1939
|
-
}
|
|
1940
|
-
/** Cleanup idle sessions that have exceeded timeout */
|
|
1941
|
-
cleanupIdleSessions() {
|
|
1942
|
-
const now = Date.now();
|
|
1943
|
-
const warningThreshold = SESSION_TIMEOUT_MS - SESSION_WARNING_MS;
|
|
1944
|
-
for (const [threadId, session] of this.sessions.entries()) {
|
|
1945
|
-
const idleTime = now - session.lastActivityAt.getTime();
|
|
1946
|
-
// Check if we should time out
|
|
1947
|
-
if (idleTime > SESSION_TIMEOUT_MS) {
|
|
1948
|
-
const mins = Math.round(idleTime / 60000);
|
|
1949
|
-
const shortId = threadId.substring(0, 8);
|
|
1950
|
-
console.log(` ⏰ Session (${shortId}…) timed out after ${mins}m idle`);
|
|
1951
|
-
this.mattermost.createPost(`⏰ **Session timed out** — no activity for ${mins} minutes`, session.threadId).catch(() => { });
|
|
1952
|
-
this.killSession(threadId);
|
|
1953
|
-
}
|
|
1954
|
-
// Check if we should show warning (only once)
|
|
1955
|
-
else if (idleTime > warningThreshold && !session.timeoutWarningPosted) {
|
|
1956
|
-
const remainingMins = Math.round((SESSION_TIMEOUT_MS - idleTime) / 60000);
|
|
1957
|
-
const shortId = threadId.substring(0, 8);
|
|
1958
|
-
console.log(` ⚠️ Session (${shortId}…) warning: ${remainingMins}m until timeout`);
|
|
1959
|
-
this.mattermost.createPost(`⚠️ **Session idle** — will time out in ~${remainingMins} minutes. Send a message to keep it alive.`, session.threadId).catch(() => { });
|
|
1960
|
-
session.timeoutWarningPosted = true;
|
|
1961
|
-
}
|
|
1962
|
-
}
|
|
1963
|
-
}
|
|
1964
|
-
}
|