ccmanager 3.12.6 → 4.0.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.
@@ -17,8 +17,11 @@ export declare const getBackgroundTaskTag: (count: number) => string;
17
17
  export declare const getTeamMemberTag: (count: number) => string;
18
18
  export declare const MENU_ICONS: {
19
19
  readonly NEW_WORKTREE: "⊕";
20
+ readonly NEW_SESSION: "⊕";
21
+ readonly RENAME_SESSION: "✎";
20
22
  readonly MERGE_WORKTREE: "⇄";
21
23
  readonly DELETE_WORKTREE: "✕";
24
+ readonly KILL_SESSION: "✕";
22
25
  readonly CONFIGURE_SHORTCUTS: "⌨";
23
26
  readonly EXIT: "⏻";
24
27
  };
@@ -29,8 +29,11 @@ export const getTeamMemberTag = (count) => {
29
29
  };
30
30
  export const MENU_ICONS = {
31
31
  NEW_WORKTREE: '⊕',
32
+ NEW_SESSION: '⊕',
33
+ RENAME_SESSION: '✎',
32
34
  MERGE_WORKTREE: '⇄',
33
35
  DELETE_WORKTREE: '✕',
36
+ KILL_SESSION: '✕',
34
37
  CONFIGURE_SHORTCUTS: '⌨',
35
38
  EXIT: '⏻',
36
39
  };
