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 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
@@ -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) */
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mattermost-claude-code",
3
- "version": "0.10.10",
3
+ "version": "0.11.0",
4
4
  "description": "Share Claude Code sessions live in a Mattermost channel with interactive features",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",