ccmanager 4.1.3 → 4.1.4
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 +3 -0
- package/dist/constants/statePersistence.js +9 -0
- package/dist/services/sessionManager.autoApproval.test.js +10 -10
- package/dist/services/sessionManager.js +53 -16
- package/dist/services/sessionManager.statePersistence.test.js +184 -8
- package/dist/services/stateDetector/claude.js +3 -10
- package/dist/services/stateDetector/claude.test.js +0 -37
- package/dist/types/index.d.ts +1 -1
- package/dist/utils/mutex.d.ts +3 -0
- package/dist/utils/mutex.js +3 -0
- package/package.json +6 -6
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Duration in milliseconds that a detected state must persist before being confirmed.
|
|
2
|
+
// A higher value prevents transient flicker (e.g., brief "idle" during terminal re-renders)
|
|
3
|
+
// at the cost of slightly slower state transitions.
|
|
4
|
+
export const STATE_PERSISTENCE_DURATION_MS = 1000;
|
|
5
|
+
// Check interval for state detection in milliseconds
|
|
6
|
+
export const STATE_CHECK_INTERVAL_MS = 100;
|
|
7
|
+
// Minimum duration in current state before allowing transition to a new state.
|
|
8
|
+
// Prevents rapid back-and-forth flicker (e.g., busy → idle → busy).
|
|
9
|
+
export const STATE_MINIMUM_DURATION_MS = 1000;
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import { spawn } from './bunTerminal.js';
|
|
4
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
|
|
4
5
|
import { Effect, Either } from 'effect';
|
|
5
|
-
/**
|
|
6
|
-
* Must match `STATE_CHECK_INTERVAL_MS` in sessionManager.ts.
|
|
7
|
-
* State updates are async; the next interval tick sees the new state and runs auto-approval.
|
|
8
|
-
*/
|
|
9
|
-
const STATE_CHECK_INTERVAL_MS = 100;
|
|
10
|
-
const STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE = 2;
|
|
11
6
|
const detectStateMock = vi.fn();
|
|
12
7
|
// Create a deferred promise pattern for controllable mock
|
|
13
8
|
let verifyResolve = null;
|
|
@@ -144,17 +139,17 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
144
139
|
}));
|
|
145
140
|
// Phase 1: waiting_input (auto-approval suppressed due to prior failure)
|
|
146
141
|
detectStateMock.mockReturnValue('waiting_input');
|
|
147
|
-
await vi.advanceTimersByTimeAsync(
|
|
142
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
|
|
148
143
|
expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
149
144
|
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
|
|
150
145
|
// Phase 2: busy - should reset the failure flag
|
|
151
146
|
detectStateMock.mockReturnValue('busy');
|
|
152
|
-
await vi.advanceTimersByTimeAsync(
|
|
147
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
|
|
153
148
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
154
149
|
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
|
|
155
150
|
// Phase 3: waiting_input again - should trigger pending_auto_approval
|
|
156
151
|
detectStateMock.mockReturnValue('waiting_input');
|
|
157
|
-
await vi.advanceTimersByTimeAsync(
|
|
152
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
|
|
158
153
|
// State should now be pending_auto_approval (waiting for verification)
|
|
159
154
|
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
160
155
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
@@ -170,6 +165,8 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
170
165
|
...data,
|
|
171
166
|
state: 'pending_auto_approval',
|
|
172
167
|
autoApprovalAbortController: abortController,
|
|
168
|
+
pendingState: 'pending_auto_approval',
|
|
169
|
+
pendingStateStart: Date.now(),
|
|
173
170
|
}));
|
|
174
171
|
const handler = vi.fn();
|
|
175
172
|
sessionManager.on('sessionStateChanged', handler);
|
|
@@ -184,6 +181,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
184
181
|
expect(stateData.autoApprovalAbortController).toBeUndefined();
|
|
185
182
|
expect(stateData.autoApprovalFailed).toBe(true);
|
|
186
183
|
expect(stateData.state).toBe('waiting_input');
|
|
184
|
+
expect(stateData.pendingState).toBeUndefined();
|
|
187
185
|
expect(handler).toHaveBeenCalledWith(session);
|
|
188
186
|
sessionManager.off('sessionStateChanged', handler);
|
|
189
187
|
});
|
|
@@ -195,7 +193,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
195
193
|
sessionManager.on('sessionStateChanged', handler);
|
|
196
194
|
// Phase 1: waiting_input → pending_auto_approval
|
|
197
195
|
detectStateMock.mockReturnValue('waiting_input');
|
|
198
|
-
await vi.advanceTimersByTimeAsync(
|
|
196
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
|
|
199
197
|
// State should be pending_auto_approval (waiting for verification)
|
|
200
198
|
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
201
199
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
@@ -206,6 +204,8 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
206
204
|
await vi.waitFor(() => {
|
|
207
205
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
208
206
|
});
|
|
207
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
208
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
209
209
|
// Verify Enter key was sent to approve
|
|
210
210
|
expect(mockPty.write).toHaveBeenCalledWith('\r');
|
|
211
211
|
// Verify sessionStateChanged was emitted with session containing state=busy
|
|
@@ -6,8 +6,7 @@ import { spawn as childSpawn } from 'child_process';
|
|
|
6
6
|
import { configReader } from './config/configReader.js';
|
|
7
7
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
8
8
|
import { createStateDetector } from './stateDetector/index.js';
|
|
9
|
-
|
|
10
|
-
const STATE_CHECK_INTERVAL_MS = 100;
|
|
9
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
|
|
11
10
|
import { Effect, Either } from 'effect';
|
|
12
11
|
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
13
12
|
import { autoApprovalVerifier } from './autoApprovalVerifier.js';
|
|
@@ -177,6 +176,9 @@ export class SessionManager extends EventEmitter {
|
|
|
177
176
|
await session.stateMutex.update(data => ({
|
|
178
177
|
...data,
|
|
179
178
|
state: newState,
|
|
179
|
+
pendingState: undefined,
|
|
180
|
+
pendingStateStart: undefined,
|
|
181
|
+
stateConfirmedAt: Date.now(),
|
|
180
182
|
...additionalUpdates,
|
|
181
183
|
}));
|
|
182
184
|
if (oldState !== newState) {
|
|
@@ -359,27 +361,60 @@ export class SessionManager extends EventEmitter {
|
|
|
359
361
|
setupBackgroundHandler(session) {
|
|
360
362
|
// Setup data handler
|
|
361
363
|
this.setupDataHandler(session);
|
|
362
|
-
// Set up interval-based state detection
|
|
364
|
+
// Set up interval-based state detection with persistence
|
|
363
365
|
session.stateCheckInterval = setInterval(() => {
|
|
364
366
|
const stateData = session.stateMutex.getSnapshot();
|
|
365
367
|
const oldState = stateData.state;
|
|
366
368
|
const detectedState = this.detectTerminalState(session);
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
// If detected state is different from current state
|
|
367
371
|
if (detectedState !== oldState) {
|
|
368
|
-
//
|
|
369
|
-
if (stateData.
|
|
370
|
-
|
|
371
|
-
|
|
372
|
+
// If this is a new pending state or the pending state changed
|
|
373
|
+
if (stateData.pendingState !== detectedState) {
|
|
374
|
+
void session.stateMutex.update(data => ({
|
|
375
|
+
...data,
|
|
376
|
+
pendingState: detectedState,
|
|
377
|
+
pendingStateStart: now,
|
|
378
|
+
}));
|
|
372
379
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
380
|
+
else if (stateData.pendingState !== undefined &&
|
|
381
|
+
stateData.pendingStateStart !== undefined) {
|
|
382
|
+
// Check if the pending state has persisted long enough
|
|
383
|
+
// and that the current state has been active for the minimum duration
|
|
384
|
+
const duration = now - stateData.pendingStateStart;
|
|
385
|
+
const timeInCurrentState = now - stateData.stateConfirmedAt;
|
|
386
|
+
if (duration >= STATE_PERSISTENCE_DURATION_MS &&
|
|
387
|
+
timeInCurrentState >= STATE_MINIMUM_DURATION_MS) {
|
|
388
|
+
// Cancel auto-approval verification if state is changing away from pending_auto_approval
|
|
389
|
+
if (stateData.autoApprovalAbortController &&
|
|
390
|
+
detectedState !== 'pending_auto_approval') {
|
|
391
|
+
this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
|
|
392
|
+
}
|
|
393
|
+
// Build additional updates for auto-approval reset
|
|
394
|
+
const additionalUpdates = {};
|
|
395
|
+
// If we previously blocked auto-approval and have moved out of a user prompt,
|
|
396
|
+
// allow future auto-approval attempts.
|
|
397
|
+
if (stateData.autoApprovalFailed &&
|
|
398
|
+
detectedState !== 'waiting_input' &&
|
|
399
|
+
detectedState !== 'pending_auto_approval') {
|
|
400
|
+
additionalUpdates.autoApprovalFailed = false;
|
|
401
|
+
additionalUpdates.autoApprovalReason = undefined;
|
|
402
|
+
}
|
|
403
|
+
// Confirm the state change with hook execution
|
|
404
|
+
void this.updateSessionState(session, detectedState, additionalUpdates);
|
|
405
|
+
}
|
|
381
406
|
}
|
|
382
|
-
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
// Detected state matches current state, clear any pending state
|
|
410
|
+
// and update stateConfirmedAt so the minimum duration guard
|
|
411
|
+
// tracks "last time current state was seen" rather than "first confirmed"
|
|
412
|
+
void session.stateMutex.update(data => ({
|
|
413
|
+
...data,
|
|
414
|
+
pendingState: undefined,
|
|
415
|
+
pendingStateStart: undefined,
|
|
416
|
+
stateConfirmedAt: now,
|
|
417
|
+
}));
|
|
383
418
|
}
|
|
384
419
|
// Handle auto-approval if state is pending_auto_approval and no verification is in progress.
|
|
385
420
|
// This ensures auto-approval is retried when the state remains pending_auto_approval
|
|
@@ -467,6 +502,8 @@ export class SessionManager extends EventEmitter {
|
|
|
467
502
|
...data,
|
|
468
503
|
autoApprovalFailed: true,
|
|
469
504
|
autoApprovalReason: reason,
|
|
505
|
+
pendingState: undefined,
|
|
506
|
+
pendingStateStart: undefined,
|
|
470
507
|
}));
|
|
471
508
|
}
|
|
472
509
|
}
|
|
@@ -3,9 +3,8 @@ import { Either } from 'effect';
|
|
|
3
3
|
import { SessionManager } from './sessionManager.js';
|
|
4
4
|
import { spawn } from './bunTerminal.js';
|
|
5
5
|
import { EventEmitter } from 'events';
|
|
6
|
+
import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
|
|
6
7
|
import { IDLE_DEBOUNCE_MS } from './stateDetector/claude.js';
|
|
7
|
-
/** Must match `STATE_CHECK_INTERVAL_MS` in sessionManager.ts */
|
|
8
|
-
const STATE_CHECK_INTERVAL_MS = 100;
|
|
9
8
|
vi.mock('./bunTerminal.js', () => ({
|
|
10
9
|
spawn: vi.fn(function () {
|
|
11
10
|
return null;
|
|
@@ -42,7 +41,7 @@ vi.mock('./config/configReader.js', () => ({
|
|
|
42
41
|
setAutoApprovalEnabled: vi.fn(),
|
|
43
42
|
},
|
|
44
43
|
}));
|
|
45
|
-
describe('SessionManager -
|
|
44
|
+
describe('SessionManager - State Persistence', () => {
|
|
46
45
|
let sessionManager;
|
|
47
46
|
let mockPtyInstances;
|
|
48
47
|
let eventEmitters;
|
|
@@ -51,6 +50,7 @@ describe('SessionManager - state detection', () => {
|
|
|
51
50
|
sessionManager = new SessionManager();
|
|
52
51
|
mockPtyInstances = new Map();
|
|
53
52
|
eventEmitters = new Map();
|
|
53
|
+
// Create mock PTY process factory
|
|
54
54
|
spawn.mockImplementation((command, args, options) => {
|
|
55
55
|
const path = options.cwd;
|
|
56
56
|
const eventEmitter = new EventEmitter();
|
|
@@ -79,35 +79,211 @@ describe('SessionManager - state detection', () => {
|
|
|
79
79
|
vi.useRealTimers();
|
|
80
80
|
vi.clearAllMocks();
|
|
81
81
|
});
|
|
82
|
-
it('
|
|
82
|
+
it('should not change state immediately when detected state changes', async () => {
|
|
83
83
|
const { Effect } = await import('effect');
|
|
84
84
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
85
85
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
86
|
+
// Initial state should be busy
|
|
86
87
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
88
|
+
// Simulate output that would trigger idle state
|
|
87
89
|
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
90
|
+
// Advance time past idle debounce so detector starts returning idle,
|
|
91
|
+
// but not enough for persistence to confirm
|
|
88
92
|
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
93
|
+
// State should still be busy, but pending state should be set
|
|
94
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
95
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
96
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
97
|
+
});
|
|
98
|
+
it('should change state after both persistence and minimum duration are met', async () => {
|
|
99
|
+
const { Effect } = await import('effect');
|
|
100
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
101
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
102
|
+
const stateChangeHandler = vi.fn();
|
|
103
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
104
|
+
// Initial state should be busy
|
|
105
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
106
|
+
// Simulate output that would trigger idle state
|
|
107
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
108
|
+
// Advance time past idle debounce but not persistence+minimum
|
|
109
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
110
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
111
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
112
|
+
// Advance time past both persistence and minimum duration
|
|
113
|
+
await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS);
|
|
114
|
+
// State should now be changed
|
|
89
115
|
expect(session.stateMutex.getSnapshot().state).toBe('idle');
|
|
116
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
117
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
118
|
+
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
90
119
|
});
|
|
91
|
-
it('
|
|
120
|
+
it('should cancel pending state if detected state changes again before persistence', async () => {
|
|
92
121
|
const { Effect } = await import('effect');
|
|
93
122
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
94
123
|
const eventEmitter = eventEmitters.get('/test/path');
|
|
124
|
+
// Initial state should be busy
|
|
125
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
126
|
+
// Simulate output that would trigger idle state
|
|
127
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
128
|
+
// Advance time past idle debounce so detector starts returning idle
|
|
129
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
130
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
131
|
+
// Simulate output that would trigger waiting_input state
|
|
95
132
|
eventEmitter.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
133
|
+
// Advance time to trigger another check (use async to process mutex updates)
|
|
134
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
135
|
+
// Pending state should now be waiting_input, not idle
|
|
136
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
|
|
137
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
138
|
+
});
|
|
139
|
+
it('should clear pending state if detected state returns to current state', async () => {
|
|
140
|
+
const { Effect } = await import('effect');
|
|
141
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
142
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
143
|
+
// Initial state should be busy
|
|
144
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
145
|
+
// Simulate output that would trigger idle state
|
|
146
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
147
|
+
// Advance time past idle debounce so detector starts returning idle
|
|
148
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
149
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
150
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
151
|
+
// Simulate output that would trigger busy state again (back to original)
|
|
152
|
+
eventEmitter.emit('data', 'ESC to interrupt');
|
|
153
|
+
// Advance time to trigger another check (use async to process mutex updates)
|
|
96
154
|
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
97
|
-
|
|
155
|
+
// Pending state should be cleared
|
|
156
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
157
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
158
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
98
159
|
});
|
|
99
|
-
it('
|
|
160
|
+
it('should not confirm state changes that do not persist long enough', async () => {
|
|
161
|
+
const { Effect } = await import('effect');
|
|
162
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
163
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
164
|
+
const stateChangeHandler = vi.fn();
|
|
165
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
166
|
+
// Initial state should be busy
|
|
167
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
168
|
+
// Try to change to idle
|
|
169
|
+
eventEmitter.emit('data', 'Some idle output\n');
|
|
170
|
+
// Wait past idle debounce so detector returns idle, then one check
|
|
171
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
172
|
+
// Should have pending state but not confirmed
|
|
173
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
174
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
175
|
+
// Now change to a different state before idle persists
|
|
176
|
+
// Clear terminal first and add waiting prompt
|
|
177
|
+
eventEmitter.emit('data', '\x1b[2J\x1b[HDo you want to continue?\n❯ 1. Yes');
|
|
178
|
+
// Advance time to detect new state
|
|
179
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
180
|
+
// Pending state should have changed to waiting_input
|
|
181
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
|
|
182
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
|
|
183
|
+
// Since states kept changing before persisting, no state change should have been confirmed
|
|
184
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
it('should properly clean up pending state when session is destroyed', async () => {
|
|
187
|
+
const { Effect } = await import('effect');
|
|
188
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
189
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
190
|
+
// Simulate output that would trigger idle state
|
|
191
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
192
|
+
// Advance time past idle debounce so detector returns idle
|
|
193
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
194
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
|
|
195
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
|
|
196
|
+
// Destroy the session
|
|
197
|
+
sessionManager.destroySession(session.id);
|
|
198
|
+
// Check that pending state is cleared
|
|
199
|
+
const remainingSessions = sessionManager.getSessionsForWorktree('/test/path');
|
|
200
|
+
expect(remainingSessions).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
it('should not transition state before minimum duration in current state has elapsed', async () => {
|
|
203
|
+
const { Effect } = await import('effect');
|
|
204
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
205
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
206
|
+
const stateChangeHandler = vi.fn();
|
|
207
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
208
|
+
// Initial state should be busy
|
|
209
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
210
|
+
// Simulate output that would trigger idle state
|
|
211
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
212
|
+
// Advance past idle debounce but not past persistence + minimum duration
|
|
213
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS +
|
|
214
|
+
STATE_PERSISTENCE_DURATION_MS -
|
|
215
|
+
STATE_CHECK_INTERVAL_MS);
|
|
216
|
+
// State should still be busy because minimum duration hasn't elapsed
|
|
217
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
218
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
it('should transition state after both persistence and minimum duration are met', async () => {
|
|
221
|
+
const { Effect } = await import('effect');
|
|
222
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
223
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
224
|
+
const stateChangeHandler = vi.fn();
|
|
225
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
226
|
+
// Initial state should be busy
|
|
227
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
228
|
+
// Simulate output that would trigger idle state
|
|
229
|
+
eventEmitter.emit('data', 'Some output without busy indicators');
|
|
230
|
+
// Advance past idle debounce + persistence/minimum duration
|
|
231
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
|
|
232
|
+
// State should now be idle since both durations are satisfied
|
|
233
|
+
expect(session.stateMutex.getSnapshot().state).toBe('idle');
|
|
234
|
+
expect(stateChangeHandler).toHaveBeenCalledWith(session);
|
|
235
|
+
});
|
|
236
|
+
it('should not transition during brief screen redraw even after long time in current state', async () => {
|
|
237
|
+
const { Effect } = await import('effect');
|
|
238
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
239
|
+
const eventEmitter = eventEmitters.get('/test/path');
|
|
240
|
+
const stateChangeHandler = vi.fn();
|
|
241
|
+
sessionManager.on('sessionStateChanged', stateChangeHandler);
|
|
242
|
+
// Initial state should be busy
|
|
243
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
244
|
+
// Keep busy state active for a long time (simulating normal operation)
|
|
245
|
+
// Each check re-detects "busy" and updates stateConfirmedAt
|
|
246
|
+
eventEmitter.emit('data', 'ESC to interrupt');
|
|
247
|
+
await vi.advanceTimersByTimeAsync(2000); // 2 seconds of confirmed busy
|
|
248
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
249
|
+
// Now simulate a brief screen redraw: busy indicators disappear temporarily
|
|
250
|
+
eventEmitter.emit('data', '\x1b[2J\x1b[H'); // Clear screen
|
|
251
|
+
// Idle debounce prevents the detector from returning idle for IDLE_DEBOUNCE_MS,
|
|
252
|
+
// so during a brief redraw (< 1500ms), no pending idle state is set.
|
|
253
|
+
// Advance a short time — still within the idle debounce window
|
|
254
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3); // 300ms
|
|
255
|
+
// State should still be busy — idle debounce hasn't elapsed
|
|
256
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
257
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
258
|
+
// Simulate busy indicators coming back (screen redraw complete)
|
|
259
|
+
eventEmitter.emit('data', 'ESC to interrupt');
|
|
260
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
261
|
+
// State should still be busy and pending should be cleared
|
|
262
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
263
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
264
|
+
expect(stateChangeHandler).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
it('should handle multiple sessions with independent state persistence', async () => {
|
|
100
267
|
const { Effect } = await import('effect');
|
|
101
268
|
const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
|
|
102
269
|
const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path2'));
|
|
103
270
|
const eventEmitter1 = eventEmitters.get('/test/path1');
|
|
104
271
|
const eventEmitter2 = eventEmitters.get('/test/path2');
|
|
272
|
+
// Both should start as busy
|
|
105
273
|
expect(session1.stateMutex.getSnapshot().state).toBe('busy');
|
|
106
274
|
expect(session2.stateMutex.getSnapshot().state).toBe('busy');
|
|
275
|
+
// Simulate different outputs for each session
|
|
276
|
+
// Session 1 goes to idle
|
|
107
277
|
eventEmitter1.emit('data', 'Idle output for session 1');
|
|
278
|
+
// Session 2 goes to waiting_input (no idle debounce for waiting_input)
|
|
108
279
|
eventEmitter2.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
109
|
-
|
|
280
|
+
// Advance past idle debounce for session 1 + persistence/minimum for both
|
|
281
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
|
|
282
|
+
// Both should now be in their new states
|
|
283
|
+
// Session 2 (waiting_input) transitions faster since it's not debounced
|
|
110
284
|
expect(session1.stateMutex.getSnapshot().state).toBe('idle');
|
|
285
|
+
expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
111
286
|
expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
287
|
+
expect(session2.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
112
288
|
});
|
|
113
289
|
});
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { BaseStateDetector } from './base.js';
|
|
2
|
-
// Spinner
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// Matches spinner activity labels like "✽ Tempering…", "✳ Simplifying…", or "· Misting…"
|
|
2
|
+
// Spinner characters used by Claude Code during active processing
|
|
3
|
+
const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆';
|
|
4
|
+
// Matches spinner activity labels like "✽ Tempering…" or "✳ Simplifying recompute_tangents…"
|
|
6
5
|
const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
|
|
7
|
-
// Session stats above the prompt, e.g. "(9m 21s · ↓ 13.7k tokens)" — requires parens, a digit, and "tokens"
|
|
8
|
-
const TOKEN_STATS_LINE_PATTERN = /\([^)]*\d[^)]*tokens\s*\)/i;
|
|
9
6
|
const BUSY_LOOKBACK_LINES = 5;
|
|
10
7
|
// Workaround: Claude Code sometimes appears idle in terminal output while
|
|
11
8
|
// still actively processing (busy). To mitigate false idle transitions,
|
|
@@ -125,10 +122,6 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
125
122
|
if (SPINNER_ACTIVITY_PATTERN.test(abovePromptBox)) {
|
|
126
123
|
return 'busy';
|
|
127
124
|
}
|
|
128
|
-
// Usage/time + token count line (often shown above the prompt while a turn is active)
|
|
129
|
-
if (TOKEN_STATS_LINE_PATTERN.test(abovePromptBox)) {
|
|
130
|
-
return 'busy';
|
|
131
|
-
}
|
|
132
125
|
// Otherwise idle (debounced)
|
|
133
126
|
return this.debounceIdle(terminal, currentState);
|
|
134
127
|
}
|
|
@@ -330,43 +330,6 @@ describe('ClaudeStateDetector', () => {
|
|
|
330
330
|
// Assert
|
|
331
331
|
expect(state).toBe('busy');
|
|
332
332
|
});
|
|
333
|
-
it('should detect busy for middle-dot activity label "· …ing…" (e.g. · Misting…)', () => {
|
|
334
|
-
terminal = createMockTerminal([
|
|
335
|
-
'· Misting…',
|
|
336
|
-
' ⎿ Tip: Run /terminal-setup to enable convenient terminal integration',
|
|
337
|
-
'──────────────────────────────',
|
|
338
|
-
'❯',
|
|
339
|
-
'──────────────────────────────',
|
|
340
|
-
]);
|
|
341
|
-
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
342
|
-
});
|
|
343
|
-
it('should detect busy when token stats line is above prompt box without interrupt or spinner', () => {
|
|
344
|
-
terminal = createMockTerminal([
|
|
345
|
-
'(9m 21s · ↓ 13.7k tokens)',
|
|
346
|
-
'──────────────────────────────',
|
|
347
|
-
'❯',
|
|
348
|
-
'──────────────────────────────',
|
|
349
|
-
]);
|
|
350
|
-
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
351
|
-
});
|
|
352
|
-
it('should detect busy for token stats line with varied spacing and casing', () => {
|
|
353
|
-
terminal = createMockTerminal([
|
|
354
|
-
' ( 1m · 500 TOKENS ) ',
|
|
355
|
-
'──────────────────────────────',
|
|
356
|
-
'❯',
|
|
357
|
-
'──────────────────────────────',
|
|
358
|
-
]);
|
|
359
|
-
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
360
|
-
});
|
|
361
|
-
it('should not treat parenthetical text with "tokens" but no digit as busy', () => {
|
|
362
|
-
terminal = createMockTerminal([
|
|
363
|
-
'(see tokens in docs)',
|
|
364
|
-
'──────────────────────────────',
|
|
365
|
-
'❯',
|
|
366
|
-
'──────────────────────────────',
|
|
367
|
-
]);
|
|
368
|
-
expect(detectStateAfterDebounce(detector, terminal, 'idle')).toBe('idle');
|
|
369
|
-
});
|
|
370
333
|
it('should detect busy with various spinner characters', () => {
|
|
371
334
|
const spinnerChars = [
|
|
372
335
|
'✱',
|
package/dist/types/index.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface Session {
|
|
|
38
38
|
/**
|
|
39
39
|
* Mutex-protected session state data.
|
|
40
40
|
* Access via stateMutex.runExclusive() or stateMutex.update() to ensure thread-safe operations.
|
|
41
|
-
* Contains: state,
|
|
41
|
+
* Contains: state, pendingState, pendingStateStart, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController
|
|
42
42
|
*/
|
|
43
43
|
stateMutex: Mutex<SessionStateData>;
|
|
44
44
|
/**
|
package/dist/utils/mutex.d.ts
CHANGED
|
@@ -42,6 +42,9 @@ export declare class Mutex<T> {
|
|
|
42
42
|
*/
|
|
43
43
|
export interface SessionStateData {
|
|
44
44
|
state: import('../types/index.js').SessionState;
|
|
45
|
+
pendingState: import('../types/index.js').SessionState | undefined;
|
|
46
|
+
pendingStateStart: number | undefined;
|
|
47
|
+
stateConfirmedAt: number;
|
|
45
48
|
autoApprovalFailed: boolean;
|
|
46
49
|
autoApprovalReason: string | undefined;
|
|
47
50
|
autoApprovalAbortController: AbortController | undefined;
|
package/dist/utils/mutex.js
CHANGED
|
@@ -80,6 +80,9 @@ export class Mutex {
|
|
|
80
80
|
export function createInitialSessionStateData() {
|
|
81
81
|
return {
|
|
82
82
|
state: 'busy',
|
|
83
|
+
pendingState: undefined,
|
|
84
|
+
pendingStateStart: undefined,
|
|
85
|
+
stateConfirmedAt: Date.now(),
|
|
83
86
|
autoApprovalFailed: false,
|
|
84
87
|
autoApprovalReason: undefined,
|
|
85
88
|
autoApprovalAbortController: undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.4",
|
|
4
4
|
"description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kodai Kabasawa",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"bin"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.1.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.1.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.1.4",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.4",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.4",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.4",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.4"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|