@@ -10,14 +10,20 @@ vi.mock('./sessionManager.js', () => {
10
10
  destroy() {
11
11
  this.sessions.clear();
12
12
  }
13
- getSession(worktreePath) {
14
- return this.sessions.get(worktreePath);
13
+ getSessionById(id) {
14
+ return this.sessions.get(id);
15
15
  }
16
- setSessionActive(_worktreePath, _active) {
16
+ getSessionsForWorktree(worktreePath) {
17
+ return Array.from(this.sessions.values()).filter((s) => s.worktreePath === worktreePath);
18
+ }
19
+ setSessionActive(_sessionId, _active) {
17
20
  // Mock implementation
18
21
  }
19
- destroySession(worktreePath) {
20
- this.sessions.delete(worktreePath);
22
+ destroySession(sessionId) {
23
+ this.sessions.delete(sessionId);
24
+ }
25
+ cancelAutoApproval(_sessionId, _reason) {
26
+ // Mock implementation
21
27
  }
22
28
  on() {
23
29
  // Mock implementation
@@ -48,9 +48,6 @@ vi.mock('./config/configReader.js', () => ({
48
48
  }),
49
49
  getHooks: vi.fn().mockReturnValue({}),
50
50
  getStatusHooks: vi.fn().mockReturnValue({}),
51
- setWorktreeLastOpened: vi.fn(),
52
- getWorktreeLastOpenedTime: vi.fn(),
53
- getWorktreeLastOpened: vi.fn(() => ({})),
54
51
  isAutoApprovalEnabled: vi.fn(() => true),
55
52
  setAutoApprovalEnabled: vi.fn(),
56
53
  },
@@ -158,7 +155,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
158
155
  }));
159
156
  const handler = vi.fn();
160
157
  sessionManager.on('sessionStateChanged', handler);
161
- sessionManager.cancelAutoApproval(session.worktreePath, 'User pressed a key');
158
+ sessionManager.cancelAutoApproval(session.id, 'User pressed a key');
162
159
  // Wait for async mutex update to complete (use vi.waitFor for proper async handling)
163
160
  await vi.waitFor(() => {
164
161
  const stateData = session.stateMutex.getSnapshot();
@@ -34,7 +34,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
34
34
  */
35
35
  private updateSessionState;
36
36
  constructor();
37
- private createSessionId;
38
37
  private createTerminal;
39
38
  private createSessionInternal;
40
39
  /**
@@ -67,16 +66,17 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
67
66
  private setupExitHandler;
68
67
  private setupBackgroundHandler;
69
68
  private cleanupSession;
70
- getSession(worktreePath: string): Session | undefined;
71
- setSessionActive(worktreePath: string, active: boolean): void;
72
- cancelAutoApproval(worktreePath: string, reason?: string): void;
69
+ getSessionById(id: string): Session | undefined;
70
+ getSessionsForWorktree(worktreePath: string): Session[];
71
+ setSessionActive(sessionId: string, active: boolean): void;
72
+ cancelAutoApproval(sessionId: string, reason?: string): void;
73
73
  toggleAutoApprovalForWorktree(worktreePath: string): boolean;
74
74
  isAutoApprovalDisabledForWorktree(worktreePath: string): boolean;
75
- destroySession(worktreePath: string): void;
75
+ destroySession(sessionId: string): void;
76
76
  /**
77
77
  * Terminate session and cleanup resources using Effect-based error handling
78
78
  *
79
- * @param {string} worktreePath - Path to the worktree
79
+ * @param {string} sessionId - Session identifier
80
80
  * @returns {Effect.Effect<void, ProcessError, never>} Effect that may fail with ProcessError if session does not exist or cleanup fails
81
81
  *
82
82
  * @example
@@ -90,7 +90,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
90
90
  * );
91
91
  * ```
92
92
  */
93
- terminateSessionEffect(worktreePath: string): Effect.Effect<void, ProcessError, never>;
93
+ terminateSessionEffect(sessionId: string): Effect.Effect<void, ProcessError, never>;
94
94
  getAllSessions(): Session[];
95
95
  /**
96
96
  * Create session with devcontainer integration using Effect-based error handling
@@ -19,9 +19,9 @@ vi.mock('./config/configReader.js', () => ({
19
19
  configReader: {
20
20
  getDefaultPreset: vi.fn(),
21
21
  getPresetByIdEffect: vi.fn(),
22
- setWorktreeLastOpened: vi.fn(),
23
- getWorktreeLastOpenedTime: vi.fn(),
24
- getWorktreeLastOpened: vi.fn(() => ({})),
22
+ isAutoApprovalEnabled: vi.fn(() => false),
23
+ setAutoApprovalEnabled: vi.fn(),
24
+ getStatusHooks: vi.fn(() => ({})),
25
25
  },
26
26
  }));
27
27
  // Mock Terminal
@@ -135,7 +135,7 @@ describe('SessionManager Effect-based Operations', () => {
135
135
  }
136
136
  }
137
137
  });
138
- it('should return existing session without creating new Effect', async () => {
138
+ it('should create a new session each time for multi-session support', async () => {
139
139
  // Setup mock preset
140
140
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
141
141
  id: '1',
@@ -144,15 +144,16 @@ describe('SessionManager Effect-based Operations', () => {
144
144
  });
145
145
  // Setup spawn mock
146
146
  vi.mocked(spawn).mockReturnValue(mockPty);
147
- // Create session twice
147
+ // Create session twice - multi-session API creates a new session each time
148
148
  const effect1 = sessionManager.createSessionWithPresetEffect('/test/worktree');
149
149
  const session1 = await Effect.runPromise(effect1);
150
150
  const effect2 = sessionManager.createSessionWithPresetEffect('/test/worktree');
151
151
  const session2 = await Effect.runPromise(effect2);
152
- // Should return the same session
153
- expect(session1).toBe(session2);
154
- // Spawn should only be called once
155
- expect(spawn).toHaveBeenCalledTimes(1);
152
+ // Should return different sessions for multi-session support
153
+ expect(session1).not.toBe(session2);
154
+ expect(session1.worktreePath).toBe(session2.worktreePath);
155
+ // Spawn should be called once per session
156
+ expect(spawn).toHaveBeenCalledTimes(2);
156
157
  });
157
158
  });
158
159
  describe('createSessionWithDevcontainer returning Effect', () => {
@@ -264,18 +265,18 @@ describe('SessionManager Effect-based Operations', () => {
264
265
  });
265
266
  vi.mocked(spawn).mockReturnValue(mockPty);
266
267
  // Create session
267
- await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
268
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
268
269
  // Terminate session - should return Effect
269
- const effect = sessionManager.terminateSessionEffect('/test/worktree');
270
+ const effect = sessionManager.terminateSessionEffect(session.id);
270
271
  // Execute the Effect and verify it succeeds
271
272
  await Effect.runPromise(effect);
272
273
  // Verify session was destroyed
273
- expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
274
+ expect(sessionManager.getSessionsForWorktree('/test/worktree')).toHaveLength(0);
274
275
  expect(mockPty.kill).toHaveBeenCalled();
275
276
  });
276
277
  it('should return Effect that fails with ProcessError when session does not exist', async () => {
277
278
  // Terminate non-existent session - should return Effect
278
- const effect = sessionManager.terminateSessionEffect('/nonexistent/worktree');
279
+ const effect = sessionManager.terminateSessionEffect('nonexistent-session-id');
279
280
  // Execute the Effect and expect it to fail with ProcessError
280
281
  const result = await Effect.runPromise(Effect.either(effect));
281
282
  expect(Either.isLeft(result)).toBe(true);
@@ -293,17 +294,17 @@ describe('SessionManager Effect-based Operations', () => {
293
294
  });
294
295
  vi.mocked(spawn).mockReturnValue(mockPty);
295
296
  // Create session
296
- await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
297
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
297
298
  // Mock kill to throw error
298
299
  mockPty.kill.mockImplementation(() => {
299
300
  throw new Error('Process already terminated');
300
301
  });
301
302
  // Terminate session - should still succeed
302
- const effect = sessionManager.terminateSessionEffect('/test/worktree');
303
+ const effect = sessionManager.terminateSessionEffect(session.id);
303
304
  // Should not throw, gracefully handle kill failure
304
305
  await Effect.runPromise(effect);
305
306
  // Session should still be removed from map
306
- expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
307
+ expect(sessionManager.getSessionsForWorktree('/test/worktree')).toHaveLength(0);
307
308
  });
308
309
  });
309
310
  });
@@ -4,7 +4,6 @@ import pkg from '@xterm/headless';
4
4
  import { exec } from 'child_process';
5
5
  import { promisify } from 'util';
6
6
  import { configReader } from './config/configReader.js';
7
- import { setWorktreeLastOpened } from './worktreeService.js';
8
7
  import { executeStatusHook } from '../utils/hookExecutor.js';
9
8
  import { createStateDetector } from './stateDetector/index.js';
10
9
  import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
@@ -190,9 +189,6 @@ export class SessionManager extends EventEmitter {
190
189
  super();
191
190
  this.sessions = new Map();
192
191
  }
193
- createSessionId() {
194
- return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
195
- }
196
192
  createTerminal() {
197
193
  return new Terminal({
198
194
  cols: process.stdout.columns || 80,
@@ -202,13 +198,17 @@ export class SessionManager extends EventEmitter {
202
198
  });
203
199
  }
204
200
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
205
- const id = this.createSessionId();
201
+ const existingSessions = this.getSessionsForWorktree(worktreePath);
202
+ const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
206
203
  const terminal = this.createTerminal();
207
204
  const detectionStrategy = options.detectionStrategy ?? 'claude';
208
205
  const stateDetector = createStateDetector(detectionStrategy);
209
206
  const session = {
210
- id,
207
+ id: `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
211
208
  worktreePath,
209
+ sessionNumber: maxNumber + 1,
210
+ sessionName: undefined,
211
+ lastAccessedAt: Date.now(),
212
212
  process: ptyProcess,
213
213
  output: [],
214
214
  outputHistory: [],
@@ -224,9 +224,7 @@ export class SessionManager extends EventEmitter {
224
224
  };
225
225
  // Set up persistent background data handler for state detection
226
226
  this.setupBackgroundHandler(session);
227
- this.sessions.set(worktreePath, session);
228
- // Record the timestamp when this worktree was opened
229
- setWorktreeLastOpened(worktreePath, Date.now());
227
+ this.sessions.set(session.id, session);
230
228
  this.emit('sessionCreated', session);
231
229
  return session;
232
230
  }
@@ -251,11 +249,6 @@ export class SessionManager extends EventEmitter {
251
249
  createSessionWithPresetEffect(worktreePath, presetId, initialPrompt) {
252
250
  return Effect.tryPromise({
253
251
  try: async () => {
254
- // Check if session already exists
255
- const existing = this.sessions.get(worktreePath);
256
- if (existing) {
257
- return existing;
258
- }
259
252
  const preset = this.resolvePreset(presetId);
260
253
  const command = preset.command;
261
254
  const launch = preparePresetLaunch(preset, initialPrompt);
@@ -470,19 +463,21 @@ export class SessionManager extends EventEmitter {
470
463
  }
471
464
  // Clear any pending state and update state to idle before destroying
472
465
  void this.updateSessionState(session, 'idle');
473
- this.destroySession(session.worktreePath);
466
+ this.destroySession(session.id);
474
467
  this.emit('sessionExit', session);
475
468
  }
476
- getSession(worktreePath) {
477
- return this.sessions.get(worktreePath);
469
+ getSessionById(id) {
470
+ return this.sessions.get(id);
478
471
  }
479
- setSessionActive(worktreePath, active) {
480
- const session = this.sessions.get(worktreePath);
472
+ getSessionsForWorktree(worktreePath) {
473
+ return Array.from(this.sessions.values()).filter(s => s.worktreePath === worktreePath);
474
+ }
475
+ setSessionActive(sessionId, active) {
476
+ const session = this.sessions.get(sessionId);
481
477
  if (session) {
482
478
  session.isActive = active;
483
- // If becoming active, record the timestamp when this worktree was opened
484
479
  if (active) {
485
- setWorktreeLastOpened(worktreePath, Date.now());
480
+ session.lastAccessedAt = Date.now();
486
481
  // Emit a restore event with the output history if available
487
482
  if (session.outputHistory.length > 0) {
488
483
  this.emit('sessionRestore', session);
@@ -490,8 +485,8 @@ export class SessionManager extends EventEmitter {
490
485
  }
491
486
  }
492
487
  }
493
- cancelAutoApproval(worktreePath, reason = 'User input received') {
494
- const session = this.sessions.get(worktreePath);
488
+ cancelAutoApproval(sessionId, reason = 'User input received') {
489
+ const session = this.sessions.get(sessionId);
495
490
  if (!session) {
496
491
  return;
497
492
  }
@@ -526,15 +521,18 @@ export class SessionManager extends EventEmitter {
526
521
  }
527
522
  else {
528
523
  this.autoApprovalDisabledWorktrees.add(worktreePath);
529
- this.cancelAutoApproval(worktreePath, 'Auto-approval disabled for worktree');
524
+ // Cancel auto-approval for all sessions in this worktree
525
+ for (const session of this.getSessionsForWorktree(worktreePath)) {
526
+ this.cancelAutoApproval(session.id, 'Auto-approval disabled for worktree');
527
+ }
530
528
  return true;
531
529
  }
532
530
  }
533
531
  isAutoApprovalDisabledForWorktree(worktreePath) {
534
532
  return this.autoApprovalDisabledWorktrees.has(worktreePath);
535
533
  }
536
- destroySession(worktreePath) {
537
- const session = this.sessions.get(worktreePath);
534
+ destroySession(sessionId) {
535
+ const session = this.sessions.get(sessionId);
538
536
  if (session) {
539
537
  const stateData = session.stateMutex.getSnapshot();
540
538
  if (stateData.autoApprovalAbortController) {
@@ -551,20 +549,20 @@ export class SessionManager extends EventEmitter {
551
549
  // Process might already be dead
552
550
  }
553
551
  // Clean up any pending timer
554
- const timer = this.busyTimers.get(worktreePath);
552
+ const timer = this.busyTimers.get(sessionId);
555
553
  if (timer) {
556
554
  clearTimeout(timer);
557
- this.busyTimers.delete(worktreePath);
555
+ this.busyTimers.delete(sessionId);
558
556
  }
559
- this.sessions.delete(worktreePath);
560
- this.waitingWithBottomBorder.delete(session.id);
557
+ this.sessions.delete(sessionId);
558
+ this.waitingWithBottomBorder.delete(sessionId);
561
559
  this.emit('sessionDestroyed', session);
562
560
  }
563
561
  }
564
562
  /**
565
563
  * Terminate session and cleanup resources using Effect-based error handling
566
564
  *
567
- * @param {string} worktreePath - Path to the worktree
565
+ * @param {string} sessionId - Session identifier
568
566
  * @returns {Effect.Effect<void, ProcessError, never>} Effect that may fail with ProcessError if session does not exist or cleanup fails
569
567
  *
570
568
  * @example
@@ -578,16 +576,20 @@ export class SessionManager extends EventEmitter {
578
576
  * );
579
577
  * ```
580
578
  */
581
- terminateSessionEffect(worktreePath) {
579
+ terminateSessionEffect(sessionId) {
582
580
  return Effect.try({
583
581
  try: () => {
584
- const session = this.sessions.get(worktreePath);
582
+ const session = this.sessions.get(sessionId);
585
583
  if (!session) {
586
584
  throw new ProcessError({
587
585
  command: 'terminateSession',
588
- message: `Session not found for worktree: ${worktreePath}`,
586
+ message: `Session not found: ${sessionId}`,
589
587
  });
590
588
  }
589
+ const stateData = session.stateMutex.getSnapshot();
590
+ if (stateData.autoApprovalAbortController) {
591
+ this.cancelAutoApprovalVerification(session, 'Session terminated');
592
+ }
591
593
  // Clear the state check interval
592
594
  if (session.stateCheckInterval) {
593
595
  clearInterval(session.stateCheckInterval);
@@ -600,14 +602,13 @@ export class SessionManager extends EventEmitter {
600
602
  // Process might already be dead, this is acceptable
601
603
  }
602
604
  // Clean up any pending timer
603
- const timer = this.busyTimers.get(worktreePath);
605
+ const timer = this.busyTimers.get(sessionId);
604
606
  if (timer) {
605
607
  clearTimeout(timer);
606
- this.busyTimers.delete(worktreePath);
608
+ this.busyTimers.delete(sessionId);
607
609
  }
608
- // Remove from sessions map and cleanup
609
- this.sessions.delete(worktreePath);
610
- this.waitingWithBottomBorder.delete(session.id);
610
+ this.sessions.delete(sessionId);
611
+ this.waitingWithBottomBorder.delete(sessionId);
611
612
  this.emit('sessionDestroyed', session);
612
613
  },
613
614
  catch: (error) => {
@@ -620,7 +621,7 @@ export class SessionManager extends EventEmitter {
620
621
  command: 'terminateSession',
621
622
  message: error instanceof Error
622
623
  ? error.message
623
- : `Failed to terminate session for ${worktreePath}`,
624
+ : `Failed to terminate session: ${sessionId}`,
624
625
  });
625
626
  },
626
627
  });
@@ -635,11 +636,6 @@ export class SessionManager extends EventEmitter {
635
636
  createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt) {
636
637
  return Effect.tryPromise({
637
638
  try: async () => {
638
- // Check if session already exists
639
- const existing = this.sessions.get(worktreePath);
640
- if (existing) {
641
- return existing;
642
- }
643
639
  // Execute devcontainer up command first
644
640
  try {
645
641
  await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
@@ -687,9 +683,8 @@ export class SessionManager extends EventEmitter {
687
683
  });
688
684
  }
689
685
  destroy() {
690
- // Clean up all sessions
691
- for (const worktreePath of this.sessions.keys()) {
692
- this.destroySession(worktreePath);
686
+ for (const sessionId of Array.from(this.sessions.keys())) {
687
+ this.destroySession(sessionId);
693
688
  }
694
689
  }
695
690
  static getSessionCounts(sessions) {
@@ -36,9 +36,6 @@ vi.mock('./config/configReader.js', () => ({
36
36
  }),
37
37
  getHooks: vi.fn().mockReturnValue({}),
38
38
  getStatusHooks: vi.fn().mockReturnValue({}),
39
- setWorktreeLastOpened: vi.fn(),
40
- getWorktreeLastOpenedTime: vi.fn(),
41
- getWorktreeLastOpened: vi.fn(() => ({})),
42
39
  isAutoApprovalEnabled: vi.fn(() => false),
43
40
  setAutoApprovalEnabled: vi.fn(),
44
41
  },
@@ -195,10 +192,10 @@ describe('SessionManager - State Persistence', () => {
195
192
  expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
196
193
  expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
197
194
  // Destroy the session
198
- sessionManager.destroySession('/test/path');
195
+ sessionManager.destroySession(session.id);
199
196
  // Check that pending state is cleared
200
- const destroyedSession = sessionManager.getSession('/test/path');
201
- expect(destroyedSession).toBeUndefined();
197
+ const remainingSessions = sessionManager.getSessionsForWorktree('/test/path');
198
+ expect(remainingSessions).toHaveLength(0);
202
199
  });
203
200
  it('should not transition state before minimum duration in current state has elapsed', async () => {
204
201
  const { Effect } = await import('effect');
@@ -25,9 +25,6 @@ vi.mock('./config/configReader.js', () => ({
25
25
  getStatusHooks: vi.fn(() => ({})),
26
26
  getDefaultPreset: vi.fn(),
27
27
  getPresetByIdEffect: vi.fn(),
28
- setWorktreeLastOpened: vi.fn(),
29
- getWorktreeLastOpenedTime: vi.fn(),
30
- getWorktreeLastOpened: vi.fn(() => ({})),
31
28
  isAutoApprovalEnabled: vi.fn(() => false),
32
29
  setAutoApprovalEnabled: vi.fn(),
33
30
  },
@@ -57,9 +54,6 @@ vi.mock('./worktreeService.js', () => ({
57
54
  WorktreeService: vi.fn(function () {
58
55
  return {};
59
56
  }),
60
- setWorktreeLastOpened: vi.fn(),
61
- getWorktreeLastOpened: vi.fn(() => ({})),
62
- getWorktreeLastOpenedTime: vi.fn(),
63
57
  }));
64
58
  // Create a mock IPty class
65
59
  class MockPty extends EventEmitter {
@@ -202,7 +196,7 @@ describe('SessionManager', () => {
202
196
  expect(spawn).toHaveBeenCalledTimes(1);
203
197
  expect(spawn).toHaveBeenCalledWith('claude', ['--bad-flag', '--teammate-mode', 'in-process'], expect.any(Object));
204
198
  });
205
- it('should return existing session if already created', async () => {
199
+ it('should create a new session each time for multi-session support', async () => {
206
200
  // Setup mock preset
207
201
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
208
202
  id: '1',
@@ -211,13 +205,14 @@ describe('SessionManager', () => {
211
205
  });
212
206
  // Setup spawn mock
213
207
  vi.mocked(spawn).mockReturnValue(mockPty);
214
- // Create session twice
208
+ // Create session twice - multi-session API creates a new session each time
215
209
  const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
216
210
  const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
217
- // Should return the same session
218
- expect(session1).toBe(session2);
219
- // Spawn should only be called once
220
- expect(spawn).toHaveBeenCalledTimes(1);
211
+ // Should return different sessions for multi-session support
212
+ expect(session1).not.toBe(session2);
213
+ expect(session1.worktreePath).toBe(session2.worktreePath);
214
+ // Spawn should be called once per session
215
+ expect(spawn).toHaveBeenCalledTimes(2);
221
216
  });
222
217
  it('should throw error when spawn fails with fallback args', async () => {
223
218
  // Setup mock preset with fallback
@@ -360,11 +355,11 @@ describe('SessionManager', () => {
360
355
  });
361
356
  vi.mocked(spawn).mockReturnValue(mockPty);
362
357
  // Create and destroy session
363
- await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
364
- sessionManager.destroySession('/test/worktree');
358
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
359
+ sessionManager.destroySession(session.id);
365
360
  // Verify cleanup
366
361
  expect(mockPty.kill).toHaveBeenCalled();
367
- expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
362
+ expect(sessionManager.getSessionsForWorktree('/test/worktree')).toHaveLength(0);
368
363
  });
369
364
  it('should handle session exit event', async () => {
370
365
  // Setup
@@ -388,7 +383,7 @@ describe('SessionManager', () => {
388
383
  // Wait for exit event
389
384
  await new Promise(resolve => setTimeout(resolve, 700));
390
385
  expect(exitedSession).toBe(createdSession);
391
- expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
386
+ expect(sessionManager.getSessionsForWorktree('/test/worktree')).toHaveLength(0);
392
387
  });
393
388
  });
394
389
  describe('createSessionWithDevcontainerEffect', () => {
@@ -480,7 +475,7 @@ describe('SessionManager', () => {
480
475
  };
481
476
  await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer: Container startup failed');
482
477
  });
483
- it('should return existing session if already created', async () => {
478
+ it('should create a new session each time for multi-session support', async () => {
484
479
  // Setup mock preset
485
480
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
486
481
  id: '1',
@@ -493,13 +488,14 @@ describe('SessionManager', () => {
493
488
  upCommand: 'devcontainer up',
494
489
  execCommand: 'devcontainer exec',
495
490
  };
496
- // Create session twice
491
+ // Create session twice - multi-session API creates a new session each time
497
492
  const session1 = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
498
493
  const session2 = await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig));
499
- // Should return the same session
500
- expect(session1).toBe(session2);
501
- // spawn should only be called once
502
- expect(spawn).toHaveBeenCalledTimes(1);
494
+ // Should return different sessions for multi-session support
495
+ expect(session1).not.toBe(session2);
496
+ expect(session1.worktreePath).toBe(session2.worktreePath);
497
+ // spawn should be called once per session
498
+ expect(spawn).toHaveBeenCalledTimes(2);
503
499
  });
504
500
  it('should handle complex exec commands with multiple arguments', async () => {
505
501
  // Setup mock preset
@@ -589,8 +585,9 @@ describe('SessionManager', () => {
589
585
  execCommand: 'devcontainer exec --workspace-folder .',
590
586
  }, 'custom-preset'));
591
587
  // Should call createSessionWithPreset internally
592
- const session = sessionManager.getSession('/test/worktree');
593
- expect(session).toBeDefined();
588
+ const sessions = sessionManager.getSessionsForWorktree('/test/worktree');
589
+ expect(sessions).toHaveLength(1);
590
+ const session = sessions[0];
594
591
  expect(session?.devcontainerConfig).toEqual({
595
592
  upCommand: 'devcontainer up --workspace-folder .',
596
593
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -1,18 +1,6 @@
1
1
  import { Effect } from 'effect';
2
2
  import { Worktree, MergeConfig } from '../types/index.js';
3
3
  import { GitError, FileSystemError, ProcessError } from '../types/errors.js';
4
- /**
5
- * Get all worktree last opened timestamps
6
- */
7
- export declare function getWorktreeLastOpened(): Record<string, number>;
8
- /**
9
- * Set the last opened timestamp for a worktree
10
- */
11
- export declare function setWorktreeLastOpened(worktreePath: string, timestamp: number): void;
12
- /**
13
- * Get the last opened timestamp for a specific worktree
14
- */
15
- export declare function getWorktreeLastOpenedTime(worktreePath: string): number | undefined;
16
4
  /**
17
5
  * WorktreeService - Git worktree management with Effect-based error handling
18
6
  *
@@ -279,9 +267,7 @@ export declare class WorktreeService {
279
267
  *
280
268
  * @throws {GitError} When git worktree list command fails
281
269
  */
282
- getWorktreesEffect(options?: {
283
- sortByLastSession?: boolean;
284
- }): Effect.Effect<Worktree[], GitError, never>;
270
+ getWorktreesEffect(): Effect.Effect<Worktree[], GitError, never>;
285
271
  /**
286
272
  * Effect-based createWorktree operation
287
273
  * May fail with GitError or FileSystemError
@@ -9,26 +9,6 @@ import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeD
9
9
  import { executeWorktreePostCreationHook, executeWorktreePreCreationHook, } from '../utils/hookExecutor.js';
10
10
  import { configReader } from './config/configReader.js';
11
11
  const CLAUDE_DIR = '.claude';
12
- // Module-level state for worktree last opened tracking (runtime state, not persisted)
13
- const worktreeLastOpened = new Map();
14
- /**
15
- * Get all worktree last opened timestamps
16
- */
17
- export function getWorktreeLastOpened() {
18
- return Object.fromEntries(worktreeLastOpened);
19
- }
20
- /**
21
- * Set the last opened timestamp for a worktree
22
- */
23
- export function setWorktreeLastOpened(worktreePath, timestamp) {
24
- worktreeLastOpened.set(worktreePath, timestamp);
25
- }
26
- /**
27
- * Get the last opened timestamp for a specific worktree
28
- */
29
- export function getWorktreeLastOpenedTime(worktreePath) {
30
- return worktreeLastOpened.get(worktreePath);
31
- }
32
12
  /**
33
13
  * WorktreeService - Git worktree management with Effect-based error handling
34
14
  *
@@ -604,10 +584,9 @@ export class WorktreeService {
604
584
  *
605
585
  * @throws {GitError} When git worktree list command fails
606
586
  */
607
- getWorktreesEffect(options) {
587
+ getWorktreesEffect() {
608
588
  // eslint-disable-next-line @typescript-eslint/no-this-alias
609
589
  const self = this;
610
- const sortByLastSession = options?.sortByLastSession ?? false;
611
590
  return Effect.catchAll(Effect.try({
612
591
  try: () => {
613
592
  const output = execSync('git worktree list --porcelain', {
@@ -662,23 +641,6 @@ export class WorktreeService {
662
641
  if (mainWorktree && mainWorktree.path.includes('.git/modules')) {
663
642
  mainWorktree.path = self.gitRootPath;
664
643
  }
665
- // Sort worktrees by last session if requested
666
- if (sortByLastSession) {
667
- worktrees.sort((a, b) => {
668
- // Get last opened timestamps for both worktrees
669
- const timeA = getWorktreeLastOpenedTime(a.path);
670
- const timeB = getWorktreeLastOpenedTime(b.path);
671
- // If both timestamps are undefined, preserve original order
672
- if (timeA === undefined && timeB === undefined) {
673
- return 0;
674
- }
675
- // If only one is undefined, treat it as older (0)
676
- const compareTimeA = timeA || 0;
677
- const compareTimeB = timeB || 0;
678
- // Sort in descending order (most recent first)
679
- return compareTimeB - compareTimeA;
680
- });
681
- }
682
644
  return worktrees;
683
645
  },
684
646
  catch: (error) => error,