ccmanager 4.1.5 → 4.1.7
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/Session.js +7 -10
- package/dist/services/sessionManager.autoApproval.test.js +24 -12
- package/dist/services/sessionManager.d.ts +3 -0
- package/dist/services/sessionManager.effect.test.js +14 -2
- package/dist/services/sessionManager.js +80 -58
- package/dist/services/sessionManager.statePersistence.test.js +8 -184
- package/dist/services/sessionManager.test.js +85 -4
- package/dist/services/stateDetector/claude.js +10 -3
- package/dist/services/stateDetector/claude.test.js +37 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/utils/hookExecutor.test.js +4 -0
- package/dist/utils/mutex.d.ts +0 -3
- package/dist/utils/mutex.js +0 -3
- package/dist/utils/worktreeUtils.test.js +1 -0
- package/package.json +6 -6
- package/dist/constants/statePersistence.d.ts +0 -3
- package/dist/constants/statePersistence.js +0 -9
|
@@ -65,9 +65,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
65
65
|
stdout.write('\x1B[2J\x1B[H');
|
|
66
66
|
// Restore the current terminal state from the headless xterm snapshot.
|
|
67
67
|
// The xterm serialize addon relies on auto-wrap (DECAWM) being enabled to
|
|
68
|
-
// render wrapped lines
|
|
69
|
-
// characters to naturally overflow to the next line
|
|
70
|
-
//
|
|
68
|
+
// render wrapped lines. It omits row separators for wrapped rows and expects
|
|
69
|
+
// characters to naturally overflow to the next line, so auto-wrap must stay
|
|
70
|
+
// enabled while writing the snapshot and only be disabled afterward.
|
|
71
71
|
const handleSessionRestore = (restoredSession, restoreSnapshot) => {
|
|
72
72
|
if (restoredSession.id === session.id) {
|
|
73
73
|
if (restoreSnapshot.length > 0) {
|
|
@@ -109,15 +109,12 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
109
109
|
/* empty */
|
|
110
110
|
}
|
|
111
111
|
// Mark session as active after resizing so the restore snapshot matches
|
|
112
|
-
// the current terminal dimensions.
|
|
113
|
-
//
|
|
114
|
-
// we proceed.
|
|
112
|
+
// the current terminal dimensions. setSessionActive synchronously emits the
|
|
113
|
+
// restore event, so the snapshot is written to stdout before we proceed.
|
|
115
114
|
sessionManager.setSessionActive(session.id, true);
|
|
116
115
|
// Prevent line wrapping from drifting redraws in TUIs that rely on
|
|
117
|
-
// cursor-up clears.
|
|
118
|
-
//
|
|
119
|
-
// enabled — it omits row separators for wrapped rows, expecting characters
|
|
120
|
-
// to naturally overflow to the next line.
|
|
116
|
+
// cursor-up clears. This must happen after the restore snapshot write,
|
|
117
|
+
// otherwise wrapped restore content can overlap on the same row.
|
|
121
118
|
stdout.write('\x1b[?7l');
|
|
122
119
|
// Handle terminal resize
|
|
123
120
|
const handleResize = () => {
|
|
@@ -1,8 +1,13 @@
|
|
|
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';
|
|
5
4
|
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;
|
|
6
11
|
const detectStateMock = vi.fn();
|
|
7
12
|
// Create a deferred promise pattern for controllable mock
|
|
8
13
|
let verifyResolve = null;
|
|
@@ -64,13 +69,25 @@ vi.mock('@xterm/addon-serialize', () => ({
|
|
|
64
69
|
vi.mock('@xterm/headless', () => ({
|
|
65
70
|
default: {
|
|
66
71
|
Terminal: vi.fn().mockImplementation(function () {
|
|
72
|
+
const normalBuffer = {
|
|
73
|
+
type: 'normal',
|
|
74
|
+
baseY: 0,
|
|
75
|
+
cursorY: 0,
|
|
76
|
+
cursorX: 0,
|
|
77
|
+
length: 0,
|
|
78
|
+
getLine: vi.fn(),
|
|
79
|
+
};
|
|
67
80
|
return {
|
|
68
81
|
rows: 24,
|
|
69
82
|
cols: 80,
|
|
70
83
|
buffer: {
|
|
71
|
-
active:
|
|
72
|
-
|
|
84
|
+
active: normalBuffer,
|
|
85
|
+
normal: normalBuffer,
|
|
86
|
+
alternate: {
|
|
87
|
+
type: 'alternate',
|
|
73
88
|
baseY: 0,
|
|
89
|
+
cursorY: 0,
|
|
90
|
+
cursorX: 0,
|
|
74
91
|
length: 0,
|
|
75
92
|
getLine: vi.fn(),
|
|
76
93
|
},
|
|
@@ -139,17 +156,17 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
139
156
|
}));
|
|
140
157
|
// Phase 1: waiting_input (auto-approval suppressed due to prior failure)
|
|
141
158
|
detectStateMock.mockReturnValue('waiting_input');
|
|
142
|
-
await vi.advanceTimersByTimeAsync(
|
|
159
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
|
|
143
160
|
expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
144
161
|
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
|
|
145
162
|
// Phase 2: busy - should reset the failure flag
|
|
146
163
|
detectStateMock.mockReturnValue('busy');
|
|
147
|
-
await vi.advanceTimersByTimeAsync(
|
|
164
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
|
|
148
165
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
149
166
|
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
|
|
150
167
|
// Phase 3: waiting_input again - should trigger pending_auto_approval
|
|
151
168
|
detectStateMock.mockReturnValue('waiting_input');
|
|
152
|
-
await vi.advanceTimersByTimeAsync(
|
|
169
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
|
|
153
170
|
// State should now be pending_auto_approval (waiting for verification)
|
|
154
171
|
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
155
172
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
@@ -165,8 +182,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
165
182
|
...data,
|
|
166
183
|
state: 'pending_auto_approval',
|
|
167
184
|
autoApprovalAbortController: abortController,
|
|
168
|
-
pendingState: 'pending_auto_approval',
|
|
169
|
-
pendingStateStart: Date.now(),
|
|
170
185
|
}));
|
|
171
186
|
const handler = vi.fn();
|
|
172
187
|
sessionManager.on('sessionStateChanged', handler);
|
|
@@ -181,7 +196,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
181
196
|
expect(stateData.autoApprovalAbortController).toBeUndefined();
|
|
182
197
|
expect(stateData.autoApprovalFailed).toBe(true);
|
|
183
198
|
expect(stateData.state).toBe('waiting_input');
|
|
184
|
-
expect(stateData.pendingState).toBeUndefined();
|
|
185
199
|
expect(handler).toHaveBeenCalledWith(session);
|
|
186
200
|
sessionManager.off('sessionStateChanged', handler);
|
|
187
201
|
});
|
|
@@ -193,7 +207,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
193
207
|
sessionManager.on('sessionStateChanged', handler);
|
|
194
208
|
// Phase 1: waiting_input → pending_auto_approval
|
|
195
209
|
detectStateMock.mockReturnValue('waiting_input');
|
|
196
|
-
await vi.advanceTimersByTimeAsync(
|
|
210
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
|
|
197
211
|
// State should be pending_auto_approval (waiting for verification)
|
|
198
212
|
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
199
213
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
@@ -204,8 +218,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
204
218
|
await vi.waitFor(() => {
|
|
205
219
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
206
220
|
});
|
|
207
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
208
|
-
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
209
221
|
// Verify Enter key was sent to approve
|
|
210
222
|
expect(mockPty.write).toHaveBeenCalledWith('\r');
|
|
211
223
|
// Verify sessionStateChanged was emitted with session containing state=busy
|
|
@@ -16,6 +16,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
16
16
|
private waitingWithBottomBorder;
|
|
17
17
|
private busyTimers;
|
|
18
18
|
private autoApprovalDisabledWorktrees;
|
|
19
|
+
private restoringSessions;
|
|
20
|
+
private bufferedRestoreData;
|
|
19
21
|
private spawn;
|
|
20
22
|
private resolvePreset;
|
|
21
23
|
detectTerminalState(session: Session): SessionState;
|
|
@@ -35,6 +37,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
35
37
|
private updateSessionState;
|
|
36
38
|
constructor();
|
|
37
39
|
private createTerminal;
|
|
40
|
+
private shouldResetRestoreScrollback;
|
|
38
41
|
private getRestoreSnapshot;
|
|
39
42
|
private createSessionInternal;
|
|
40
43
|
/**
|
|
@@ -46,13 +46,25 @@ vi.mock('@xterm/addon-serialize', () => ({
|
|
|
46
46
|
vi.mock('@xterm/headless', () => ({
|
|
47
47
|
default: {
|
|
48
48
|
Terminal: vi.fn().mockImplementation(function () {
|
|
49
|
+
const normalBuffer = {
|
|
50
|
+
type: 'normal',
|
|
51
|
+
baseY: 0,
|
|
52
|
+
cursorY: 0,
|
|
53
|
+
cursorX: 0,
|
|
54
|
+
length: 0,
|
|
55
|
+
getLine: vi.fn(),
|
|
56
|
+
};
|
|
49
57
|
return {
|
|
50
58
|
rows: 24,
|
|
51
59
|
cols: 80,
|
|
52
60
|
buffer: {
|
|
53
|
-
active:
|
|
54
|
-
|
|
61
|
+
active: normalBuffer,
|
|
62
|
+
normal: normalBuffer,
|
|
63
|
+
alternate: {
|
|
64
|
+
type: 'alternate',
|
|
55
65
|
baseY: 0,
|
|
66
|
+
cursorY: 0,
|
|
67
|
+
cursorX: 0,
|
|
56
68
|
length: 0,
|
|
57
69
|
getLine: vi.fn(),
|
|
58
70
|
},
|
|
@@ -6,7 +6,8 @@ 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
|
-
|
|
9
|
+
/** Interval in milliseconds for polling terminal state detection. */
|
|
10
|
+
const STATE_CHECK_INTERVAL_MS = 100;
|
|
10
11
|
import { Effect, Either } from 'effect';
|
|
11
12
|
import { ProcessError, ConfigError } from '../types/errors.js';
|
|
12
13
|
import { autoApprovalVerifier } from './autoApprovalVerifier.js';
|
|
@@ -19,11 +20,14 @@ import { preparePresetLaunch } from '../utils/presetPrompt.js';
|
|
|
19
20
|
const { Terminal } = pkg;
|
|
20
21
|
const TERMINAL_CONTENT_MAX_LINES = 300;
|
|
21
22
|
const TERMINAL_SCROLLBACK_LINES = 5000;
|
|
23
|
+
const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
|
|
22
24
|
export class SessionManager extends EventEmitter {
|
|
23
25
|
sessions;
|
|
24
26
|
waitingWithBottomBorder = new Map();
|
|
25
27
|
busyTimers = new Map();
|
|
26
28
|
autoApprovalDisabledWorktrees = new Set();
|
|
29
|
+
restoringSessions = new Set();
|
|
30
|
+
bufferedRestoreData = new Map();
|
|
27
31
|
async spawn(command, args, worktreePath, options = {}) {
|
|
28
32
|
const spawnOptions = {
|
|
29
33
|
name: 'xterm-256color',
|
|
@@ -176,9 +180,6 @@ export class SessionManager extends EventEmitter {
|
|
|
176
180
|
await session.stateMutex.update(data => ({
|
|
177
181
|
...data,
|
|
178
182
|
state: newState,
|
|
179
|
-
pendingState: undefined,
|
|
180
|
-
pendingStateStart: undefined,
|
|
181
|
-
stateConfirmedAt: Date.now(),
|
|
182
183
|
...additionalUpdates,
|
|
183
184
|
}));
|
|
184
185
|
if (oldState !== newState) {
|
|
@@ -199,10 +200,36 @@ export class SessionManager extends EventEmitter {
|
|
|
199
200
|
logLevel: 'off',
|
|
200
201
|
});
|
|
201
202
|
}
|
|
203
|
+
shouldResetRestoreScrollback(data) {
|
|
204
|
+
return (data.includes('\x1b[2J') ||
|
|
205
|
+
data.includes('\x1b[3J') ||
|
|
206
|
+
data.includes('\x1bc'));
|
|
207
|
+
}
|
|
202
208
|
getRestoreSnapshot(session) {
|
|
203
|
-
|
|
204
|
-
|
|
209
|
+
const activeBuffer = session.terminal.buffer.active;
|
|
210
|
+
if (activeBuffer.type !== 'normal') {
|
|
211
|
+
return session.serializer.serialize({
|
|
212
|
+
scrollback: 0,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
216
|
+
const bufferLength = normalBuffer.length;
|
|
217
|
+
if (bufferLength === 0) {
|
|
218
|
+
return '';
|
|
219
|
+
}
|
|
220
|
+
const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
|
|
221
|
+
const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
|
|
222
|
+
const rangeEnd = bufferLength - 1;
|
|
223
|
+
const snapshot = session.serializer.serialize({
|
|
224
|
+
range: {
|
|
225
|
+
start: rangeStart,
|
|
226
|
+
end: rangeEnd,
|
|
227
|
+
},
|
|
228
|
+
excludeAltBuffer: true,
|
|
205
229
|
});
|
|
230
|
+
const cursorRow = normalBuffer.cursorY + 1;
|
|
231
|
+
const cursorCol = normalBuffer.cursorX + 1;
|
|
232
|
+
return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
|
|
206
233
|
}
|
|
207
234
|
async createSessionInternal(worktreePath, ptyProcess, options = {}) {
|
|
208
235
|
const existingSessions = this.getSessionsForWorktree(worktreePath);
|
|
@@ -226,6 +253,7 @@ export class SessionManager extends EventEmitter {
|
|
|
226
253
|
isActive: false,
|
|
227
254
|
terminal,
|
|
228
255
|
serializer,
|
|
256
|
+
restoreScrollbackBaseLine: 0,
|
|
229
257
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
230
258
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
231
259
|
presetName: options.presetName,
|
|
@@ -301,9 +329,19 @@ export class SessionManager extends EventEmitter {
|
|
|
301
329
|
session.process.onData((data) => {
|
|
302
330
|
// Write data to virtual terminal
|
|
303
331
|
session.terminal.write(data);
|
|
332
|
+
if (this.shouldResetRestoreScrollback(data)) {
|
|
333
|
+
session.restoreScrollbackBaseLine =
|
|
334
|
+
session.terminal.buffer.normal.baseY;
|
|
335
|
+
}
|
|
304
336
|
session.lastActivity = new Date();
|
|
305
337
|
// Only emit data events when session is active
|
|
306
338
|
if (session.isActive) {
|
|
339
|
+
if (this.restoringSessions.has(session.id)) {
|
|
340
|
+
const bufferedData = this.bufferedRestoreData.get(session.id) ?? [];
|
|
341
|
+
bufferedData.push(data);
|
|
342
|
+
this.bufferedRestoreData.set(session.id, bufferedData);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
307
345
|
this.emit('sessionData', session, data);
|
|
308
346
|
}
|
|
309
347
|
});
|
|
@@ -361,60 +399,27 @@ export class SessionManager extends EventEmitter {
|
|
|
361
399
|
setupBackgroundHandler(session) {
|
|
362
400
|
// Setup data handler
|
|
363
401
|
this.setupDataHandler(session);
|
|
364
|
-
// Set up interval-based state detection
|
|
402
|
+
// Set up interval-based state detection
|
|
365
403
|
session.stateCheckInterval = setInterval(() => {
|
|
366
404
|
const stateData = session.stateMutex.getSnapshot();
|
|
367
405
|
const oldState = stateData.state;
|
|
368
406
|
const detectedState = this.detectTerminalState(session);
|
|
369
|
-
const now = Date.now();
|
|
370
|
-
// If detected state is different from current state
|
|
371
407
|
if (detectedState !== oldState) {
|
|
372
|
-
//
|
|
373
|
-
if (stateData.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
pendingState: detectedState,
|
|
377
|
-
pendingStateStart: now,
|
|
378
|
-
}));
|
|
408
|
+
// Cancel auto-approval verification if state is changing away from pending_auto_approval
|
|
409
|
+
if (stateData.autoApprovalAbortController &&
|
|
410
|
+
detectedState !== 'pending_auto_approval') {
|
|
411
|
+
this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
|
|
379
412
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
}
|
|
413
|
+
const additionalUpdates = {};
|
|
414
|
+
// If we previously blocked auto-approval and have moved out of a user prompt,
|
|
415
|
+
// allow future auto-approval attempts.
|
|
416
|
+
if (stateData.autoApprovalFailed &&
|
|
417
|
+
detectedState !== 'waiting_input' &&
|
|
418
|
+
detectedState !== 'pending_auto_approval') {
|
|
419
|
+
additionalUpdates.autoApprovalFailed = false;
|
|
420
|
+
additionalUpdates.autoApprovalReason = undefined;
|
|
406
421
|
}
|
|
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
|
-
}));
|
|
422
|
+
void this.updateSessionState(session, detectedState, additionalUpdates);
|
|
418
423
|
}
|
|
419
424
|
// Handle auto-approval if state is pending_auto_approval and no verification is in progress.
|
|
420
425
|
// This ensures auto-approval is retried when the state remains pending_auto_approval
|
|
@@ -471,10 +476,27 @@ export class SessionManager extends EventEmitter {
|
|
|
471
476
|
session.isActive = active;
|
|
472
477
|
if (active) {
|
|
473
478
|
session.lastAccessedAt = Date.now();
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
this.
|
|
479
|
+
this.restoringSessions.add(session.id);
|
|
480
|
+
try {
|
|
481
|
+
const restoreSnapshot = this.getRestoreSnapshot(session);
|
|
482
|
+
if (restoreSnapshot.length > 0) {
|
|
483
|
+
this.emit('sessionRestore', session, restoreSnapshot);
|
|
484
|
+
}
|
|
477
485
|
}
|
|
486
|
+
finally {
|
|
487
|
+
this.restoringSessions.delete(session.id);
|
|
488
|
+
const bufferedData = this.bufferedRestoreData.get(session.id);
|
|
489
|
+
if (bufferedData && bufferedData.length > 0) {
|
|
490
|
+
this.bufferedRestoreData.delete(session.id);
|
|
491
|
+
for (const chunk of bufferedData) {
|
|
492
|
+
this.emit('sessionData', session, chunk);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
this.restoringSessions.delete(session.id);
|
|
499
|
+
this.bufferedRestoreData.delete(session.id);
|
|
478
500
|
}
|
|
479
501
|
}
|
|
480
502
|
}
|
|
@@ -502,8 +524,6 @@ export class SessionManager extends EventEmitter {
|
|
|
502
524
|
...data,
|
|
503
525
|
autoApprovalFailed: true,
|
|
504
526
|
autoApprovalReason: reason,
|
|
505
|
-
pendingState: undefined,
|
|
506
|
-
pendingStateStart: undefined,
|
|
507
527
|
}));
|
|
508
528
|
}
|
|
509
529
|
}
|
|
@@ -549,6 +569,8 @@ export class SessionManager extends EventEmitter {
|
|
|
549
569
|
}
|
|
550
570
|
this.sessions.delete(sessionId);
|
|
551
571
|
this.waitingWithBottomBorder.delete(sessionId);
|
|
572
|
+
this.restoringSessions.delete(sessionId);
|
|
573
|
+
this.bufferedRestoreData.delete(sessionId);
|
|
552
574
|
this.emit('sessionDestroyed', session);
|
|
553
575
|
}
|
|
554
576
|
}
|
|
@@ -3,8 +3,9 @@ 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';
|
|
7
6
|
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;
|
|
8
9
|
vi.mock('./bunTerminal.js', () => ({
|
|
9
10
|
spawn: vi.fn(function () {
|
|
10
11
|
return null;
|
|
@@ -41,7 +42,7 @@ vi.mock('./config/configReader.js', () => ({
|
|
|
41
42
|
setAutoApprovalEnabled: vi.fn(),
|
|
42
43
|
},
|
|
43
44
|
}));
|
|
44
|
-
describe('SessionManager -
|
|
45
|
+
describe('SessionManager - state detection', () => {
|
|
45
46
|
let sessionManager;
|
|
46
47
|
let mockPtyInstances;
|
|
47
48
|
let eventEmitters;
|
|
@@ -50,7 +51,6 @@ describe('SessionManager - State Persistence', () => {
|
|
|
50
51
|
sessionManager = new SessionManager();
|
|
51
52
|
mockPtyInstances = new Map();
|
|
52
53
|
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,211 +79,35 @@ describe('SessionManager - State Persistence', () => {
|
|
|
79
79
|
vi.useRealTimers();
|
|
80
80
|
vi.clearAllMocks();
|
|
81
81
|
});
|
|
82
|
-
it('
|
|
82
|
+
it('transitions busy to idle after idle debounce and the next poll', 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
|
|
87
86
|
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
88
|
-
// Simulate output that would trigger idle state
|
|
89
87
|
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
|
|
92
88
|
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
|
|
115
89
|
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);
|
|
119
90
|
});
|
|
120
|
-
it('
|
|
91
|
+
it('transitions busy to waiting_input on the next poll without idle debounce', async () => {
|
|
121
92
|
const { Effect } = await import('effect');
|
|
122
93
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
123
94
|
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
|
|
132
95
|
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)
|
|
154
96
|
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
|
|
155
|
-
|
|
156
|
-
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
157
|
-
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
158
|
-
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
97
|
+
expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
159
98
|
});
|
|
160
|
-
it('
|
|
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 () => {
|
|
99
|
+
it('handles multiple sessions independently', async () => {
|
|
267
100
|
const { Effect } = await import('effect');
|
|
268
101
|
const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
|
|
269
102
|
const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path2'));
|
|
270
103
|
const eventEmitter1 = eventEmitters.get('/test/path1');
|
|
271
104
|
const eventEmitter2 = eventEmitters.get('/test/path2');
|
|
272
|
-
// Both should start as busy
|
|
273
105
|
expect(session1.stateMutex.getSnapshot().state).toBe('busy');
|
|
274
106
|
expect(session2.stateMutex.getSnapshot().state).toBe('busy');
|
|
275
|
-
// Simulate different outputs for each session
|
|
276
|
-
// Session 1 goes to idle
|
|
277
107
|
eventEmitter1.emit('data', 'Idle output for session 1');
|
|
278
|
-
// Session 2 goes to waiting_input (no idle debounce for waiting_input)
|
|
279
108
|
eventEmitter2.emit('data', 'Do you want to continue?\n❯ 1. Yes');
|
|
280
|
-
|
|
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
|
|
109
|
+
await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
|
|
284
110
|
expect(session1.stateMutex.getSnapshot().state).toBe('idle');
|
|
285
|
-
expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
286
111
|
expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
287
|
-
expect(session2.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
288
112
|
});
|
|
289
113
|
});
|
|
@@ -50,13 +50,27 @@ vi.mock('@xterm/addon-serialize', () => ({
|
|
|
50
50
|
vi.mock('@xterm/headless', () => ({
|
|
51
51
|
default: {
|
|
52
52
|
Terminal: vi.fn(function () {
|
|
53
|
+
const normalBuffer = {
|
|
54
|
+
type: 'normal',
|
|
55
|
+
baseY: 0,
|
|
56
|
+
cursorY: 0,
|
|
57
|
+
cursorX: 0,
|
|
58
|
+
length: 0,
|
|
59
|
+
getLine: vi.fn(function () {
|
|
60
|
+
return null;
|
|
61
|
+
}),
|
|
62
|
+
};
|
|
53
63
|
return {
|
|
54
64
|
rows: 24,
|
|
55
65
|
cols: 80,
|
|
56
66
|
buffer: {
|
|
57
|
-
active:
|
|
58
|
-
|
|
67
|
+
active: normalBuffer,
|
|
68
|
+
normal: normalBuffer,
|
|
69
|
+
alternate: {
|
|
70
|
+
type: 'alternate',
|
|
59
71
|
baseY: 0,
|
|
72
|
+
cursorY: 0,
|
|
73
|
+
cursorX: 0,
|
|
60
74
|
length: 0,
|
|
61
75
|
getLine: vi.fn(function () {
|
|
62
76
|
return null;
|
|
@@ -760,7 +774,7 @@ describe('SessionManager', () => {
|
|
|
760
774
|
});
|
|
761
775
|
});
|
|
762
776
|
describe('session restore snapshots', () => {
|
|
763
|
-
it('should emit
|
|
777
|
+
it('should emit a bounded normal-buffer restore snapshot and restore the cursor position', async () => {
|
|
764
778
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
765
779
|
id: '1',
|
|
766
780
|
name: 'Main',
|
|
@@ -768,14 +782,44 @@ describe('SessionManager', () => {
|
|
|
768
782
|
});
|
|
769
783
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
770
784
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
785
|
+
const normalBuffer = session.terminal.buffer.normal;
|
|
786
|
+
normalBuffer.baseY = 260;
|
|
787
|
+
normalBuffer.length = 300;
|
|
788
|
+
normalBuffer.cursorY = 7;
|
|
789
|
+
normalBuffer.cursorX = 11;
|
|
790
|
+
session.restoreScrollbackBaseLine = 120;
|
|
771
791
|
const serializeMock = vi
|
|
772
792
|
.spyOn(session.serializer, 'serialize')
|
|
773
793
|
.mockReturnValue('\u001b[31mrestored\u001b[0m');
|
|
774
794
|
const restoreHandler = vi.fn();
|
|
775
795
|
sessionManager.on('sessionRestore', restoreHandler);
|
|
776
796
|
sessionManager.setSessionActive(session.id, true);
|
|
797
|
+
expect(serializeMock).toHaveBeenCalledWith({
|
|
798
|
+
range: {
|
|
799
|
+
start: 120,
|
|
800
|
+
end: 299,
|
|
801
|
+
},
|
|
802
|
+
excludeAltBuffer: true,
|
|
803
|
+
});
|
|
804
|
+
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
|
|
805
|
+
});
|
|
806
|
+
it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
|
|
807
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
808
|
+
id: '1',
|
|
809
|
+
name: 'Main',
|
|
810
|
+
command: 'claude',
|
|
811
|
+
});
|
|
812
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
813
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
814
|
+
session.terminal.buffer.active = session.terminal.buffer.alternate;
|
|
815
|
+
const serializeMock = vi
|
|
816
|
+
.spyOn(session.serializer, 'serialize')
|
|
817
|
+
.mockReturnValue('\u001b[31malt\u001b[0m');
|
|
818
|
+
const restoreHandler = vi.fn();
|
|
819
|
+
sessionManager.on('sessionRestore', restoreHandler);
|
|
820
|
+
sessionManager.setSessionActive(session.id, true);
|
|
777
821
|
expect(serializeMock).toHaveBeenCalledWith({ scrollback: 0 });
|
|
778
|
-
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[
|
|
822
|
+
expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31malt\u001b[0m');
|
|
779
823
|
});
|
|
780
824
|
it('should skip restore event when serialized output is empty', async () => {
|
|
781
825
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
@@ -791,6 +835,43 @@ describe('SessionManager', () => {
|
|
|
791
835
|
sessionManager.setSessionActive(session.id, true);
|
|
792
836
|
expect(restoreHandler).not.toHaveBeenCalled();
|
|
793
837
|
});
|
|
838
|
+
it('should reset restore scrollback baseline after a clear-screen sequence', async () => {
|
|
839
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
840
|
+
id: '1',
|
|
841
|
+
name: 'Main',
|
|
842
|
+
command: 'claude',
|
|
843
|
+
});
|
|
844
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
845
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
846
|
+
session.terminal.buffer.normal.baseY = 17;
|
|
847
|
+
mockPty.emit('data', '\x1b[2J\x1b[Hfresh');
|
|
848
|
+
expect(session.restoreScrollbackBaseLine).toBe(17);
|
|
849
|
+
});
|
|
850
|
+
it('should flush live session data after the restore snapshot completes', async () => {
|
|
851
|
+
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
852
|
+
id: '1',
|
|
853
|
+
name: 'Main',
|
|
854
|
+
command: 'claude',
|
|
855
|
+
});
|
|
856
|
+
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
857
|
+
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
|
|
858
|
+
session.terminal.buffer.normal.length = 1;
|
|
859
|
+
vi.spyOn(session.serializer, 'serialize').mockReturnValue('restored');
|
|
860
|
+
const eventOrder = [];
|
|
861
|
+
sessionManager.on('sessionRestore', restoredSession => {
|
|
862
|
+
if (restoredSession.id === session.id) {
|
|
863
|
+
eventOrder.push('restore');
|
|
864
|
+
mockPty.emit('data', 'live-output');
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
sessionManager.on('sessionData', activeSession => {
|
|
868
|
+
if (activeSession.id === session.id) {
|
|
869
|
+
eventOrder.push('data');
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
sessionManager.setSessionActive(session.id, true);
|
|
873
|
+
expect(eventOrder).toEqual(['restore', 'data']);
|
|
874
|
+
});
|
|
794
875
|
});
|
|
795
876
|
describe('static methods', () => {
|
|
796
877
|
describe('getSessionCounts', () => {
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { BaseStateDetector } from './base.js';
|
|
2
|
-
// Spinner characters
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
// Spinner / activity-prefix characters (line must still match SPINNER_ACTIVITY_PATTERN: …ing + …)
|
|
3
|
+
// Includes: ornament spinners; · / • / ∙ / ⋅ bullets; ⏺ (record); ▸▹ triangles; ○● circles
|
|
4
|
+
const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆·•⏺▸▹∙⋅○●';
|
|
5
|
+
// Matches spinner activity labels like "✽ Tempering…", "✳ Simplifying…", or "· Misting…"
|
|
5
6
|
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;
|
|
6
9
|
const BUSY_LOOKBACK_LINES = 5;
|
|
7
10
|
// Workaround: Claude Code sometimes appears idle in terminal output while
|
|
8
11
|
// still actively processing (busy). To mitigate false idle transitions,
|
|
@@ -122,6 +125,10 @@ export class ClaudeStateDetector extends BaseStateDetector {
|
|
|
122
125
|
if (SPINNER_ACTIVITY_PATTERN.test(abovePromptBox)) {
|
|
123
126
|
return 'busy';
|
|
124
127
|
}
|
|
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
|
+
}
|
|
125
132
|
// Otherwise idle (debounced)
|
|
126
133
|
return this.debounceIdle(terminal, currentState);
|
|
127
134
|
}
|
|
@@ -330,6 +330,43 @@ 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
|
+
});
|
|
333
370
|
it('should detect busy with various spinner characters', () => {
|
|
334
371
|
const spinnerChars = [
|
|
335
372
|
'✱',
|
package/dist/types/index.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export interface Session {
|
|
|
30
30
|
isActive: boolean;
|
|
31
31
|
terminal: Terminal;
|
|
32
32
|
serializer: SerializeAddon;
|
|
33
|
+
restoreScrollbackBaseLine: number;
|
|
33
34
|
stateCheckInterval: NodeJS.Timeout | undefined;
|
|
34
35
|
isPrimaryCommand: boolean;
|
|
35
36
|
presetName: string | undefined;
|
|
@@ -38,7 +39,7 @@ export interface Session {
|
|
|
38
39
|
/**
|
|
39
40
|
* Mutex-protected session state data.
|
|
40
41
|
* Access via stateMutex.runExclusive() or stateMutex.update() to ensure thread-safe operations.
|
|
41
|
-
* Contains: state,
|
|
42
|
+
* Contains: state, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController, backgroundTaskCount, teamMemberCount
|
|
42
43
|
*/
|
|
43
44
|
stateMutex: Mutex<SessionStateData>;
|
|
44
45
|
/**
|
|
@@ -383,6 +383,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
383
383
|
process: {},
|
|
384
384
|
terminal: {},
|
|
385
385
|
serializer: {},
|
|
386
|
+
restoreScrollbackBaseLine: 0,
|
|
386
387
|
output: [],
|
|
387
388
|
stateCheckInterval: undefined,
|
|
388
389
|
isPrimaryCommand: true,
|
|
@@ -442,6 +443,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
442
443
|
process: {},
|
|
443
444
|
terminal: {},
|
|
444
445
|
serializer: {},
|
|
446
|
+
restoreScrollbackBaseLine: 0,
|
|
445
447
|
output: [],
|
|
446
448
|
stateCheckInterval: undefined,
|
|
447
449
|
isPrimaryCommand: true,
|
|
@@ -499,6 +501,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
499
501
|
process: {},
|
|
500
502
|
terminal: {},
|
|
501
503
|
serializer: {},
|
|
504
|
+
restoreScrollbackBaseLine: 0,
|
|
502
505
|
output: [],
|
|
503
506
|
stateCheckInterval: undefined,
|
|
504
507
|
isPrimaryCommand: true,
|
|
@@ -558,6 +561,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
558
561
|
process: {},
|
|
559
562
|
terminal: {},
|
|
560
563
|
serializer: {},
|
|
564
|
+
restoreScrollbackBaseLine: 0,
|
|
561
565
|
output: [],
|
|
562
566
|
stateCheckInterval: undefined,
|
|
563
567
|
isPrimaryCommand: true,
|
package/dist/utils/mutex.d.ts
CHANGED
|
@@ -42,9 +42,6 @@ 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;
|
|
48
45
|
autoApprovalFailed: boolean;
|
|
49
46
|
autoApprovalReason: string | undefined;
|
|
50
47
|
autoApprovalAbortController: AbortController | undefined;
|
package/dist/utils/mutex.js
CHANGED
|
@@ -80,9 +80,6 @@ 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(),
|
|
86
83
|
autoApprovalFailed: false,
|
|
87
84
|
autoApprovalReason: undefined,
|
|
88
85
|
autoApprovalAbortController: undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.7",
|
|
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.7",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.1.7",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.1.7",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.1.7",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.1.7"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|
|
@@ -1,9 +0,0 @@
|
|
|
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;
|