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.
- package/dist/components/App.js +137 -63
- package/dist/components/App.test.js +16 -30
- package/dist/components/Dashboard.js +3 -3
- package/dist/components/Menu.d.ts +2 -2
- package/dist/components/Menu.js +66 -140
- package/dist/components/Menu.recent-projects.test.js +8 -8
- package/dist/components/Menu.test.js +17 -17
- package/dist/components/Session.js +3 -3
- package/dist/components/SessionActions.d.ts +9 -0
- package/dist/components/SessionActions.js +29 -0
- package/dist/components/SessionRename.d.ts +8 -0
- package/dist/components/SessionRename.js +18 -0
- package/dist/constants/statusIcons.d.ts +3 -0
- package/dist/constants/statusIcons.js +3 -0
- package/dist/services/globalSessionOrchestrator.test.js +11 -5
- package/dist/services/sessionManager.autoApproval.test.js +1 -4
- package/dist/services/sessionManager.d.ts +7 -7
- package/dist/services/sessionManager.effect.test.js +17 -16
- package/dist/services/sessionManager.js +43 -48
- package/dist/services/sessionManager.statePersistence.test.js +3 -6
- package/dist/services/sessionManager.test.js +21 -24
- package/dist/services/worktreeService.d.ts +1 -15
- package/dist/services/worktreeService.js +1 -39
- package/dist/services/worktreeService.sort.test.js +141 -303
- package/dist/types/index.d.ts +37 -6
- package/dist/utils/hookExecutor.test.js +8 -0
- package/dist/utils/worktreeUtils.d.ts +12 -6
- package/dist/utils/worktreeUtils.js +116 -50
- package/dist/utils/worktreeUtils.test.js +9 -7
- package/package.json +6 -6
|
@@ -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
|
-
|
|
14
|
-
return this.sessions.get(
|
|
13
|
+
getSessionById(id) {
|
|
14
|
+
return this.sessions.get(id);
|
|
15
15
|
}
|
|
16
|
-
|
|
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(
|
|
20
|
-
this.sessions.delete(
|
|
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.
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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(
|
|
75
|
+
destroySession(sessionId: string): void;
|
|
76
76
|
/**
|
|
77
77
|
* Terminate session and cleanup resources using Effect-based error handling
|
|
78
78
|
*
|
|
79
|
-
* @param {string}
|
|
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(
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
|
153
|
-
expect(session1).toBe(session2);
|
|
154
|
-
|
|
155
|
-
|
|
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(
|
|
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.
|
|
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('
|
|
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(
|
|
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.
|
|
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
|
|
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(
|
|
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.
|
|
466
|
+
this.destroySession(session.id);
|
|
474
467
|
this.emit('sessionExit', session);
|
|
475
468
|
}
|
|
476
|
-
|
|
477
|
-
return this.sessions.get(
|
|
469
|
+
getSessionById(id) {
|
|
470
|
+
return this.sessions.get(id);
|
|
478
471
|
}
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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(
|
|
494
|
-
const session = this.sessions.get(
|
|
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
|
-
|
|
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(
|
|
537
|
-
const session = this.sessions.get(
|
|
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(
|
|
552
|
+
const timer = this.busyTimers.get(sessionId);
|
|
555
553
|
if (timer) {
|
|
556
554
|
clearTimeout(timer);
|
|
557
|
-
this.busyTimers.delete(
|
|
555
|
+
this.busyTimers.delete(sessionId);
|
|
558
556
|
}
|
|
559
|
-
this.sessions.delete(
|
|
560
|
-
this.waitingWithBottomBorder.delete(
|
|
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}
|
|
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(
|
|
579
|
+
terminateSessionEffect(sessionId) {
|
|
582
580
|
return Effect.try({
|
|
583
581
|
try: () => {
|
|
584
|
-
const session = this.sessions.get(
|
|
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
|
|
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(
|
|
605
|
+
const timer = this.busyTimers.get(sessionId);
|
|
604
606
|
if (timer) {
|
|
605
607
|
clearTimeout(timer);
|
|
606
|
-
this.busyTimers.delete(
|
|
608
|
+
this.busyTimers.delete(sessionId);
|
|
607
609
|
}
|
|
608
|
-
|
|
609
|
-
this.
|
|
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
|
|
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
|
-
|
|
691
|
-
|
|
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(
|
|
195
|
+
sessionManager.destroySession(session.id);
|
|
199
196
|
// Check that pending state is cleared
|
|
200
|
-
const
|
|
201
|
-
expect(
|
|
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
|
|
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
|
|
218
|
-
expect(session1).toBe(session2);
|
|
219
|
-
|
|
220
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
500
|
-
expect(session1).toBe(session2);
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
593
|
-
expect(
|
|
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(
|
|
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(
|
|
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,
|