ccmanager 2.2.0 → 2.2.2
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/README.md +3 -5
- package/dist/constants/statePersistence.d.ts +2 -0
- package/dist/constants/statePersistence.js +4 -0
- package/dist/services/sessionManager.js +40 -9
- package/dist/services/sessionManager.statePersistence.test.d.ts +1 -0
- package/dist/services/sessionManager.statePersistence.test.js +215 -0
- package/dist/services/stateDetector.d.ts +5 -5
- package/dist/services/stateDetector.js +7 -3
- package/dist/services/stateDetector.test.js +55 -23
- 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
package/README.md
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
# CCManager - AI Code
|
|
2
|
-
|
|
3
|
-
CCManager is a TUI application for managing multiple AI coding assistant sessions (Claude Code, Gemini CLI) across Git worktrees and projects.
|
|
1
|
+
# CCManager - AI Code Agent Session Manager
|
|
4
2
|
|
|
3
|
+
[](https://github.com/Piebald-AI/awesome-gemini-cli)
|
|
5
4
|
|
|
5
|
+
CCManager is a CLI application for managing multiple AI coding assistant sessions (Claude Code, Gemini CLI, Codex CLI) across Git worktrees and projects.
|
|
6
6
|
|
|
7
7
|
https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
9
|
## Features
|
|
12
10
|
|
|
13
11
|
- Run multiple AI assistant sessions in parallel across different Git worktrees
|
|
@@ -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 {
|
|
@@ -23,7 +24,7 @@ export class SessionManager extends EventEmitter {
|
|
|
23
24
|
// Create a detector based on the session's detection strategy
|
|
24
25
|
const strategy = session.detectionStrategy || 'claude';
|
|
25
26
|
const detector = createStateDetector(strategy);
|
|
26
|
-
return detector.detectState(session.terminal);
|
|
27
|
+
return detector.detectState(session.terminal, session.state);
|
|
27
28
|
}
|
|
28
29
|
constructor() {
|
|
29
30
|
super();
|
|
@@ -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
|
+
});
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { SessionState, Terminal, StateDetectionStrategy } from '../types/index.js';
|
|
2
2
|
export interface StateDetector {
|
|
3
|
-
detectState(terminal: Terminal): SessionState;
|
|
3
|
+
detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
4
4
|
}
|
|
5
5
|
export declare function createStateDetector(strategy?: StateDetectionStrategy): StateDetector;
|
|
6
6
|
export declare abstract class BaseStateDetector implements StateDetector {
|
|
7
|
-
abstract detectState(terminal: Terminal): SessionState;
|
|
7
|
+
abstract detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
8
8
|
protected getTerminalLines(terminal: Terminal, maxLines?: number): string[];
|
|
9
9
|
protected getTerminalContent(terminal: Terminal, maxLines?: number): string;
|
|
10
10
|
}
|
|
11
11
|
export declare class ClaudeStateDetector extends BaseStateDetector {
|
|
12
|
-
detectState(terminal: Terminal): SessionState;
|
|
12
|
+
detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
13
13
|
}
|
|
14
14
|
export declare class GeminiStateDetector extends BaseStateDetector {
|
|
15
|
-
detectState(terminal: Terminal): SessionState;
|
|
15
|
+
detectState(terminal: Terminal, _currentState: SessionState): SessionState;
|
|
16
16
|
}
|
|
17
17
|
export declare class CodexStateDetector extends BaseStateDetector {
|
|
18
|
-
detectState(terminal: Terminal): SessionState;
|
|
18
|
+
detectState(terminal: Terminal, _currentState: SessionState): SessionState;
|
|
19
19
|
}
|
|
@@ -32,9 +32,13 @@ export class BaseStateDetector {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
export class ClaudeStateDetector extends BaseStateDetector {
|
|
35
|
-
detectState(terminal) {
|
|
35
|
+
detectState(terminal, currentState) {
|
|
36
36
|
const content = this.getTerminalContent(terminal);
|
|
37
37
|
const lowerContent = content.toLowerCase();
|
|
38
|
+
// Check for ctrl+r toggle prompt - maintain current state
|
|
39
|
+
if (lowerContent.includes('ctrl+r to toggle')) {
|
|
40
|
+
return currentState;
|
|
41
|
+
}
|
|
38
42
|
// Check for waiting prompts with box character
|
|
39
43
|
if (content.includes('│ Do you want') ||
|
|
40
44
|
content.includes('│ Would you like')) {
|
|
@@ -50,7 +54,7 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
50
54
|
}
|
|
51
55
|
// https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
|
|
52
56
|
export class GeminiStateDetector extends BaseStateDetector {
|
|
53
|
-
detectState(terminal) {
|
|
57
|
+
detectState(terminal, _currentState) {
|
|
54
58
|
const content = this.getTerminalContent(terminal);
|
|
55
59
|
const lowerContent = content.toLowerCase();
|
|
56
60
|
// Check for waiting prompts with box character
|
|
@@ -68,7 +72,7 @@ export class GeminiStateDetector extends BaseStateDetector {
|
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
export class CodexStateDetector extends BaseStateDetector {
|
|
71
|
-
detectState(terminal) {
|
|
75
|
+
detectState(terminal, _currentState) {
|
|
72
76
|
const content = this.getTerminalContent(terminal);
|
|
73
77
|
const lowerContent = content.toLowerCase();
|
|
74
78
|
// Check for waiting prompts
|
|
@@ -33,7 +33,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
33
33
|
'│ > ',
|
|
34
34
|
]);
|
|
35
35
|
// Act
|
|
36
|
-
const state = detector.detectState(terminal);
|
|
36
|
+
const state = detector.detectState(terminal, 'idle');
|
|
37
37
|
// Assert
|
|
38
38
|
expect(state).toBe('waiting_input');
|
|
39
39
|
});
|
|
@@ -45,7 +45,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
45
45
|
'│ > ',
|
|
46
46
|
]);
|
|
47
47
|
// Act
|
|
48
|
-
const state = detector.detectState(terminal);
|
|
48
|
+
const state = detector.detectState(terminal, 'idle');
|
|
49
49
|
// Assert
|
|
50
50
|
expect(state).toBe('waiting_input');
|
|
51
51
|
});
|
|
@@ -56,7 +56,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
56
56
|
'Press ESC to interrupt',
|
|
57
57
|
]);
|
|
58
58
|
// Act
|
|
59
|
-
const state = detector.detectState(terminal);
|
|
59
|
+
const state = detector.detectState(terminal, 'idle');
|
|
60
60
|
// Assert
|
|
61
61
|
expect(state).toBe('busy');
|
|
62
62
|
});
|
|
@@ -67,7 +67,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
67
67
|
'press esc to interrupt the process',
|
|
68
68
|
]);
|
|
69
69
|
// Act
|
|
70
|
-
const state = detector.detectState(terminal);
|
|
70
|
+
const state = detector.detectState(terminal, 'idle');
|
|
71
71
|
// Assert
|
|
72
72
|
expect(state).toBe('busy');
|
|
73
73
|
});
|
|
@@ -79,7 +79,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
79
79
|
'> ',
|
|
80
80
|
]);
|
|
81
81
|
// Act
|
|
82
|
-
const state = detector.detectState(terminal);
|
|
82
|
+
const state = detector.detectState(terminal, 'idle');
|
|
83
83
|
// Assert
|
|
84
84
|
expect(state).toBe('idle');
|
|
85
85
|
});
|
|
@@ -87,7 +87,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
87
87
|
// Arrange
|
|
88
88
|
terminal = createMockTerminal([]);
|
|
89
89
|
// Act
|
|
90
|
-
const state = detector.detectState(terminal);
|
|
90
|
+
const state = detector.detectState(terminal, 'idle');
|
|
91
91
|
// Assert
|
|
92
92
|
expect(state).toBe('idle');
|
|
93
93
|
});
|
|
@@ -106,7 +106,7 @@ describe('ClaudeStateDetector', () => {
|
|
|
106
106
|
}
|
|
107
107
|
terminal = createMockTerminal(lines);
|
|
108
108
|
// Act
|
|
109
|
-
const state = detector.detectState(terminal);
|
|
109
|
+
const state = detector.detectState(terminal, 'idle');
|
|
110
110
|
// Assert
|
|
111
111
|
expect(state).toBe('idle'); // Should not detect the old prompt
|
|
112
112
|
});
|
|
@@ -118,10 +118,42 @@ describe('ClaudeStateDetector', () => {
|
|
|
118
118
|
'│ > ',
|
|
119
119
|
]);
|
|
120
120
|
// Act
|
|
121
|
-
const state = detector.detectState(terminal);
|
|
121
|
+
const state = detector.detectState(terminal, 'idle');
|
|
122
122
|
// Assert
|
|
123
123
|
expect(state).toBe('waiting_input'); // waiting_input should take precedence
|
|
124
124
|
});
|
|
125
|
+
it('should maintain current state when "ctrl+r to toggle" is present', () => {
|
|
126
|
+
// Arrange
|
|
127
|
+
terminal = createMockTerminal([
|
|
128
|
+
'Some output',
|
|
129
|
+
'Press Ctrl+R to toggle history search',
|
|
130
|
+
'More output',
|
|
131
|
+
]);
|
|
132
|
+
// Act - test with different current states
|
|
133
|
+
const idleState = detector.detectState(terminal, 'idle');
|
|
134
|
+
const busyState = detector.detectState(terminal, 'busy');
|
|
135
|
+
const waitingState = detector.detectState(terminal, 'waiting_input');
|
|
136
|
+
// Assert - should maintain whatever the current state was
|
|
137
|
+
expect(idleState).toBe('idle');
|
|
138
|
+
expect(busyState).toBe('busy');
|
|
139
|
+
expect(waitingState).toBe('waiting_input');
|
|
140
|
+
});
|
|
141
|
+
it('should maintain current state for various "ctrl+r" patterns', () => {
|
|
142
|
+
// Arrange - test different case variations
|
|
143
|
+
const patterns = [
|
|
144
|
+
'ctrl+r to toggle',
|
|
145
|
+
'CTRL+R TO TOGGLE',
|
|
146
|
+
'Ctrl+R to toggle history',
|
|
147
|
+
'Press ctrl+r to toggle the search',
|
|
148
|
+
];
|
|
149
|
+
for (const pattern of patterns) {
|
|
150
|
+
terminal = createMockTerminal(['Some output', pattern]);
|
|
151
|
+
// Act
|
|
152
|
+
const state = detector.detectState(terminal, 'busy');
|
|
153
|
+
// Assert - should maintain the current state
|
|
154
|
+
expect(state).toBe('busy');
|
|
155
|
+
}
|
|
156
|
+
});
|
|
125
157
|
});
|
|
126
158
|
});
|
|
127
159
|
describe('GeminiStateDetector', () => {
|
|
@@ -157,7 +189,7 @@ describe('GeminiStateDetector', () => {
|
|
|
157
189
|
'│ > ',
|
|
158
190
|
]);
|
|
159
191
|
// Act
|
|
160
|
-
const state = detector.detectState(terminal);
|
|
192
|
+
const state = detector.detectState(terminal, 'idle');
|
|
161
193
|
// Assert
|
|
162
194
|
expect(state).toBe('waiting_input');
|
|
163
195
|
});
|
|
@@ -169,7 +201,7 @@ describe('GeminiStateDetector', () => {
|
|
|
169
201
|
'│ > ',
|
|
170
202
|
]);
|
|
171
203
|
// Act
|
|
172
|
-
const state = detector.detectState(terminal);
|
|
204
|
+
const state = detector.detectState(terminal, 'idle');
|
|
173
205
|
// Assert
|
|
174
206
|
expect(state).toBe('waiting_input');
|
|
175
207
|
});
|
|
@@ -181,7 +213,7 @@ describe('GeminiStateDetector', () => {
|
|
|
181
213
|
'│ > ',
|
|
182
214
|
]);
|
|
183
215
|
// Act
|
|
184
|
-
const state = detector.detectState(terminal);
|
|
216
|
+
const state = detector.detectState(terminal, 'idle');
|
|
185
217
|
// Assert
|
|
186
218
|
expect(state).toBe('waiting_input');
|
|
187
219
|
});
|
|
@@ -192,7 +224,7 @@ describe('GeminiStateDetector', () => {
|
|
|
192
224
|
'Press ESC to cancel',
|
|
193
225
|
]);
|
|
194
226
|
// Act
|
|
195
|
-
const state = detector.detectState(terminal);
|
|
227
|
+
const state = detector.detectState(terminal, 'idle');
|
|
196
228
|
// Assert
|
|
197
229
|
expect(state).toBe('busy');
|
|
198
230
|
});
|
|
@@ -203,7 +235,7 @@ describe('GeminiStateDetector', () => {
|
|
|
203
235
|
'Press Esc to cancel the operation',
|
|
204
236
|
]);
|
|
205
237
|
// Act
|
|
206
|
-
const state = detector.detectState(terminal);
|
|
238
|
+
const state = detector.detectState(terminal, 'idle');
|
|
207
239
|
// Assert
|
|
208
240
|
expect(state).toBe('busy');
|
|
209
241
|
});
|
|
@@ -214,7 +246,7 @@ describe('GeminiStateDetector', () => {
|
|
|
214
246
|
'Type your message below',
|
|
215
247
|
]);
|
|
216
248
|
// Act
|
|
217
|
-
const state = detector.detectState(terminal);
|
|
249
|
+
const state = detector.detectState(terminal, 'idle');
|
|
218
250
|
// Assert
|
|
219
251
|
expect(state).toBe('idle');
|
|
220
252
|
});
|
|
@@ -222,7 +254,7 @@ describe('GeminiStateDetector', () => {
|
|
|
222
254
|
// Arrange
|
|
223
255
|
terminal = createMockTerminal([]);
|
|
224
256
|
// Act
|
|
225
|
-
const state = detector.detectState(terminal);
|
|
257
|
+
const state = detector.detectState(terminal, 'idle');
|
|
226
258
|
// Assert
|
|
227
259
|
expect(state).toBe('idle');
|
|
228
260
|
});
|
|
@@ -234,7 +266,7 @@ describe('GeminiStateDetector', () => {
|
|
|
234
266
|
'│ > ',
|
|
235
267
|
]);
|
|
236
268
|
// Act
|
|
237
|
-
const state = detector.detectState(terminal);
|
|
269
|
+
const state = detector.detectState(terminal, 'idle');
|
|
238
270
|
// Assert
|
|
239
271
|
expect(state).toBe('waiting_input'); // waiting_input should take precedence
|
|
240
272
|
});
|
|
@@ -267,7 +299,7 @@ describe('CodexStateDetector', () => {
|
|
|
267
299
|
// Arrange
|
|
268
300
|
terminal = createMockTerminal(['Some output', '│Allow execution?', '│ > ']);
|
|
269
301
|
// Act
|
|
270
|
-
const state = detector.detectState(terminal);
|
|
302
|
+
const state = detector.detectState(terminal, 'idle');
|
|
271
303
|
// Assert
|
|
272
304
|
expect(state).toBe('waiting_input');
|
|
273
305
|
});
|
|
@@ -275,7 +307,7 @@ describe('CodexStateDetector', () => {
|
|
|
275
307
|
// Arrange
|
|
276
308
|
terminal = createMockTerminal(['Some output', 'Continue? [y/N]', '> ']);
|
|
277
309
|
// Act
|
|
278
|
-
const state = detector.detectState(terminal);
|
|
310
|
+
const state = detector.detectState(terminal, 'idle');
|
|
279
311
|
// Assert
|
|
280
312
|
expect(state).toBe('waiting_input');
|
|
281
313
|
});
|
|
@@ -286,7 +318,7 @@ describe('CodexStateDetector', () => {
|
|
|
286
318
|
'Press any key to continue...',
|
|
287
319
|
]);
|
|
288
320
|
// Act
|
|
289
|
-
const state = detector.detectState(terminal);
|
|
321
|
+
const state = detector.detectState(terminal, 'idle');
|
|
290
322
|
// Assert
|
|
291
323
|
expect(state).toBe('waiting_input');
|
|
292
324
|
});
|
|
@@ -298,7 +330,7 @@ describe('CodexStateDetector', () => {
|
|
|
298
330
|
'Working...',
|
|
299
331
|
]);
|
|
300
332
|
// Act
|
|
301
|
-
const state = detector.detectState(terminal);
|
|
333
|
+
const state = detector.detectState(terminal, 'idle');
|
|
302
334
|
// Assert
|
|
303
335
|
expect(state).toBe('busy');
|
|
304
336
|
});
|
|
@@ -310,7 +342,7 @@ describe('CodexStateDetector', () => {
|
|
|
310
342
|
'Working...',
|
|
311
343
|
]);
|
|
312
344
|
// Act
|
|
313
|
-
const state = detector.detectState(terminal);
|
|
345
|
+
const state = detector.detectState(terminal, 'idle');
|
|
314
346
|
// Assert
|
|
315
347
|
expect(state).toBe('busy');
|
|
316
348
|
});
|
|
@@ -318,7 +350,7 @@ describe('CodexStateDetector', () => {
|
|
|
318
350
|
// Arrange
|
|
319
351
|
terminal = createMockTerminal(['Normal output', 'Some message', 'Ready']);
|
|
320
352
|
// Act
|
|
321
|
-
const state = detector.detectState(terminal);
|
|
353
|
+
const state = detector.detectState(terminal, 'idle');
|
|
322
354
|
// Assert
|
|
323
355
|
expect(state).toBe('idle');
|
|
324
356
|
});
|
|
@@ -326,7 +358,7 @@ describe('CodexStateDetector', () => {
|
|
|
326
358
|
// Arrange
|
|
327
359
|
terminal = createMockTerminal(['press esc to cancel', '[y/N]']);
|
|
328
360
|
// Act
|
|
329
|
-
const state = detector.detectState(terminal);
|
|
361
|
+
const state = detector.detectState(terminal, 'idle');
|
|
330
362
|
// Assert
|
|
331
363
|
expect(state).toBe('waiting_input');
|
|
332
364
|
});
|
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], []);
|