mattermost-claude-code 0.10.10 → 0.11.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 +23 -0
- package/dist/claude/session.d.ts +67 -2
- package/dist/claude/session.js +471 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +15 -0
- package/dist/git/worktree.d.ts +50 -0
- package/dist/git/worktree.js +228 -0
- package/dist/index.js +58 -1
- package/dist/mcp/permission-server.js +5 -2
- package/dist/persistence/session-store.d.ts +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.0] - 2025-12-28
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Git worktree support** - Isolate file changes between concurrent sessions
|
|
14
|
+
- Smart detection prompts for a branch when uncommitted changes or concurrent sessions exist
|
|
15
|
+
- Reply with a branch name to create a worktree, or react with ❌ to skip
|
|
16
|
+
- Inline syntax: `@bot on branch feature/x help me implement...`
|
|
17
|
+
- `!worktree <branch>` - Create and switch to a git worktree
|
|
18
|
+
- `!worktree list` - List all worktrees for the repo
|
|
19
|
+
- `!worktree switch <branch>` - Switch to an existing worktree
|
|
20
|
+
- `!worktree remove <branch>` - Remove a worktree
|
|
21
|
+
- `!worktree off` - Disable worktree prompts for this session
|
|
22
|
+
- Configure via `WORKTREE_MODE=off|prompt|require` (default: `prompt`)
|
|
23
|
+
- Worktrees persist after session ends (manual cleanup)
|
|
24
|
+
- Session header shows worktree info when active
|
|
25
|
+
|
|
26
|
+
## [0.10.11] - 2025-12-28
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **Permission prompts now update after approval/denial** - Shows result inline
|
|
30
|
+
- "⚠️ Permission requested" → "✅ Allowed by @user" or "❌ Denied by @user"
|
|
31
|
+
- Consistent with plan approval and message approval behavior
|
|
32
|
+
|
|
10
33
|
## [0.10.10] - 2025-12-28
|
|
11
34
|
|
|
12
35
|
### Fixed
|
package/dist/claude/session.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { ClaudeCli } from './cli.js';
|
|
2
2
|
import { MattermostClient } from '../mattermost/client.js';
|
|
3
3
|
import { MattermostFile } from '../mattermost/types.js';
|
|
4
|
-
import { PersistedSession } from '../persistence/session-store.js';
|
|
4
|
+
import { PersistedSession, WorktreeInfo } from '../persistence/session-store.js';
|
|
5
|
+
import { WorktreeMode } from '../config.js';
|
|
5
6
|
interface QuestionOption {
|
|
6
7
|
label: string;
|
|
7
8
|
description: string;
|
|
@@ -62,19 +63,25 @@ interface Session {
|
|
|
62
63
|
wasInterrupted: boolean;
|
|
63
64
|
inProgressTaskStart: number | null;
|
|
64
65
|
activeToolStarts: Map<string, number>;
|
|
66
|
+
worktreeInfo?: WorktreeInfo;
|
|
67
|
+
pendingWorktreePrompt?: boolean;
|
|
68
|
+
worktreePromptDisabled?: boolean;
|
|
69
|
+
queuedPrompt?: string;
|
|
70
|
+
worktreePromptPostId?: string;
|
|
65
71
|
}
|
|
66
72
|
export declare class SessionManager {
|
|
67
73
|
private mattermost;
|
|
68
74
|
private workingDir;
|
|
69
75
|
private skipPermissions;
|
|
70
76
|
private chromeEnabled;
|
|
77
|
+
private worktreeMode;
|
|
71
78
|
private debug;
|
|
72
79
|
private sessions;
|
|
73
80
|
private postIndex;
|
|
74
81
|
private sessionStore;
|
|
75
82
|
private cleanupTimer;
|
|
76
83
|
private isShuttingDown;
|
|
77
|
-
constructor(mattermost: MattermostClient, workingDir: string, skipPermissions?: boolean, chromeEnabled?: boolean);
|
|
84
|
+
constructor(mattermost: MattermostClient, workingDir: string, skipPermissions?: boolean, chromeEnabled?: boolean, worktreeMode?: WorktreeMode);
|
|
78
85
|
/**
|
|
79
86
|
* Initialize session manager by resuming any persisted sessions.
|
|
80
87
|
* Should be called before starting to listen for new messages.
|
|
@@ -115,6 +122,27 @@ export declare class SessionManager {
|
|
|
115
122
|
prompt: string;
|
|
116
123
|
files?: MattermostFile[];
|
|
117
124
|
}, username: string, replyToPostId?: string): Promise<void>;
|
|
125
|
+
/**
|
|
126
|
+
* Start a session with an initial worktree specified.
|
|
127
|
+
* Used when user specifies "on branch X" or "!worktree X" in their initial message.
|
|
128
|
+
*/
|
|
129
|
+
startSessionWithWorktree(options: {
|
|
130
|
+
prompt: string;
|
|
131
|
+
files?: MattermostFile[];
|
|
132
|
+
}, branch: string, username: string, replyToPostId?: string): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* Check if we should prompt for a worktree before starting work.
|
|
135
|
+
* Returns the reason string if we should prompt, or null if not.
|
|
136
|
+
*/
|
|
137
|
+
private shouldPromptForWorktree;
|
|
138
|
+
/**
|
|
139
|
+
* Check if another session is using the same repository
|
|
140
|
+
*/
|
|
141
|
+
private hasOtherSessionInRepo;
|
|
142
|
+
/**
|
|
143
|
+
* Post the worktree prompt message
|
|
144
|
+
*/
|
|
145
|
+
private postWorktreePrompt;
|
|
118
146
|
private handleEvent;
|
|
119
147
|
private handleTaskComplete;
|
|
120
148
|
private handleExitPlanMode;
|
|
@@ -181,6 +209,43 @@ export declare class SessionManager {
|
|
|
181
209
|
private updateSessionHeader;
|
|
182
210
|
/** Request approval for a message from an unauthorized user */
|
|
183
211
|
requestMessageApproval(threadId: string, username: string, message: string): Promise<void>;
|
|
212
|
+
/**
|
|
213
|
+
* Handle a worktree branch response from user.
|
|
214
|
+
* Called when user replies with a branch name to the worktree prompt.
|
|
215
|
+
*/
|
|
216
|
+
handleWorktreeBranchResponse(threadId: string, branchName: string, username: string): Promise<boolean>;
|
|
217
|
+
/**
|
|
218
|
+
* Handle ❌ reaction on worktree prompt - skip worktree and continue in main repo.
|
|
219
|
+
*/
|
|
220
|
+
handleWorktreeSkip(threadId: string, username: string): Promise<void>;
|
|
221
|
+
/**
|
|
222
|
+
* Create a new worktree and switch the session to it.
|
|
223
|
+
*/
|
|
224
|
+
createAndSwitchToWorktree(threadId: string, branch: string, username: string): Promise<void>;
|
|
225
|
+
/**
|
|
226
|
+
* Switch to an existing worktree.
|
|
227
|
+
*/
|
|
228
|
+
switchToWorktree(threadId: string, branchOrPath: string, username: string): Promise<void>;
|
|
229
|
+
/**
|
|
230
|
+
* List all worktrees for the current repository.
|
|
231
|
+
*/
|
|
232
|
+
listWorktreesCommand(threadId: string, _username: string): Promise<void>;
|
|
233
|
+
/**
|
|
234
|
+
* Remove a worktree.
|
|
235
|
+
*/
|
|
236
|
+
removeWorktreeCommand(threadId: string, branchOrPath: string, username: string): Promise<void>;
|
|
237
|
+
/**
|
|
238
|
+
* Disable worktree prompts for a session.
|
|
239
|
+
*/
|
|
240
|
+
disableWorktreePrompt(threadId: string, username: string): Promise<void>;
|
|
241
|
+
/**
|
|
242
|
+
* Check if a session has a pending worktree prompt.
|
|
243
|
+
*/
|
|
244
|
+
hasPendingWorktreePrompt(threadId: string): boolean;
|
|
245
|
+
/**
|
|
246
|
+
* Get the worktree prompt post ID for a session.
|
|
247
|
+
*/
|
|
248
|
+
getWorktreePromptPostId(threadId: string): string | undefined;
|
|
184
249
|
/** Kill all active sessions (for graceful shutdown) */
|
|
185
250
|
killAllSessions(): void;
|
|
186
251
|
/** Kill all sessions AND unpersist them (for emergency shutdown - no resume) */
|
package/dist/claude/session.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getUpdateInfo } from '../update-notifier.js';
|
|
|
5
5
|
import { getReleaseNotes, getWhatsNewSummary } from '../changelog.js';
|
|
6
6
|
import { SessionStore } from '../persistence/session-store.js';
|
|
7
7
|
import { getMattermostLogo } from '../logo.js';
|
|
8
|
+
import { isGitRepository, getRepositoryRoot, hasUncommittedChanges, listWorktrees, createWorktree, removeWorktree as removeGitWorktree, getWorktreeDir, findWorktreeByBranch, isValidBranchName, } from '../git/worktree.js';
|
|
8
9
|
import { randomUUID } from 'crypto';
|
|
9
10
|
import { readFileSync } from 'fs';
|
|
10
11
|
import { dirname, resolve } from 'path';
|
|
@@ -26,6 +27,7 @@ export class SessionManager {
|
|
|
26
27
|
workingDir;
|
|
27
28
|
skipPermissions;
|
|
28
29
|
chromeEnabled;
|
|
30
|
+
worktreeMode;
|
|
29
31
|
debug = process.env.DEBUG === '1' || process.argv.includes('--debug');
|
|
30
32
|
// Multi-session storage
|
|
31
33
|
sessions = new Map(); // threadId -> Session
|
|
@@ -36,11 +38,12 @@ export class SessionManager {
|
|
|
36
38
|
cleanupTimer = null;
|
|
37
39
|
// Shutdown flag to suppress exit messages during graceful shutdown
|
|
38
40
|
isShuttingDown = false;
|
|
39
|
-
constructor(mattermost, workingDir, skipPermissions = false, chromeEnabled = false) {
|
|
41
|
+
constructor(mattermost, workingDir, skipPermissions = false, chromeEnabled = false, worktreeMode = 'prompt') {
|
|
40
42
|
this.mattermost = mattermost;
|
|
41
43
|
this.workingDir = workingDir;
|
|
42
44
|
this.skipPermissions = skipPermissions;
|
|
43
45
|
this.chromeEnabled = chromeEnabled;
|
|
46
|
+
this.worktreeMode = worktreeMode;
|
|
44
47
|
// Listen for reactions to answer questions
|
|
45
48
|
this.mattermost.on('reaction', async (reaction, user) => {
|
|
46
49
|
try {
|
|
@@ -146,6 +149,11 @@ export class SessionManager {
|
|
|
146
149
|
wasInterrupted: false,
|
|
147
150
|
inProgressTaskStart: null,
|
|
148
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,
|
|
149
157
|
};
|
|
150
158
|
// Register session
|
|
151
159
|
this.sessions.set(state.threadId, session);
|
|
@@ -197,6 +205,11 @@ export class SessionManager {
|
|
|
197
205
|
tasksPostId: session.tasksPostId,
|
|
198
206
|
lastActivityAt: session.lastActivityAt.toISOString(),
|
|
199
207
|
planApproved: session.planApproved,
|
|
208
|
+
// Worktree state
|
|
209
|
+
worktreeInfo: session.worktreeInfo,
|
|
210
|
+
pendingWorktreePrompt: session.pendingWorktreePrompt,
|
|
211
|
+
worktreePromptDisabled: session.worktreePromptDisabled,
|
|
212
|
+
queuedPrompt: session.queuedPrompt,
|
|
200
213
|
};
|
|
201
214
|
this.sessionStore.save(session.threadId, state);
|
|
202
215
|
console.log(` [persist] Saved session ${shortId}... (claudeId: ${session.claudeSessionId.substring(0, 8)}...)`);
|
|
@@ -348,12 +361,132 @@ export class SessionManager {
|
|
|
348
361
|
this.sessions.delete(actualThreadId);
|
|
349
362
|
return;
|
|
350
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
|
+
}
|
|
351
375
|
// Send the message to Claude (with images if present)
|
|
352
376
|
const content = await this.buildMessageContent(options.prompt, options.files);
|
|
353
377
|
claude.sendMessage(content);
|
|
354
378
|
// Persist session for resume after restart
|
|
355
379
|
this.persistSession(session);
|
|
356
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
|
+
const reactionOptions = reason === 'require' ? [] : ['❌'];
|
|
483
|
+
const post = await this.mattermost.createInteractivePost(message, reactionOptions, session.threadId);
|
|
484
|
+
// Track the post for reaction handling
|
|
485
|
+
session.worktreePromptPostId = post.id;
|
|
486
|
+
this.registerPost(post.id, session.threadId);
|
|
487
|
+
// Stop typing while waiting for response
|
|
488
|
+
this.stopTyping(session);
|
|
489
|
+
}
|
|
357
490
|
handleEvent(threadId, event) {
|
|
358
491
|
const session = this.sessions.get(threadId);
|
|
359
492
|
if (!session)
|
|
@@ -602,6 +735,12 @@ export class SessionManager {
|
|
|
602
735
|
const session = this.getSessionByPost(postId);
|
|
603
736
|
if (!session)
|
|
604
737
|
return;
|
|
738
|
+
// Handle ❌ on worktree prompt (skip worktree, continue in main repo)
|
|
739
|
+
// Must be checked BEFORE cancel reaction handler since ❌ is also a cancel emoji
|
|
740
|
+
if (session.worktreePromptPostId === postId && emojiName === 'x') {
|
|
741
|
+
await this.handleWorktreeSkip(session.threadId, username);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
605
744
|
// Handle cancel reactions (❌ or 🛑) on any post in the session
|
|
606
745
|
if (isCancelEmoji(emojiName)) {
|
|
607
746
|
await this.cancelSession(session.threadId, username);
|
|
@@ -911,7 +1050,34 @@ export class SessionManager {
|
|
|
911
1050
|
let content = session.pendingContent.replace(/\n{3,}/g, '\n\n').trim();
|
|
912
1051
|
// Mattermost has a 16,383 character limit for posts
|
|
913
1052
|
const MAX_POST_LENGTH = 16000; // Leave some margin
|
|
1053
|
+
const CONTINUATION_THRESHOLD = 14000; // Start new message before we hit the limit
|
|
1054
|
+
// Check if we need to start a new message due to length
|
|
1055
|
+
if (session.currentPostId && content.length > CONTINUATION_THRESHOLD) {
|
|
1056
|
+
// Finalize the current post with what we have up to the threshold
|
|
1057
|
+
// Find a good break point (end of line) near the threshold
|
|
1058
|
+
let breakPoint = content.lastIndexOf('\n', CONTINUATION_THRESHOLD);
|
|
1059
|
+
if (breakPoint < CONTINUATION_THRESHOLD * 0.7) {
|
|
1060
|
+
// If we can't find a good line break, just break at the threshold
|
|
1061
|
+
breakPoint = CONTINUATION_THRESHOLD;
|
|
1062
|
+
}
|
|
1063
|
+
const firstPart = content.substring(0, breakPoint).trim() + '\n\n*... (continued below)*';
|
|
1064
|
+
const remainder = content.substring(breakPoint).trim();
|
|
1065
|
+
// Update the current post with the first part
|
|
1066
|
+
await this.mattermost.updatePost(session.currentPostId, firstPart);
|
|
1067
|
+
// Start a new post for the continuation
|
|
1068
|
+
session.currentPostId = null;
|
|
1069
|
+
session.pendingContent = remainder;
|
|
1070
|
+
// Create the continuation post if there's content
|
|
1071
|
+
if (remainder) {
|
|
1072
|
+
const post = await this.mattermost.createPost('*(continued)*\n\n' + remainder, session.threadId);
|
|
1073
|
+
session.currentPostId = post.id;
|
|
1074
|
+
this.registerPost(post.id, session.threadId);
|
|
1075
|
+
}
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
// Normal case: content fits in current post
|
|
914
1079
|
if (content.length > MAX_POST_LENGTH) {
|
|
1080
|
+
// Safety truncation if we somehow got content that's still too long
|
|
915
1081
|
content = content.substring(0, MAX_POST_LENGTH - 50) + '\n\n*... (truncated)*';
|
|
916
1082
|
}
|
|
917
1083
|
if (session.currentPostId) {
|
|
@@ -1369,6 +1535,11 @@ export class SessionManager {
|
|
|
1369
1535
|
`| 📂 **Directory** | \`${shortDir}\` |`,
|
|
1370
1536
|
`| 👤 **Started by** | @${session.startedBy} |`,
|
|
1371
1537
|
];
|
|
1538
|
+
// Show worktree info if active
|
|
1539
|
+
if (session.worktreeInfo) {
|
|
1540
|
+
const shortRepoRoot = session.worktreeInfo.repoRoot.replace(process.env.HOME || '', '~');
|
|
1541
|
+
rows.push(`| 🌿 **Worktree** | \`${session.worktreeInfo.branch}\` (from \`${shortRepoRoot}\`) |`);
|
|
1542
|
+
}
|
|
1372
1543
|
if (otherParticipants) {
|
|
1373
1544
|
rows.push(`| 👥 **Participants** | ${otherParticipants} |`);
|
|
1374
1545
|
}
|
|
@@ -1420,6 +1591,305 @@ export class SessionManager {
|
|
|
1420
1591
|
};
|
|
1421
1592
|
this.registerPost(post.id, threadId);
|
|
1422
1593
|
}
|
|
1594
|
+
// ---------------------------------------------------------------------------
|
|
1595
|
+
// Worktree Management
|
|
1596
|
+
// ---------------------------------------------------------------------------
|
|
1597
|
+
/**
|
|
1598
|
+
* Handle a worktree branch response from user.
|
|
1599
|
+
* Called when user replies with a branch name to the worktree prompt.
|
|
1600
|
+
*/
|
|
1601
|
+
async handleWorktreeBranchResponse(threadId, branchName, username) {
|
|
1602
|
+
const session = this.sessions.get(threadId);
|
|
1603
|
+
if (!session || !session.pendingWorktreePrompt)
|
|
1604
|
+
return false;
|
|
1605
|
+
// Only session owner can respond
|
|
1606
|
+
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
// Validate branch name
|
|
1610
|
+
if (!isValidBranchName(branchName)) {
|
|
1611
|
+
await this.mattermost.createPost(`❌ Invalid branch name: \`${branchName}\`. Please provide a valid git branch name.`, threadId);
|
|
1612
|
+
return true; // We handled it, but need another response
|
|
1613
|
+
}
|
|
1614
|
+
// Create and switch to worktree
|
|
1615
|
+
await this.createAndSwitchToWorktree(threadId, branchName, username);
|
|
1616
|
+
return true;
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Handle ❌ reaction on worktree prompt - skip worktree and continue in main repo.
|
|
1620
|
+
*/
|
|
1621
|
+
async handleWorktreeSkip(threadId, username) {
|
|
1622
|
+
const session = this.sessions.get(threadId);
|
|
1623
|
+
if (!session || !session.pendingWorktreePrompt)
|
|
1624
|
+
return;
|
|
1625
|
+
// Only session owner can skip
|
|
1626
|
+
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
// Update the prompt post
|
|
1630
|
+
if (session.worktreePromptPostId) {
|
|
1631
|
+
try {
|
|
1632
|
+
await this.mattermost.updatePost(session.worktreePromptPostId, `✅ Continuing in main repo (skipped by @${username})`);
|
|
1633
|
+
}
|
|
1634
|
+
catch (err) {
|
|
1635
|
+
console.error(' ⚠️ Failed to update worktree prompt:', err);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
// Clear pending state
|
|
1639
|
+
session.pendingWorktreePrompt = false;
|
|
1640
|
+
session.worktreePromptPostId = undefined;
|
|
1641
|
+
const queuedPrompt = session.queuedPrompt;
|
|
1642
|
+
session.queuedPrompt = undefined;
|
|
1643
|
+
// Persist updated state
|
|
1644
|
+
this.persistSession(session);
|
|
1645
|
+
// Now send the queued message to Claude
|
|
1646
|
+
if (queuedPrompt && session.claude.isRunning()) {
|
|
1647
|
+
session.claude.sendMessage(queuedPrompt);
|
|
1648
|
+
this.startTyping(session);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Create a new worktree and switch the session to it.
|
|
1653
|
+
*/
|
|
1654
|
+
async createAndSwitchToWorktree(threadId, branch, username) {
|
|
1655
|
+
const session = this.sessions.get(threadId);
|
|
1656
|
+
if (!session)
|
|
1657
|
+
return;
|
|
1658
|
+
// Only session owner or admins can manage worktrees
|
|
1659
|
+
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1660
|
+
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
// Check if we're in a git repo
|
|
1664
|
+
const isRepo = await isGitRepository(session.workingDir);
|
|
1665
|
+
if (!isRepo) {
|
|
1666
|
+
await this.mattermost.createPost(`❌ Current directory is not a git repository`, threadId);
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
// Get repo root
|
|
1670
|
+
const repoRoot = await getRepositoryRoot(session.workingDir);
|
|
1671
|
+
// Check if worktree already exists for this branch
|
|
1672
|
+
const existing = await findWorktreeByBranch(repoRoot, branch);
|
|
1673
|
+
if (existing && !existing.isMain) {
|
|
1674
|
+
await this.mattermost.createPost(`⚠️ Worktree for branch \`${branch}\` already exists at \`${existing.path}\`. Use \`!worktree switch ${branch}\` to switch to it.`, threadId);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const shortId = threadId.substring(0, 8);
|
|
1678
|
+
console.log(` 🌿 Session (${shortId}…) creating worktree for branch ${branch}`);
|
|
1679
|
+
// Generate worktree path
|
|
1680
|
+
const worktreePath = getWorktreeDir(repoRoot, branch);
|
|
1681
|
+
try {
|
|
1682
|
+
// Create the worktree
|
|
1683
|
+
await createWorktree(repoRoot, branch, worktreePath);
|
|
1684
|
+
// Update the prompt post if it exists
|
|
1685
|
+
if (session.worktreePromptPostId) {
|
|
1686
|
+
try {
|
|
1687
|
+
await this.mattermost.updatePost(session.worktreePromptPostId, `✅ Created worktree for \`${branch}\``);
|
|
1688
|
+
}
|
|
1689
|
+
catch (err) {
|
|
1690
|
+
console.error(' ⚠️ Failed to update worktree prompt:', err);
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
// Clear pending state
|
|
1694
|
+
const wasPending = session.pendingWorktreePrompt;
|
|
1695
|
+
session.pendingWorktreePrompt = false;
|
|
1696
|
+
session.worktreePromptPostId = undefined;
|
|
1697
|
+
const queuedPrompt = session.queuedPrompt;
|
|
1698
|
+
session.queuedPrompt = undefined;
|
|
1699
|
+
// Store worktree info
|
|
1700
|
+
session.worktreeInfo = {
|
|
1701
|
+
repoRoot,
|
|
1702
|
+
worktreePath,
|
|
1703
|
+
branch,
|
|
1704
|
+
};
|
|
1705
|
+
// Update working directory
|
|
1706
|
+
session.workingDir = worktreePath;
|
|
1707
|
+
// If Claude is already running, restart it in the new directory
|
|
1708
|
+
if (session.claude.isRunning()) {
|
|
1709
|
+
this.stopTyping(session);
|
|
1710
|
+
session.isRestarting = true;
|
|
1711
|
+
session.claude.kill();
|
|
1712
|
+
// Flush any pending content
|
|
1713
|
+
await this.flush(session);
|
|
1714
|
+
session.currentPostId = null;
|
|
1715
|
+
session.pendingContent = '';
|
|
1716
|
+
// Create new CLI with new working directory
|
|
1717
|
+
const cliOptions = {
|
|
1718
|
+
workingDir: worktreePath,
|
|
1719
|
+
threadId: threadId,
|
|
1720
|
+
skipPermissions: this.skipPermissions || !session.forceInteractivePermissions,
|
|
1721
|
+
sessionId: session.claudeSessionId,
|
|
1722
|
+
resume: true,
|
|
1723
|
+
chrome: this.chromeEnabled,
|
|
1724
|
+
};
|
|
1725
|
+
session.claude = new ClaudeCli(cliOptions);
|
|
1726
|
+
// Rebind event handlers
|
|
1727
|
+
session.claude.on('event', (e) => this.handleEvent(threadId, e));
|
|
1728
|
+
session.claude.on('exit', (code) => this.handleExit(threadId, code));
|
|
1729
|
+
// Start the new CLI
|
|
1730
|
+
session.claude.start();
|
|
1731
|
+
}
|
|
1732
|
+
// Update session header
|
|
1733
|
+
await this.updateSessionHeader(session);
|
|
1734
|
+
// Post confirmation
|
|
1735
|
+
const shortWorktreePath = worktreePath.replace(process.env.HOME || '', '~');
|
|
1736
|
+
await this.mattermost.createPost(`✅ **Created worktree** for branch \`${branch}\`\n📁 Working directory: \`${shortWorktreePath}\`\n*Claude Code restarted in the new worktree*`, threadId);
|
|
1737
|
+
// Update activity and persist
|
|
1738
|
+
session.lastActivityAt = new Date();
|
|
1739
|
+
session.timeoutWarningPosted = false;
|
|
1740
|
+
this.persistSession(session);
|
|
1741
|
+
// If there was a queued prompt (from initial session start), send it now
|
|
1742
|
+
if (wasPending && queuedPrompt && session.claude.isRunning()) {
|
|
1743
|
+
session.claude.sendMessage(queuedPrompt);
|
|
1744
|
+
this.startTyping(session);
|
|
1745
|
+
}
|
|
1746
|
+
console.log(` 🌿 Session (${shortId}…) switched to worktree ${branch} at ${shortWorktreePath}`);
|
|
1747
|
+
}
|
|
1748
|
+
catch (err) {
|
|
1749
|
+
console.error(` ❌ Failed to create worktree:`, err);
|
|
1750
|
+
await this.mattermost.createPost(`❌ Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`, threadId);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
/**
|
|
1754
|
+
* Switch to an existing worktree.
|
|
1755
|
+
*/
|
|
1756
|
+
async switchToWorktree(threadId, branchOrPath, username) {
|
|
1757
|
+
const session = this.sessions.get(threadId);
|
|
1758
|
+
if (!session)
|
|
1759
|
+
return;
|
|
1760
|
+
// Only session owner or admins can manage worktrees
|
|
1761
|
+
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1762
|
+
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
// Get current repo root
|
|
1766
|
+
const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
|
|
1767
|
+
// Find the worktree
|
|
1768
|
+
const worktrees = await listWorktrees(repoRoot);
|
|
1769
|
+
const target = worktrees.find(wt => wt.branch === branchOrPath ||
|
|
1770
|
+
wt.path === branchOrPath ||
|
|
1771
|
+
wt.path.endsWith(branchOrPath));
|
|
1772
|
+
if (!target) {
|
|
1773
|
+
await this.mattermost.createPost(`❌ Worktree not found: \`${branchOrPath}\`. Use \`!worktree list\` to see available worktrees.`, threadId);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
// Use changeDirectory logic to switch
|
|
1777
|
+
await this.changeDirectory(threadId, target.path, username);
|
|
1778
|
+
// Update worktree info
|
|
1779
|
+
session.worktreeInfo = {
|
|
1780
|
+
repoRoot,
|
|
1781
|
+
worktreePath: target.path,
|
|
1782
|
+
branch: target.branch,
|
|
1783
|
+
};
|
|
1784
|
+
// Update session header
|
|
1785
|
+
await this.updateSessionHeader(session);
|
|
1786
|
+
this.persistSession(session);
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* List all worktrees for the current repository.
|
|
1790
|
+
*/
|
|
1791
|
+
async listWorktreesCommand(threadId, _username) {
|
|
1792
|
+
const session = this.sessions.get(threadId);
|
|
1793
|
+
if (!session)
|
|
1794
|
+
return;
|
|
1795
|
+
// Check if we're in a git repo
|
|
1796
|
+
const isRepo = await isGitRepository(session.workingDir);
|
|
1797
|
+
if (!isRepo) {
|
|
1798
|
+
await this.mattermost.createPost(`❌ Current directory is not a git repository`, threadId);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
// Get repo root (either from worktree info or current dir)
|
|
1802
|
+
const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
|
|
1803
|
+
const worktrees = await listWorktrees(repoRoot);
|
|
1804
|
+
if (worktrees.length === 0) {
|
|
1805
|
+
await this.mattermost.createPost(`📋 No worktrees found for this repository`, threadId);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
const shortRepoRoot = repoRoot.replace(process.env.HOME || '', '~');
|
|
1809
|
+
let message = `📋 **Worktrees for** \`${shortRepoRoot}\`:\n\n`;
|
|
1810
|
+
for (const wt of worktrees) {
|
|
1811
|
+
const shortPath = wt.path.replace(process.env.HOME || '', '~');
|
|
1812
|
+
const isCurrent = session.workingDir === wt.path;
|
|
1813
|
+
const marker = isCurrent ? ' ← current' : '';
|
|
1814
|
+
const label = wt.isMain ? '(main repository)' : '';
|
|
1815
|
+
message += `• \`${wt.branch}\` → \`${shortPath}\` ${label}${marker}\n`;
|
|
1816
|
+
}
|
|
1817
|
+
await this.mattermost.createPost(message, threadId);
|
|
1818
|
+
}
|
|
1819
|
+
/**
|
|
1820
|
+
* Remove a worktree.
|
|
1821
|
+
*/
|
|
1822
|
+
async removeWorktreeCommand(threadId, branchOrPath, username) {
|
|
1823
|
+
const session = this.sessions.get(threadId);
|
|
1824
|
+
if (!session)
|
|
1825
|
+
return;
|
|
1826
|
+
// Only session owner or admins can manage worktrees
|
|
1827
|
+
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1828
|
+
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
// Get current repo root
|
|
1832
|
+
const repoRoot = session.worktreeInfo?.repoRoot || await getRepositoryRoot(session.workingDir);
|
|
1833
|
+
// Find the worktree
|
|
1834
|
+
const worktrees = await listWorktrees(repoRoot);
|
|
1835
|
+
const target = worktrees.find(wt => wt.branch === branchOrPath ||
|
|
1836
|
+
wt.path === branchOrPath ||
|
|
1837
|
+
wt.path.endsWith(branchOrPath));
|
|
1838
|
+
if (!target) {
|
|
1839
|
+
await this.mattermost.createPost(`❌ Worktree not found: \`${branchOrPath}\`. Use \`!worktree list\` to see available worktrees.`, threadId);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
// Can't remove the main repository
|
|
1843
|
+
if (target.isMain) {
|
|
1844
|
+
await this.mattermost.createPost(`❌ Cannot remove the main repository. Use \`!worktree remove\` only for worktrees.`, threadId);
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
// Can't remove the current working directory
|
|
1848
|
+
if (session.workingDir === target.path) {
|
|
1849
|
+
await this.mattermost.createPost(`❌ Cannot remove the current working directory. Switch to another worktree first.`, threadId);
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
try {
|
|
1853
|
+
await removeGitWorktree(repoRoot, target.path);
|
|
1854
|
+
const shortPath = target.path.replace(process.env.HOME || '', '~');
|
|
1855
|
+
await this.mattermost.createPost(`✅ Removed worktree \`${target.branch}\` at \`${shortPath}\``, threadId);
|
|
1856
|
+
console.log(` 🗑️ Removed worktree ${target.branch} at ${shortPath}`);
|
|
1857
|
+
}
|
|
1858
|
+
catch (err) {
|
|
1859
|
+
console.error(` ❌ Failed to remove worktree:`, err);
|
|
1860
|
+
await this.mattermost.createPost(`❌ Failed to remove worktree: ${err instanceof Error ? err.message : String(err)}`, threadId);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Disable worktree prompts for a session.
|
|
1865
|
+
*/
|
|
1866
|
+
async disableWorktreePrompt(threadId, username) {
|
|
1867
|
+
const session = this.sessions.get(threadId);
|
|
1868
|
+
if (!session)
|
|
1869
|
+
return;
|
|
1870
|
+
// Only session owner or admins can manage worktrees
|
|
1871
|
+
if (session.startedBy !== username && !this.mattermost.isUserAllowed(username)) {
|
|
1872
|
+
await this.mattermost.createPost(`⚠️ Only @${session.startedBy} or allowed users can manage worktrees`, threadId);
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
session.worktreePromptDisabled = true;
|
|
1876
|
+
this.persistSession(session);
|
|
1877
|
+
await this.mattermost.createPost(`✅ Worktree prompts disabled for this session`, threadId);
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Check if a session has a pending worktree prompt.
|
|
1881
|
+
*/
|
|
1882
|
+
hasPendingWorktreePrompt(threadId) {
|
|
1883
|
+
const session = this.sessions.get(threadId);
|
|
1884
|
+
return session?.pendingWorktreePrompt === true;
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* Get the worktree prompt post ID for a session.
|
|
1888
|
+
*/
|
|
1889
|
+
getWorktreePromptPostId(threadId) {
|
|
1890
|
+
const session = this.sessions.get(threadId);
|
|
1891
|
+
return session?.worktreePromptPostId;
|
|
1892
|
+
}
|
|
1423
1893
|
/** Kill all active sessions (for graceful shutdown) */
|
|
1424
1894
|
killAllSessions() {
|
|
1425
1895
|
console.log(` [shutdown] killAllSessions called, isShuttingDown already=${this.isShuttingDown}`);
|
package/dist/config.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Check if any .env config file exists */
|
|
2
2
|
export declare function configExists(): boolean;
|
|
3
|
+
export type WorktreeMode = 'off' | 'prompt' | 'require';
|
|
3
4
|
/** CLI arguments that can override config */
|
|
4
5
|
export interface CliArgs {
|
|
5
6
|
url?: string;
|
|
@@ -9,6 +10,7 @@ export interface CliArgs {
|
|
|
9
10
|
allowedUsers?: string;
|
|
10
11
|
skipPermissions?: boolean;
|
|
11
12
|
chrome?: boolean;
|
|
13
|
+
worktreeMode?: WorktreeMode;
|
|
12
14
|
}
|
|
13
15
|
export interface Config {
|
|
14
16
|
mattermost: {
|
|
@@ -20,5 +22,6 @@ export interface Config {
|
|
|
20
22
|
allowedUsers: string[];
|
|
21
23
|
skipPermissions: boolean;
|
|
22
24
|
chrome: boolean;
|
|
25
|
+
worktreeMode: WorktreeMode;
|
|
23
26
|
}
|
|
24
27
|
export declare function loadConfig(cliArgs?: CliArgs): Config;
|
package/dist/config.js
CHANGED
|
@@ -65,6 +65,20 @@ export function loadConfig(cliArgs) {
|
|
|
65
65
|
else {
|
|
66
66
|
chrome = process.env.CLAUDE_CHROME === 'true';
|
|
67
67
|
}
|
|
68
|
+
// Worktree mode: CLI flag or env var, default to 'prompt'
|
|
69
|
+
let worktreeMode;
|
|
70
|
+
if (cliArgs?.worktreeMode !== undefined) {
|
|
71
|
+
worktreeMode = cliArgs.worktreeMode;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const envValue = process.env.WORKTREE_MODE?.toLowerCase();
|
|
75
|
+
if (envValue === 'off' || envValue === 'prompt' || envValue === 'require') {
|
|
76
|
+
worktreeMode = envValue;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
worktreeMode = 'prompt'; // Default
|
|
80
|
+
}
|
|
81
|
+
}
|
|
68
82
|
return {
|
|
69
83
|
mattermost: {
|
|
70
84
|
url: url.replace(/\/$/, ''), // Remove trailing slash
|
|
@@ -75,5 +89,6 @@ export function loadConfig(cliArgs) {
|
|
|
75
89
|
allowedUsers,
|
|
76
90
|
skipPermissions,
|
|
77
91
|
chrome,
|
|
92
|
+
worktreeMode,
|
|
78
93
|
};
|
|
79
94
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface WorktreeInfo {
|
|
2
|
+
path: string;
|
|
3
|
+
branch: string;
|
|
4
|
+
commit: string;
|
|
5
|
+
isMain: boolean;
|
|
6
|
+
isBare: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Check if a directory is inside a git repository
|
|
10
|
+
*/
|
|
11
|
+
export declare function isGitRepository(dir: string): Promise<boolean>;
|
|
12
|
+
/**
|
|
13
|
+
* Get the root directory of the git repository
|
|
14
|
+
*/
|
|
15
|
+
export declare function getRepositoryRoot(dir: string): Promise<string>;
|
|
16
|
+
/**
|
|
17
|
+
* Check if there are uncommitted changes (staged or unstaged)
|
|
18
|
+
*/
|
|
19
|
+
export declare function hasUncommittedChanges(dir: string): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* List all worktrees for a repository
|
|
22
|
+
*/
|
|
23
|
+
export declare function listWorktrees(repoRoot: string): Promise<WorktreeInfo[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Check if a branch exists (local or remote)
|
|
26
|
+
*/
|
|
27
|
+
export declare function branchExists(repoRoot: string, branch: string): Promise<boolean>;
|
|
28
|
+
/**
|
|
29
|
+
* Generate the worktree directory path
|
|
30
|
+
* Creates path like: /path/to/repo-worktrees/branch-name-abc123
|
|
31
|
+
*/
|
|
32
|
+
export declare function getWorktreeDir(repoRoot: string, branch: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Create a new worktree for a branch
|
|
35
|
+
* If the branch doesn't exist, creates it from the current HEAD
|
|
36
|
+
*/
|
|
37
|
+
export declare function createWorktree(repoRoot: string, branch: string, targetDir: string): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Remove a worktree
|
|
40
|
+
*/
|
|
41
|
+
export declare function removeWorktree(repoRoot: string, worktreePath: string): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Find a worktree by branch name
|
|
44
|
+
*/
|
|
45
|
+
export declare function findWorktreeByBranch(repoRoot: string, branch: string): Promise<WorktreeInfo | null>;
|
|
46
|
+
/**
|
|
47
|
+
* Validate a git branch name
|
|
48
|
+
* Based on git-check-ref-format rules
|
|
49
|
+
*/
|
|
50
|
+
export declare function isValidBranchName(name: string): boolean;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as fs from 'fs/promises';
|
|
5
|
+
/**
|
|
6
|
+
* Execute a git command and return stdout
|
|
7
|
+
*/
|
|
8
|
+
async function execGit(args, cwd) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const proc = spawn('git', args, { cwd });
|
|
11
|
+
let stdout = '';
|
|
12
|
+
let stderr = '';
|
|
13
|
+
proc.stdout.on('data', (data) => {
|
|
14
|
+
stdout += data.toString();
|
|
15
|
+
});
|
|
16
|
+
proc.stderr.on('data', (data) => {
|
|
17
|
+
stderr += data.toString();
|
|
18
|
+
});
|
|
19
|
+
proc.on('close', (code) => {
|
|
20
|
+
if (code === 0) {
|
|
21
|
+
resolve(stdout.trim());
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
reject(new Error(`git ${args.join(' ')} failed: ${stderr || stdout}`));
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
proc.on('error', (err) => {
|
|
28
|
+
reject(err);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Check if a directory is inside a git repository
|
|
34
|
+
*/
|
|
35
|
+
export async function isGitRepository(dir) {
|
|
36
|
+
try {
|
|
37
|
+
await execGit(['rev-parse', '--git-dir'], dir);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Get the root directory of the git repository
|
|
46
|
+
*/
|
|
47
|
+
export async function getRepositoryRoot(dir) {
|
|
48
|
+
return execGit(['rev-parse', '--show-toplevel'], dir);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Check if there are uncommitted changes (staged or unstaged)
|
|
52
|
+
*/
|
|
53
|
+
export async function hasUncommittedChanges(dir) {
|
|
54
|
+
try {
|
|
55
|
+
// Check for staged changes
|
|
56
|
+
const staged = await execGit(['diff', '--cached', '--quiet'], dir).catch(() => 'changes');
|
|
57
|
+
if (staged === 'changes')
|
|
58
|
+
return true;
|
|
59
|
+
// Check for unstaged changes
|
|
60
|
+
const unstaged = await execGit(['diff', '--quiet'], dir).catch(() => 'changes');
|
|
61
|
+
if (unstaged === 'changes')
|
|
62
|
+
return true;
|
|
63
|
+
// Check for untracked files
|
|
64
|
+
const untracked = await execGit(['ls-files', '--others', '--exclude-standard'], dir);
|
|
65
|
+
return untracked.length > 0;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* List all worktrees for a repository
|
|
73
|
+
*/
|
|
74
|
+
export async function listWorktrees(repoRoot) {
|
|
75
|
+
const output = await execGit(['worktree', 'list', '--porcelain'], repoRoot);
|
|
76
|
+
const worktrees = [];
|
|
77
|
+
if (!output)
|
|
78
|
+
return worktrees;
|
|
79
|
+
// Parse porcelain output
|
|
80
|
+
// Format:
|
|
81
|
+
// worktree /path/to/worktree
|
|
82
|
+
// HEAD <commit>
|
|
83
|
+
// branch refs/heads/branch-name
|
|
84
|
+
// <blank line>
|
|
85
|
+
const blocks = output.split('\n\n').filter(Boolean);
|
|
86
|
+
for (const block of blocks) {
|
|
87
|
+
const lines = block.split('\n');
|
|
88
|
+
const worktree = {};
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
if (line.startsWith('worktree ')) {
|
|
91
|
+
worktree.path = line.slice(9);
|
|
92
|
+
}
|
|
93
|
+
else if (line.startsWith('HEAD ')) {
|
|
94
|
+
worktree.commit = line.slice(5);
|
|
95
|
+
}
|
|
96
|
+
else if (line.startsWith('branch ')) {
|
|
97
|
+
// refs/heads/branch-name -> branch-name
|
|
98
|
+
worktree.branch = line.slice(7).replace('refs/heads/', '');
|
|
99
|
+
}
|
|
100
|
+
else if (line === 'bare') {
|
|
101
|
+
worktree.isBare = true;
|
|
102
|
+
}
|
|
103
|
+
else if (line === 'detached') {
|
|
104
|
+
worktree.branch = '(detached)';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (worktree.path) {
|
|
108
|
+
worktrees.push({
|
|
109
|
+
path: worktree.path,
|
|
110
|
+
branch: worktree.branch || '(unknown)',
|
|
111
|
+
commit: worktree.commit || '',
|
|
112
|
+
isMain: worktrees.length === 0, // First worktree is the main one
|
|
113
|
+
isBare: worktree.isBare || false,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return worktrees;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Check if a branch exists (local or remote)
|
|
121
|
+
*/
|
|
122
|
+
export async function branchExists(repoRoot, branch) {
|
|
123
|
+
try {
|
|
124
|
+
// Check local branches
|
|
125
|
+
await execGit(['rev-parse', '--verify', `refs/heads/${branch}`], repoRoot);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
try {
|
|
130
|
+
// Check remote branches
|
|
131
|
+
await execGit(['rev-parse', '--verify', `refs/remotes/origin/${branch}`], repoRoot);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Generate the worktree directory path
|
|
141
|
+
* Creates path like: /path/to/repo-worktrees/branch-name-abc123
|
|
142
|
+
*/
|
|
143
|
+
export function getWorktreeDir(repoRoot, branch) {
|
|
144
|
+
const repoName = path.basename(repoRoot);
|
|
145
|
+
const parentDir = path.dirname(repoRoot);
|
|
146
|
+
const worktreesDir = path.join(parentDir, `${repoName}-worktrees`);
|
|
147
|
+
// Sanitize branch name for filesystem
|
|
148
|
+
const sanitizedBranch = branch
|
|
149
|
+
.replace(/\//g, '-')
|
|
150
|
+
.replace(/[^a-zA-Z0-9-_]/g, '');
|
|
151
|
+
const shortUuid = randomUUID().slice(0, 8);
|
|
152
|
+
return path.join(worktreesDir, `${sanitizedBranch}-${shortUuid}`);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Create a new worktree for a branch
|
|
156
|
+
* If the branch doesn't exist, creates it from the current HEAD
|
|
157
|
+
*/
|
|
158
|
+
export async function createWorktree(repoRoot, branch, targetDir) {
|
|
159
|
+
// Ensure the parent directory exists
|
|
160
|
+
const parentDir = path.dirname(targetDir);
|
|
161
|
+
await fs.mkdir(parentDir, { recursive: true });
|
|
162
|
+
// Check if branch exists
|
|
163
|
+
const exists = await branchExists(repoRoot, branch);
|
|
164
|
+
if (exists) {
|
|
165
|
+
// Use existing branch
|
|
166
|
+
await execGit(['worktree', 'add', targetDir, branch], repoRoot);
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
// Create new branch from HEAD
|
|
170
|
+
await execGit(['worktree', 'add', '-b', branch, targetDir], repoRoot);
|
|
171
|
+
}
|
|
172
|
+
return targetDir;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Remove a worktree
|
|
176
|
+
*/
|
|
177
|
+
export async function removeWorktree(repoRoot, worktreePath) {
|
|
178
|
+
// First try to remove cleanly
|
|
179
|
+
try {
|
|
180
|
+
await execGit(['worktree', 'remove', worktreePath], repoRoot);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// If that fails, try force remove
|
|
184
|
+
await execGit(['worktree', 'remove', '--force', worktreePath], repoRoot);
|
|
185
|
+
}
|
|
186
|
+
// Prune any stale worktree references
|
|
187
|
+
await execGit(['worktree', 'prune'], repoRoot);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Find a worktree by branch name
|
|
191
|
+
*/
|
|
192
|
+
export async function findWorktreeByBranch(repoRoot, branch) {
|
|
193
|
+
const worktrees = await listWorktrees(repoRoot);
|
|
194
|
+
return worktrees.find((wt) => wt.branch === branch) || null;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Validate a git branch name
|
|
198
|
+
* Based on git-check-ref-format rules
|
|
199
|
+
*/
|
|
200
|
+
export function isValidBranchName(name) {
|
|
201
|
+
if (!name || name.length === 0)
|
|
202
|
+
return false;
|
|
203
|
+
// Cannot start or end with /
|
|
204
|
+
if (name.startsWith('/') || name.endsWith('/'))
|
|
205
|
+
return false;
|
|
206
|
+
// Cannot contain ..
|
|
207
|
+
if (name.includes('..'))
|
|
208
|
+
return false;
|
|
209
|
+
// Cannot contain special characters
|
|
210
|
+
if (/[\s~^:?*[\]\\]/.test(name))
|
|
211
|
+
return false;
|
|
212
|
+
// Cannot start with -
|
|
213
|
+
if (name.startsWith('-'))
|
|
214
|
+
return false;
|
|
215
|
+
// Cannot end with .lock
|
|
216
|
+
if (name.endsWith('.lock'))
|
|
217
|
+
return false;
|
|
218
|
+
// Cannot contain @{
|
|
219
|
+
if (name.includes('@{'))
|
|
220
|
+
return false;
|
|
221
|
+
// Cannot be @
|
|
222
|
+
if (name === '@')
|
|
223
|
+
return false;
|
|
224
|
+
// Cannot contain consecutive dots
|
|
225
|
+
if (/\.\./.test(name))
|
|
226
|
+
return false;
|
|
227
|
+
return true;
|
|
228
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -78,7 +78,7 @@ async function main() {
|
|
|
78
78
|
}
|
|
79
79
|
console.log('');
|
|
80
80
|
const mattermost = new MattermostClient(config);
|
|
81
|
-
const session = new SessionManager(mattermost, workingDir, config.skipPermissions, config.chrome);
|
|
81
|
+
const session = new SessionManager(mattermost, workingDir, config.skipPermissions, config.chrome, config.worktreeMode);
|
|
82
82
|
mattermost.on('message', async (post, user) => {
|
|
83
83
|
try {
|
|
84
84
|
const username = user?.username || 'unknown';
|
|
@@ -143,6 +143,11 @@ async function main() {
|
|
|
143
143
|
`| \`!cost\` | Show token usage and cost for this session |\n` +
|
|
144
144
|
`| \`!compact\` | Compress context to free up space |\n` +
|
|
145
145
|
`| \`!cd <path>\` | Change working directory (restarts Claude) |\n` +
|
|
146
|
+
`| \`!worktree <branch>\` | Create and switch to a git worktree |\n` +
|
|
147
|
+
`| \`!worktree list\` | List all worktrees for the repo |\n` +
|
|
148
|
+
`| \`!worktree switch <branch>\` | Switch to an existing worktree |\n` +
|
|
149
|
+
`| \`!worktree remove <branch>\` | Remove a worktree |\n` +
|
|
150
|
+
`| \`!worktree off\` | Disable worktree prompts for this session |\n` +
|
|
146
151
|
`| \`!invite @user\` | Invite a user to this session |\n` +
|
|
147
152
|
`| \`!kick @user\` | Remove an invited user |\n` +
|
|
148
153
|
`| \`!permissions interactive\` | Enable interactive permissions |\n` +
|
|
@@ -197,6 +202,49 @@ async function main() {
|
|
|
197
202
|
await session.changeDirectory(threadRoot, cdMatch[1].trim(), username);
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
205
|
+
// Check for !worktree command
|
|
206
|
+
const worktreeMatch = content.match(/^!worktree\s+(\S+)(?:\s+(.*))?$/i);
|
|
207
|
+
if (worktreeMatch) {
|
|
208
|
+
const subcommand = worktreeMatch[1].toLowerCase();
|
|
209
|
+
const args = worktreeMatch[2]?.trim();
|
|
210
|
+
switch (subcommand) {
|
|
211
|
+
case 'list':
|
|
212
|
+
await session.listWorktreesCommand(threadRoot, username);
|
|
213
|
+
break;
|
|
214
|
+
case 'switch':
|
|
215
|
+
if (!args) {
|
|
216
|
+
await mattermost.createPost('❌ Usage: `!worktree switch <branch>`', threadRoot);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
await session.switchToWorktree(threadRoot, args, username);
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
case 'remove':
|
|
223
|
+
if (!args) {
|
|
224
|
+
await mattermost.createPost('❌ Usage: `!worktree remove <branch>`', threadRoot);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
await session.removeWorktreeCommand(threadRoot, args, username);
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
case 'off':
|
|
231
|
+
await session.disableWorktreePrompt(threadRoot, username);
|
|
232
|
+
break;
|
|
233
|
+
default:
|
|
234
|
+
// Treat as branch name: !worktree feature/foo
|
|
235
|
+
await session.createAndSwitchToWorktree(threadRoot, subcommand, username);
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// Check for pending worktree prompt - treat message as branch name response
|
|
240
|
+
if (session.hasPendingWorktreePrompt(threadRoot)) {
|
|
241
|
+
// Only session owner can respond
|
|
242
|
+
if (session.isUserAllowedInSession(threadRoot, username)) {
|
|
243
|
+
const handled = await session.handleWorktreeBranchResponse(threadRoot, content, username);
|
|
244
|
+
if (handled)
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
200
248
|
// Check for Claude Code slash commands (translate ! to /)
|
|
201
249
|
// These are sent directly to Claude Code as /commands
|
|
202
250
|
if (lowerContent === '!context' || lowerContent === '!cost' || lowerContent === '!compact') {
|
|
@@ -260,6 +308,15 @@ async function main() {
|
|
|
260
308
|
await mattermost.createPost(`Mention me with your request`, threadRoot);
|
|
261
309
|
return;
|
|
262
310
|
}
|
|
311
|
+
// Check for inline branch syntax: "on branch X" or "!worktree X"
|
|
312
|
+
const branchMatch = prompt.match(/(?:on branch|!worktree)\s+(\S+)/i);
|
|
313
|
+
if (branchMatch) {
|
|
314
|
+
const branch = branchMatch[1];
|
|
315
|
+
// Remove the branch specification from the prompt
|
|
316
|
+
const cleanedPrompt = prompt.replace(/(?:on branch|!worktree)\s+\S+/i, '').trim();
|
|
317
|
+
await session.startSessionWithWorktree({ prompt: cleanedPrompt || prompt, files }, branch, username, threadRoot);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
263
320
|
await session.startSession({ prompt, files }, username, threadRoot);
|
|
264
321
|
}
|
|
265
322
|
catch (err) {
|
|
@@ -28,7 +28,7 @@ import WebSocket from 'ws';
|
|
|
28
28
|
import { isApprovalEmoji, isAllowAllEmoji, APPROVAL_EMOJIS, ALLOW_ALL_EMOJIS, DENIAL_EMOJIS } from '../mattermost/emoji.js';
|
|
29
29
|
import { formatToolForPermission } from '../utils/tool-formatter.js';
|
|
30
30
|
import { mcpLogger } from '../utils/logger.js';
|
|
31
|
-
import { getMe, getUser, createInteractivePost, isUserAllowed, } from '../mattermost/api.js';
|
|
31
|
+
import { getMe, getUser, createInteractivePost, updatePost, isUserAllowed, } from '../mattermost/api.js';
|
|
32
32
|
// =============================================================================
|
|
33
33
|
// Configuration
|
|
34
34
|
// =============================================================================
|
|
@@ -151,17 +151,20 @@ async function handlePermission(toolName, toolInput) {
|
|
|
151
151
|
const userId = await getBotUserId();
|
|
152
152
|
const post = await createInteractivePost(apiConfig, MM_CHANNEL_ID, message, [APPROVAL_EMOJIS[0], ALLOW_ALL_EMOJIS[0], DENIAL_EMOJIS[0]], MM_THREAD_ID || undefined, userId);
|
|
153
153
|
// Wait for user's reaction
|
|
154
|
-
const { emoji } = await waitForReaction(post.id);
|
|
154
|
+
const { emoji, username } = await waitForReaction(post.id);
|
|
155
155
|
if (isApprovalEmoji(emoji)) {
|
|
156
|
+
await updatePost(apiConfig, post.id, `✅ **Allowed** by @${username}\n\n${toolInfo}`);
|
|
156
157
|
mcpLogger.info(`Allowed: ${toolName}`);
|
|
157
158
|
return { behavior: 'allow', updatedInput: toolInput };
|
|
158
159
|
}
|
|
159
160
|
else if (isAllowAllEmoji(emoji)) {
|
|
160
161
|
allowAllSession = true;
|
|
162
|
+
await updatePost(apiConfig, post.id, `✅ **Allowed all** by @${username}\n\n${toolInfo}`);
|
|
161
163
|
mcpLogger.info(`Allowed all: ${toolName}`);
|
|
162
164
|
return { behavior: 'allow', updatedInput: toolInput };
|
|
163
165
|
}
|
|
164
166
|
else {
|
|
167
|
+
await updatePost(apiConfig, post.id, `❌ **Denied** by @${username}\n\n${toolInfo}`);
|
|
165
168
|
mcpLogger.info(`Denied: ${toolName}`);
|
|
166
169
|
return { behavior: 'deny', message: 'User denied permission' };
|
|
167
170
|
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree information for a session
|
|
3
|
+
*/
|
|
4
|
+
export interface WorktreeInfo {
|
|
5
|
+
repoRoot: string;
|
|
6
|
+
worktreePath: string;
|
|
7
|
+
branch: string;
|
|
8
|
+
}
|
|
1
9
|
/**
|
|
2
10
|
* Persisted session state for resuming after bot restart
|
|
3
11
|
*/
|
|
@@ -14,6 +22,10 @@ export interface PersistedSession {
|
|
|
14
22
|
tasksPostId: string | null;
|
|
15
23
|
lastActivityAt: string;
|
|
16
24
|
planApproved: boolean;
|
|
25
|
+
worktreeInfo?: WorktreeInfo;
|
|
26
|
+
pendingWorktreePrompt?: boolean;
|
|
27
|
+
worktreePromptDisabled?: boolean;
|
|
28
|
+
queuedPrompt?: string;
|
|
17
29
|
}
|
|
18
30
|
/**
|
|
19
31
|
* SessionStore - Persistence layer for session state
|