ccmanager 2.2.0 → 2.2.1
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/constants/statePersistence.d.ts +2 -0
- package/dist/constants/statePersistence.js +4 -0
- package/dist/services/sessionManager.js +39 -8
- package/dist/services/sessionManager.statePersistence.test.d.ts +1 -0
- package/dist/services/sessionManager.statePersistence.test.js +215 -0
- package/dist/types/index.d.ts +7 -5
- package/dist/utils/hookExecutor.test.js +18 -0
- package/dist/utils/worktreeUtils.test.js +7 -0
- package/package.json +1 -1
|
@@ -6,6 +6,7 @@ import { promisify } from 'util';
|
|
|
6
6
|
import { configurationManager } from './configurationManager.js';
|
|
7
7
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
8
8
|
import { createStateDetector } from './stateDetector.js';
|
|
9
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
9
10
|
const { Terminal } = pkg;
|
|
10
11
|
const execAsync = promisify(exec);
|
|
11
12
|
export class SessionManager extends EventEmitter {
|
|
@@ -71,10 +72,13 @@ export class SessionManager extends EventEmitter {
|
|
|
71
72
|
lastActivity: new Date(),
|
|
72
73
|
isActive: false,
|
|
73
74
|
terminal,
|
|
75
|
+
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
74
76
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
75
77
|
commandConfig,
|
|
76
78
|
detectionStrategy: options.detectionStrategy ?? 'claude',
|
|
77
|
-
devcontainerConfig: options.devcontainerConfig,
|
|
79
|
+
devcontainerConfig: options.devcontainerConfig ?? undefined,
|
|
80
|
+
pendingState: undefined,
|
|
81
|
+
pendingStateStart: undefined,
|
|
78
82
|
};
|
|
79
83
|
// Set up persistent background data handler for state detection
|
|
80
84
|
this.setupBackgroundHandler(session);
|
|
@@ -188,16 +192,39 @@ export class SessionManager extends EventEmitter {
|
|
|
188
192
|
setupBackgroundHandler(session) {
|
|
189
193
|
// Setup data handler
|
|
190
194
|
this.setupDataHandler(session);
|
|
195
|
+
// Set up interval-based state detection with persistence
|
|
191
196
|
session.stateCheckInterval = setInterval(() => {
|
|
192
197
|
const oldState = session.state;
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
198
|
+
const detectedState = this.detectTerminalState(session);
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
// If detected state is different from current state
|
|
201
|
+
if (detectedState !== oldState) {
|
|
202
|
+
// If this is a new pending state or the pending state changed
|
|
203
|
+
if (session.pendingState !== detectedState) {
|
|
204
|
+
session.pendingState = detectedState;
|
|
205
|
+
session.pendingStateStart = now;
|
|
206
|
+
}
|
|
207
|
+
else if (session.pendingState !== undefined &&
|
|
208
|
+
session.pendingStateStart !== undefined) {
|
|
209
|
+
// Check if the pending state has persisted long enough
|
|
210
|
+
const duration = now - session.pendingStateStart;
|
|
211
|
+
if (duration >= STATE_PERSISTENCE_DURATION_MS) {
|
|
212
|
+
// Confirm the state change
|
|
213
|
+
session.state = detectedState;
|
|
214
|
+
session.pendingState = undefined;
|
|
215
|
+
session.pendingStateStart = undefined;
|
|
216
|
+
// Execute status hook asynchronously (non-blocking)
|
|
217
|
+
void executeStatusHook(oldState, detectedState, session);
|
|
218
|
+
this.emit('sessionStateChanged', session);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
// Detected state matches current state, clear any pending state
|
|
224
|
+
session.pendingState = undefined;
|
|
225
|
+
session.pendingStateStart = undefined;
|
|
199
226
|
}
|
|
200
|
-
},
|
|
227
|
+
}, STATE_CHECK_INTERVAL_MS);
|
|
201
228
|
// Setup exit handler
|
|
202
229
|
this.setupExitHandler(session);
|
|
203
230
|
}
|
|
@@ -205,7 +232,11 @@ export class SessionManager extends EventEmitter {
|
|
|
205
232
|
// Clear the state check interval
|
|
206
233
|
if (session.stateCheckInterval) {
|
|
207
234
|
clearInterval(session.stateCheckInterval);
|
|
235
|
+
session.stateCheckInterval = undefined;
|
|
208
236
|
}
|
|
237
|
+
// Clear any pending state
|
|
238
|
+
session.pendingState = undefined;
|
|
239
|
+
session.pendingStateStart = undefined;
|
|
209
240
|
// Update state to idle before destroying
|
|
210
241
|
session.state = 'idle';
|
|
211
242
|
this.emit('sessionStateChanged', session);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { SessionManager } from './sessionManager.js';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
|
|
6
|
+
vi.mock('node-pty');
|
|
7
|
+
vi.mock('./configurationManager.js', () => ({
|
|
8
|
+
configurationManager: {
|
|
9
|
+
getConfig: vi.fn().mockReturnValue({
|
|
10
|
+
commands: [
|
|
11
|
+
{
|
|
12
|
+
id: 'test',
|
|
13
|
+
name: 'Test',
|
|
14
|
+
command: 'test',
|
|
15
|
+
args: [],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
defaultCommandId: 'test',
|
|
19
|
+
}),
|
|
20
|
+
getPresetById: vi.fn().mockReturnValue({
|
|
21
|
+
id: 'test',
|
|
22
|
+
name: 'Test',
|
|
23
|
+
command: 'test',
|
|
24
|
+
args: [],
|
|
25
|
+
}),
|
|
26
|
+
getDefaultPreset: vi.fn().mockReturnValue({
|
|
27
|
+
id: 'test',
|
|
28
|
+
name: 'Test',
|
|
29
|
+
command: 'test',
|
|
30
|
+
args: [],
|
|
31
|
+
}),
|
|
32
|
+
getHooks: vi.fn().mockReturnValue({}),
|
|
33
|
+
getStatusHooks: vi.fn().mockReturnValue({}),
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
describe('SessionManager - State Persistence', () => {
|
|
37
|
+
let sessionManager;
|
|
38
|
+
let mockPtyInstances;
|
|
39
|
+
let eventEmitters;
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
sessionManager = new SessionManager();
|
|
43
|
+
mockPtyInstances = new Map();
|
|
44
|
+
eventEmitters = new Map();
|
|
45
|
+
// Create mock PTY process factory
|
|
46
|
+
spawn.mockImplementation((command, args, options) => {
|
|
47
|
+
const path = options.cwd;
|
|
48
|
+
const eventEmitter = new EventEmitter();
|
|
49
|
+
eventEmitters.set(path, eventEmitter);
|
|
50
|
+
const mockPty = {
|
|
51
|
+
onData: vi.fn((callback) => {
|
|
52
|
+
eventEmitter.on('data', callback);
|
|
53
|
+
return { dispose: vi.fn() };
|
|
54
|
+
}),
|
|
55
|
+
onExit: vi.fn((callback) => {
|
|
56
|
+
eventEmitter.on('exit', callback);
|
|
57
|
+
return { dispose: vi.fn() };
|
|
58
|
+
}),
|
|
59
|
+
write: vi.fn(),
|
|
60
|
+
resize: vi.fn(),
|
|
61
|
+
kill: vi.fn(),
|
|
62
|
+
process: 'test',
|
|
63
|
+
pid: 12345 + mockPtyInstances.size,
|
|
64
|
+
};
|
|
65
|
+
mockPtyInstances.set(path, mockPty);
|
|
66
|
+
return mockPty;
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.clearAllTimers();
|
|
71
|
+
vi.useRealTimers();
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
});
|
|
74
|
+
it('should not change state immediately when detected state changes', async () => {
|
|
75
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
76
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
77
|
+
// Initial state should be busy
|
|
78
|
+
expect(session.state).toBe('busy');
|
|
79
|
+
// Simulate output that would trigger idle state
|
|
80
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
81
|
+
// Advance time less than persistence duration
|
|
82
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
83
|
+
// State should still be busy, but pending state should be set
|
|
84
|
+
expect(session.state).toBe('busy');
|
|
85
|
+
expect(session.pendingState).toBe('idle');
|
|
86
|
+
expect(session.pendingStateStart).toBeDefined();
|
|
87
|
+
});
|
|
88
|
+
it('should change state after persistence duration is met', async () => {
|
|
89
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
90
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
91
|
+
const stateChangeHandler = vi.fn();
|
|
92
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
93
|
+
// Initial state should be busy
|
|
94
|
+
expect(session.state).toBe('busy');
|
|
95
|
+
// Simulate output that would trigger idle state
|
|
96
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
97
|
+
// Advance time less than persistence duration
|
|
98
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
99
|
+
expect(session.state).toBe('busy');
|
|
100
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
101
|
+
// Advance time to exceed persistence duration
|
|
102
|
+
vi.advanceTimersByTime(STATE_PERSISTENCE_DURATION_MS);
|
|
103
|
+
// State should now be changed
|
|
104
|
+
expect(session.state).toBe('idle');
|
|
105
|
+
expect(session.pendingState).toBeUndefined();
|
|
106
|
+
expect(session.pendingStateStart).toBeUndefined();
|
|
107
|
+
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
108
|
+
});
|
|
109
|
+
it('should cancel pending state if detected state changes again before persistence', async () => {
|
|
110
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
111
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
112
|
+
// Initial state should be busy
|
|
113
|
+
expect(session.state).toBe('busy');
|
|
114
|
+
// Simulate output that would trigger idle state
|
|
115
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
116
|
+
// Advance time less than persistence duration
|
|
117
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
118
|
+
expect(session.pendingState).toBe('idle');
|
|
119
|
+
// Simulate output that would trigger waiting_input state
|
|
120
|
+
eventEmitter.emit('data', '│ Do you want to continue?');
|
|
121
|
+
// Advance time to trigger another check
|
|
122
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS);
|
|
123
|
+
// Pending state should now be waiting_input, not idle
|
|
124
|
+
expect(session.state).toBe('busy'); // Still original state
|
|
125
|
+
expect(session.pendingState).toBe('waiting_input');
|
|
126
|
+
});
|
|
127
|
+
it('should clear pending state if detected state returns to current state', async () => {
|
|
128
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
129
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
130
|
+
// Initial state should be busy
|
|
131
|
+
expect(session.state).toBe('busy');
|
|
132
|
+
// Simulate output that would trigger idle state
|
|
133
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
134
|
+
// Advance time less than persistence duration
|
|
135
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
136
|
+
expect(session.pendingState).toBe('idle');
|
|
137
|
+
expect(session.pendingStateStart).toBeDefined();
|
|
138
|
+
// Simulate output that would trigger busy state again (back to original)
|
|
139
|
+
eventEmitter.emit('data', 'ESC to interrupt');
|
|
140
|
+
// Advance time to trigger another check
|
|
141
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS);
|
|
142
|
+
// Pending state should be cleared
|
|
143
|
+
expect(session.state).toBe('busy');
|
|
144
|
+
expect(session.pendingState).toBeUndefined();
|
|
145
|
+
expect(session.pendingStateStart).toBeUndefined();
|
|
146
|
+
});
|
|
147
|
+
it('should not confirm state changes that do not persist long enough', async () => {
|
|
148
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
149
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
150
|
+
const stateChangeHandler = vi.fn();
|
|
151
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
152
|
+
// Initial state should be busy
|
|
153
|
+
expect(session.state).toBe('busy');
|
|
154
|
+
// Try to change to idle
|
|
155
|
+
eventEmitter.emit('data', 'Some idle output\n');
|
|
156
|
+
// Wait for detection but not full persistence (less than 200ms)
|
|
157
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS); // 100ms
|
|
158
|
+
// Should have pending state but not confirmed
|
|
159
|
+
expect(session.state).toBe('busy');
|
|
160
|
+
expect(session.pendingState).toBe('idle');
|
|
161
|
+
// Now change to a different state before idle persists
|
|
162
|
+
// Clear terminal first and add waiting prompt
|
|
163
|
+
eventEmitter.emit('data', '\x1b[2J\x1b[H│ Do you want to continue?\n');
|
|
164
|
+
// Advance time to detect new state but still less than persistence duration from first change
|
|
165
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS); // Another 100ms, total 200ms exactly at threshold
|
|
166
|
+
// Pending state should have changed to waiting_input
|
|
167
|
+
expect(session.state).toBe('busy'); // Still original state
|
|
168
|
+
expect(session.pendingState).toBe('waiting_input');
|
|
169
|
+
// Since states kept changing before persisting, no state change should have been confirmed
|
|
170
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
it('should properly clean up pending state when session is destroyed', async () => {
|
|
173
|
+
const session = await sessionManager.createSessionWithPreset('/test/path');
|
|
174
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
175
|
+
// Simulate output that would trigger idle state
|
|
176
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
177
|
+
// Advance time less than persistence duration
|
|
178
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
179
|
+
expect(session.pendingState).toBe('idle');
|
|
180
|
+
expect(session.pendingStateStart).toBeDefined();
|
|
181
|
+
// Destroy the session
|
|
182
|
+
sessionManager.destroySession('/test/path');
|
|
183
|
+
// Check that pending state is cleared
|
|
184
|
+
const destroyedSession = sessionManager.getSession('/test/path');
|
|
185
|
+
expect(destroyedSession).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
it('should handle multiple sessions with independent state persistence', async () => {
|
|
188
|
+
const session1 = await sessionManager.createSessionWithPreset('/test/path1');
|
|
189
|
+
const session2 = await sessionManager.createSessionWithPreset('/test/path2');
|
|
190
|
+
const eventEmitter1 = eventEmitters.get('/test/path1');
|
|
191
|
+
const eventEmitter2 = eventEmitters.get('/test/path2');
|
|
192
|
+
// Both should start as busy
|
|
193
|
+
expect(session1.state).toBe('busy');
|
|
194
|
+
expect(session2.state).toBe('busy');
|
|
195
|
+
// Simulate different outputs for each session
|
|
196
|
+
// Session 1 goes to idle
|
|
197
|
+
eventEmitter1.emit('data', 'Idle output for session 1');
|
|
198
|
+
// Session 2 goes to waiting_input
|
|
199
|
+
eventEmitter2.emit('data', '│ Do you want to continue?');
|
|
200
|
+
// Advance time to check but not confirm
|
|
201
|
+
vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
|
|
202
|
+
// Both should have pending states but not changed yet
|
|
203
|
+
expect(session1.state).toBe('busy');
|
|
204
|
+
expect(session1.pendingState).toBe('idle');
|
|
205
|
+
expect(session2.state).toBe('busy');
|
|
206
|
+
expect(session2.pendingState).toBe('waiting_input');
|
|
207
|
+
// Advance time to confirm both
|
|
208
|
+
vi.advanceTimersByTime(STATE_PERSISTENCE_DURATION_MS);
|
|
209
|
+
// Both should now be in their new states
|
|
210
|
+
expect(session1.state).toBe('idle');
|
|
211
|
+
expect(session1.pendingState).toBeUndefined();
|
|
212
|
+
expect(session2.state).toBe('waiting_input');
|
|
213
|
+
expect(session2.pendingState).toBeUndefined();
|
|
214
|
+
});
|
|
215
|
+
});
|
package/dist/types/index.d.ts
CHANGED
|
@@ -22,11 +22,13 @@ export interface Session {
|
|
|
22
22
|
lastActivity: Date;
|
|
23
23
|
isActive: boolean;
|
|
24
24
|
terminal: Terminal;
|
|
25
|
-
stateCheckInterval
|
|
26
|
-
isPrimaryCommand
|
|
27
|
-
commandConfig
|
|
28
|
-
detectionStrategy
|
|
29
|
-
devcontainerConfig
|
|
25
|
+
stateCheckInterval: NodeJS.Timeout | undefined;
|
|
26
|
+
isPrimaryCommand: boolean;
|
|
27
|
+
commandConfig: CommandConfig | undefined;
|
|
28
|
+
detectionStrategy: StateDetectionStrategy | undefined;
|
|
29
|
+
devcontainerConfig: DevcontainerConfig | undefined;
|
|
30
|
+
pendingState: SessionState | undefined;
|
|
31
|
+
pendingStateStart: number | undefined;
|
|
30
32
|
}
|
|
31
33
|
export interface SessionManager {
|
|
32
34
|
sessions: Map<string, Session>;
|
|
@@ -257,6 +257,12 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
257
257
|
outputHistory: [],
|
|
258
258
|
state: 'idle',
|
|
259
259
|
stateCheckInterval: undefined,
|
|
260
|
+
isPrimaryCommand: true,
|
|
261
|
+
commandConfig: undefined,
|
|
262
|
+
detectionStrategy: 'claude',
|
|
263
|
+
devcontainerConfig: undefined,
|
|
264
|
+
pendingState: undefined,
|
|
265
|
+
pendingStateStart: undefined,
|
|
260
266
|
lastActivity: new Date(),
|
|
261
267
|
isActive: true,
|
|
262
268
|
};
|
|
@@ -304,6 +310,12 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
304
310
|
outputHistory: [],
|
|
305
311
|
state: 'idle',
|
|
306
312
|
stateCheckInterval: undefined,
|
|
313
|
+
isPrimaryCommand: true,
|
|
314
|
+
commandConfig: undefined,
|
|
315
|
+
detectionStrategy: 'claude',
|
|
316
|
+
devcontainerConfig: undefined,
|
|
317
|
+
pendingState: undefined,
|
|
318
|
+
pendingStateStart: undefined,
|
|
307
319
|
lastActivity: new Date(),
|
|
308
320
|
isActive: true,
|
|
309
321
|
};
|
|
@@ -349,6 +361,12 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
349
361
|
outputHistory: [],
|
|
350
362
|
state: 'idle',
|
|
351
363
|
stateCheckInterval: undefined,
|
|
364
|
+
isPrimaryCommand: true,
|
|
365
|
+
commandConfig: undefined,
|
|
366
|
+
detectionStrategy: 'claude',
|
|
367
|
+
devcontainerConfig: undefined,
|
|
368
|
+
pendingState: undefined,
|
|
369
|
+
pendingStateStart: undefined,
|
|
352
370
|
lastActivity: new Date(),
|
|
353
371
|
isActive: true,
|
|
354
372
|
};
|
|
@@ -105,6 +105,13 @@ describe('prepareWorktreeItems', () => {
|
|
|
105
105
|
lastActivity: new Date(),
|
|
106
106
|
isActive: true,
|
|
107
107
|
terminal: {},
|
|
108
|
+
stateCheckInterval: undefined,
|
|
109
|
+
isPrimaryCommand: true,
|
|
110
|
+
commandConfig: undefined,
|
|
111
|
+
detectionStrategy: 'claude',
|
|
112
|
+
devcontainerConfig: undefined,
|
|
113
|
+
pendingState: undefined,
|
|
114
|
+
pendingStateStart: undefined,
|
|
108
115
|
};
|
|
109
116
|
it('should prepare basic worktree without git status', () => {
|
|
110
117
|
const items = prepareWorktreeItems([mockWorktree], []);
|