ccmanager 3.1.4 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node-pty';
1
+ import { spawn } from './bunTerminal.js';
2
2
  import { EventEmitter } from 'events';
3
3
  import pkg from '@xterm/headless';
4
4
  import { exec } from 'child_process';
@@ -11,6 +11,7 @@ import { Effect } from 'effect';
11
11
  import { ProcessError, ConfigError } from '../types/errors.js';
12
12
  import { autoApprovalVerifier } from './autoApprovalVerifier.js';
13
13
  import { logger } from '../utils/logger.js';
14
+ import { Mutex, createInitialSessionStateData } from '../utils/mutex.js';
14
15
  const { Terminal } = pkg;
15
16
  const execAsync = promisify(exec);
16
17
  const TERMINAL_CONTENT_MAX_LINES = 300;
@@ -29,11 +30,12 @@ export class SessionManager extends EventEmitter {
29
30
  // Create a detector based on the session's detection strategy
30
31
  const strategy = session.detectionStrategy || 'claude';
31
32
  const detector = createStateDetector(strategy);
32
- const detectedState = detector.detectState(session.terminal, session.state);
33
+ const stateData = session.stateMutex.getSnapshot();
34
+ const detectedState = detector.detectState(session.terminal, stateData.state);
33
35
  // If auto-approval is enabled and state is waiting_input, convert to pending_auto_approval
34
36
  if (detectedState === 'waiting_input' &&
35
37
  configurationManager.isAutoApprovalEnabled() &&
36
- !session.autoApprovalFailed) {
38
+ !stateData.autoApprovalFailed) {
37
39
  return 'pending_auto_approval';
38
40
  }
39
41
  return detectedState;
@@ -58,80 +60,101 @@ export class SessionManager extends EventEmitter {
58
60
  // Cancel any existing verification before starting a new one
59
61
  this.cancelAutoApprovalVerification(session, 'Restarting verification for pending auto-approval state');
60
62
  const abortController = new AbortController();
61
- session.autoApprovalAbortController = abortController;
62
- session.autoApprovalReason = undefined;
63
+ void session.stateMutex.update(data => ({
64
+ ...data,
65
+ autoApprovalAbortController: abortController,
66
+ autoApprovalReason: undefined,
67
+ }));
63
68
  // Get terminal content for verification
64
69
  const terminalContent = this.getTerminalContent(session);
65
70
  // Verify if permission is needed
66
71
  void Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission(terminalContent, {
67
72
  signal: abortController.signal,
68
73
  }))
69
- .then(autoApprovalResult => {
74
+ .then(async (autoApprovalResult) => {
70
75
  if (abortController.signal.aborted) {
71
76
  logger.debug(`[${session.id}] Auto-approval verification aborted before completion`);
72
77
  return;
73
78
  }
74
79
  // If state already moved away, skip handling
75
- if (session.state !== 'pending_auto_approval') {
76
- logger.debug(`[${session.id}] Skipping auto-approval handling; current state is ${session.state}`);
80
+ const currentState = session.stateMutex.getSnapshot().state;
81
+ if (currentState !== 'pending_auto_approval') {
82
+ logger.debug(`[${session.id}] Skipping auto-approval handling; current state is ${currentState}`);
77
83
  return;
78
84
  }
79
85
  if (autoApprovalResult.needsPermission) {
80
86
  // Change state to waiting_input to ask for user permission
81
87
  logger.info(`[${session.id}] Auto-approval verification determined user permission needed`);
82
- session.state = 'waiting_input';
83
- session.autoApprovalFailed = true;
84
- session.autoApprovalReason = autoApprovalResult.reason;
85
- session.pendingState = undefined;
86
- session.pendingStateStart = undefined;
88
+ await session.stateMutex.update(data => ({
89
+ ...data,
90
+ state: 'waiting_input',
91
+ autoApprovalFailed: true,
92
+ autoApprovalReason: autoApprovalResult.reason,
93
+ pendingState: undefined,
94
+ pendingStateStart: undefined,
95
+ }));
87
96
  this.emit('sessionStateChanged', session);
88
97
  }
89
98
  else {
90
99
  // Auto-approve by simulating Enter key press
91
100
  logger.info(`[${session.id}] Auto-approval granted, simulating user permission`);
92
- session.autoApprovalReason = undefined;
93
101
  session.process.write('\r');
94
102
  // Force state to busy to prevent endless auto-approval
95
103
  // when the state detection still sees pending_auto_approval
96
- session.state = 'busy';
97
- session.pendingState = undefined;
98
- session.pendingStateStart = undefined;
104
+ await session.stateMutex.update(data => ({
105
+ ...data,
106
+ state: 'busy',
107
+ autoApprovalReason: undefined,
108
+ pendingState: undefined,
109
+ pendingStateStart: undefined,
110
+ }));
99
111
  this.emit('sessionStateChanged', session);
100
112
  }
101
113
  })
102
- .catch((error) => {
114
+ .catch(async (error) => {
103
115
  if (abortController.signal.aborted) {
104
116
  logger.debug(`[${session.id}] Auto-approval verification aborted (${error?.message ?? 'aborted'})`);
105
117
  return;
106
118
  }
107
119
  // On failure, fall back to requiring explicit permission
108
120
  logger.error(`[${session.id}] Auto-approval verification failed, requiring user permission`, error);
109
- if (session.state === 'pending_auto_approval') {
110
- session.state = 'waiting_input';
111
- session.autoApprovalFailed = true;
112
- session.autoApprovalReason =
113
- error?.message ??
114
- 'Auto-approval verification failed';
115
- session.pendingState = undefined;
116
- session.pendingStateStart = undefined;
121
+ const currentState = session.stateMutex.getSnapshot().state;
122
+ if (currentState === 'pending_auto_approval') {
123
+ await session.stateMutex.update(data => ({
124
+ ...data,
125
+ state: 'waiting_input',
126
+ autoApprovalFailed: true,
127
+ autoApprovalReason: error?.message ??
128
+ 'Auto-approval verification failed',
129
+ pendingState: undefined,
130
+ pendingStateStart: undefined,
131
+ }));
117
132
  this.emit('sessionStateChanged', session);
118
133
  }
119
134
  })
120
- .finally(() => {
121
- if (session.autoApprovalAbortController === abortController) {
122
- session.autoApprovalAbortController = undefined;
135
+ .finally(async () => {
136
+ const currentController = session.stateMutex.getSnapshot().autoApprovalAbortController;
137
+ if (currentController === abortController) {
138
+ await session.stateMutex.update(data => ({
139
+ ...data,
140
+ autoApprovalAbortController: undefined,
141
+ }));
123
142
  }
124
143
  });
125
144
  }
126
145
  cancelAutoApprovalVerification(session, reason) {
127
- const controller = session.autoApprovalAbortController;
146
+ const stateData = session.stateMutex.getSnapshot();
147
+ const controller = stateData.autoApprovalAbortController;
128
148
  if (!controller) {
129
149
  return;
130
150
  }
131
151
  if (!controller.signal.aborted) {
132
152
  controller.abort();
133
153
  }
134
- session.autoApprovalAbortController = undefined;
154
+ void session.stateMutex.update(data => ({
155
+ ...data,
156
+ autoApprovalAbortController: undefined,
157
+ }));
135
158
  logger.info(`[${session.id}] Cancelled auto-approval verification: ${reason}`);
136
159
  }
137
160
  constructor() {
@@ -174,7 +197,6 @@ export class SessionManager extends EventEmitter {
174
197
  id,
175
198
  worktreePath,
176
199
  process: ptyProcess,
177
- state: 'busy', // Session starts as busy when created
178
200
  output: [],
179
201
  outputHistory: [],
180
202
  lastActivity: new Date(),
@@ -185,11 +207,7 @@ export class SessionManager extends EventEmitter {
185
207
  commandConfig,
186
208
  detectionStrategy: options.detectionStrategy ?? 'claude',
187
209
  devcontainerConfig: options.devcontainerConfig ?? undefined,
188
- pendingState: undefined,
189
- pendingStateStart: undefined,
190
- autoApprovalFailed: false,
191
- autoApprovalReason: undefined,
192
- autoApprovalAbortController: undefined,
210
+ stateMutex: new Mutex(createInitialSessionStateData()),
193
211
  };
194
212
  // Set up persistent background data handler for state detection
195
213
  this.setupBackgroundHandler(session);
@@ -356,37 +374,47 @@ export class SessionManager extends EventEmitter {
356
374
  this.setupDataHandler(session);
357
375
  // Set up interval-based state detection with persistence
358
376
  session.stateCheckInterval = setInterval(() => {
359
- const oldState = session.state;
377
+ const stateData = session.stateMutex.getSnapshot();
378
+ const oldState = stateData.state;
360
379
  const detectedState = this.detectTerminalState(session);
361
380
  const now = Date.now();
362
381
  // If detected state is different from current state
363
382
  if (detectedState !== oldState) {
364
383
  // If this is a new pending state or the pending state changed
365
- if (session.pendingState !== detectedState) {
366
- session.pendingState = detectedState;
367
- session.pendingStateStart = now;
384
+ if (stateData.pendingState !== detectedState) {
385
+ void session.stateMutex.update(data => ({
386
+ ...data,
387
+ pendingState: detectedState,
388
+ pendingStateStart: now,
389
+ }));
368
390
  }
369
- else if (session.pendingState !== undefined &&
370
- session.pendingStateStart !== undefined) {
391
+ else if (stateData.pendingState !== undefined &&
392
+ stateData.pendingStateStart !== undefined) {
371
393
  // Check if the pending state has persisted long enough
372
- const duration = now - session.pendingStateStart;
394
+ const duration = now - stateData.pendingStateStart;
373
395
  if (duration >= STATE_PERSISTENCE_DURATION_MS) {
374
396
  // Confirm the state change
375
- session.state = detectedState;
376
- session.pendingState = undefined;
377
- session.pendingStateStart = undefined;
378
- if (session.autoApprovalAbortController &&
397
+ void session.stateMutex.update(data => {
398
+ const newData = {
399
+ ...data,
400
+ state: detectedState,
401
+ pendingState: undefined,
402
+ pendingStateStart: undefined,
403
+ };
404
+ // If we previously blocked auto-approval and have moved out of a user prompt,
405
+ // allow future auto-approval attempts.
406
+ if (data.autoApprovalFailed &&
407
+ detectedState !== 'waiting_input' &&
408
+ detectedState !== 'pending_auto_approval') {
409
+ newData.autoApprovalFailed = false;
410
+ newData.autoApprovalReason = undefined;
411
+ }
412
+ return newData;
413
+ });
414
+ if (stateData.autoApprovalAbortController &&
379
415
  detectedState !== 'pending_auto_approval') {
380
416
  this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
381
417
  }
382
- // If we previously blocked auto-approval and have moved out of a user prompt,
383
- // allow future auto-approval attempts.
384
- if (session.autoApprovalFailed &&
385
- detectedState !== 'waiting_input' &&
386
- detectedState !== 'pending_auto_approval') {
387
- session.autoApprovalFailed = false;
388
- session.autoApprovalReason = undefined;
389
- }
390
418
  // Execute status hook asynchronously (non-blocking) using Effect
391
419
  void Effect.runPromise(executeStatusHook(oldState, detectedState, session));
392
420
  this.emit('sessionStateChanged', session);
@@ -395,14 +423,18 @@ export class SessionManager extends EventEmitter {
395
423
  }
396
424
  else {
397
425
  // Detected state matches current state, clear any pending state
398
- session.pendingState = undefined;
399
- session.pendingStateStart = undefined;
426
+ void session.stateMutex.update(data => ({
427
+ ...data,
428
+ pendingState: undefined,
429
+ pendingStateStart: undefined,
430
+ }));
400
431
  }
401
432
  // Handle auto-approval if state is pending_auto_approval and no verification is in progress.
402
433
  // This ensures auto-approval is retried when the state remains pending_auto_approval
403
434
  // but the previous verification completed (success, failure, timeout, or abort).
404
- if (session.state === 'pending_auto_approval' &&
405
- !session.autoApprovalAbortController) {
435
+ const currentStateData = session.stateMutex.getSnapshot();
436
+ if (currentStateData.state === 'pending_auto_approval' &&
437
+ !currentStateData.autoApprovalAbortController) {
406
438
  this.handleAutoApproval(session);
407
439
  }
408
440
  }, STATE_CHECK_INTERVAL_MS);
@@ -410,7 +442,8 @@ export class SessionManager extends EventEmitter {
410
442
  this.setupExitHandler(session);
411
443
  }
412
444
  cleanupSession(session) {
413
- if (session.autoApprovalAbortController) {
445
+ const stateData = session.stateMutex.getSnapshot();
446
+ if (stateData.autoApprovalAbortController) {
414
447
  this.cancelAutoApprovalVerification(session, 'Session cleanup');
415
448
  }
416
449
  // Clear the state check interval
@@ -418,11 +451,13 @@ export class SessionManager extends EventEmitter {
418
451
  clearInterval(session.stateCheckInterval);
419
452
  session.stateCheckInterval = undefined;
420
453
  }
421
- // Clear any pending state
422
- session.pendingState = undefined;
423
- session.pendingStateStart = undefined;
424
- // Update state to idle before destroying
425
- session.state = 'idle';
454
+ // Clear any pending state and update state to idle before destroying
455
+ void session.stateMutex.update(data => ({
456
+ ...data,
457
+ state: 'idle',
458
+ pendingState: undefined,
459
+ pendingStateStart: undefined,
460
+ }));
426
461
  this.emit('sessionStateChanged', session);
427
462
  this.destroySession(session.worktreePath);
428
463
  this.emit('sessionExit', session);
@@ -449,24 +484,34 @@ export class SessionManager extends EventEmitter {
449
484
  if (!session) {
450
485
  return;
451
486
  }
452
- if (session.state !== 'pending_auto_approval' &&
453
- !session.autoApprovalAbortController) {
487
+ const stateData = session.stateMutex.getSnapshot();
488
+ if (stateData.state !== 'pending_auto_approval' &&
489
+ !stateData.autoApprovalAbortController) {
454
490
  return;
455
491
  }
456
492
  this.cancelAutoApprovalVerification(session, reason);
457
- session.autoApprovalFailed = true;
458
- session.autoApprovalReason = reason;
459
- session.pendingState = undefined;
460
- session.pendingStateStart = undefined;
461
- if (session.state === 'pending_auto_approval') {
462
- session.state = 'waiting_input';
493
+ void session.stateMutex.update(data => {
494
+ const newData = {
495
+ ...data,
496
+ autoApprovalFailed: true,
497
+ autoApprovalReason: reason,
498
+ pendingState: undefined,
499
+ pendingStateStart: undefined,
500
+ };
501
+ if (data.state === 'pending_auto_approval') {
502
+ newData.state = 'waiting_input';
503
+ }
504
+ return newData;
505
+ });
506
+ if (stateData.state === 'pending_auto_approval') {
463
507
  this.emit('sessionStateChanged', session);
464
508
  }
465
509
  }
466
510
  destroySession(worktreePath) {
467
511
  const session = this.sessions.get(worktreePath);
468
512
  if (session) {
469
- if (session.autoApprovalAbortController) {
513
+ const stateData = session.stateMutex.getSnapshot();
514
+ if (stateData.autoApprovalAbortController) {
470
515
  this.cancelAutoApprovalVerification(session, 'Session destroyed');
471
516
  }
472
517
  // Clear the state check interval
@@ -650,7 +695,8 @@ export class SessionManager extends EventEmitter {
650
695
  total: sessions.length,
651
696
  };
652
697
  sessions.forEach(session => {
653
- switch (session.state) {
698
+ const stateData = session.stateMutex.getSnapshot();
699
+ switch (stateData.state) {
654
700
  case 'idle':
655
701
  counts.idle++;
656
702
  break;
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import { SessionManager } from './sessionManager.js';
3
- import { spawn } from 'node-pty';
3
+ import { spawn } from './bunTerminal.js';
4
4
  import { EventEmitter } from 'events';
5
5
  import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
6
- vi.mock('node-pty', () => ({
7
- spawn: vi.fn(),
6
+ vi.mock('./bunTerminal.js', () => ({
7
+ spawn: vi.fn(function () {
8
+ return null;
9
+ }),
8
10
  }));
9
11
  vi.mock('./configurationManager.js', () => ({
10
12
  configurationManager: {
@@ -83,15 +85,15 @@ describe('SessionManager - State Persistence', () => {
83
85
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
84
86
  const eventEmitter = eventEmitters.get('/test/path');
85
87
  // Initial state should be busy
86
- expect(session.state).toBe('busy');
88
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
87
89
  // Simulate output that would trigger idle state
88
90
  eventEmitter.emit('data', 'Some output without busy indicators');
89
- // Advance time less than persistence duration
90
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
91
+ // Advance time less than persistence duration (use async to process mutex updates)
92
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
91
93
  // State should still be busy, but pending state should be set
92
- expect(session.state).toBe('busy');
93
- expect(session.pendingState).toBe('idle');
94
- expect(session.pendingStateStart).toBeDefined();
94
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
95
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
96
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
95
97
  });
96
98
  it('should change state after persistence duration is met', async () => {
97
99
  const { Effect } = await import('effect');
@@ -100,19 +102,19 @@ describe('SessionManager - State Persistence', () => {
100
102
  const stateChangeHandler = vi.fn();
101
103
  sessionManager.on('sessionStateChanged', stateChangeHandler);
102
104
  // Initial state should be busy
103
- expect(session.state).toBe('busy');
105
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
104
106
  // Simulate output that would trigger idle state
105
107
  eventEmitter.emit('data', 'Some output without busy indicators');
106
- // Advance time less than persistence duration
107
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
108
- expect(session.state).toBe('busy');
108
+ // Advance time less than persistence duration (use async to process mutex updates)
109
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
110
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
109
111
  expect(stateChangeHandler).not.toHaveBeenCalled();
110
112
  // Advance time to exceed persistence duration
111
- vi.advanceTimersByTime(STATE_PERSISTENCE_DURATION_MS);
113
+ await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
112
114
  // State should now be changed
113
- expect(session.state).toBe('idle');
114
- expect(session.pendingState).toBeUndefined();
115
- expect(session.pendingStateStart).toBeUndefined();
115
+ expect(session.stateMutex.getSnapshot().state).toBe('idle');
116
+ expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
117
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
116
118
  expect(stateChangeHandler).toHaveBeenCalledWith(session);
117
119
  });
118
120
  it('should cancel pending state if detected state changes again before persistence', async () => {
@@ -120,40 +122,40 @@ describe('SessionManager - State Persistence', () => {
120
122
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
121
123
  const eventEmitter = eventEmitters.get('/test/path');
122
124
  // Initial state should be busy
123
- expect(session.state).toBe('busy');
125
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
124
126
  // Simulate output that would trigger idle state
125
127
  eventEmitter.emit('data', 'Some output without busy indicators');
126
- // Advance time less than persistence duration
127
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
128
- expect(session.pendingState).toBe('idle');
128
+ // Advance time less than persistence duration (use async to process mutex updates)
129
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
130
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
129
131
  // Simulate output that would trigger waiting_input state
130
132
  eventEmitter.emit('data', 'Do you want to continue?\n❯ 1. Yes');
131
- // Advance time to trigger another check
132
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS);
133
+ // Advance time to trigger another check (use async to process mutex updates)
134
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
133
135
  // Pending state should now be waiting_input, not idle
134
- expect(session.state).toBe('busy'); // Still original state
135
- expect(session.pendingState).toBe('waiting_input');
136
+ expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
137
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
136
138
  });
137
139
  it('should clear pending state if detected state returns to current state', async () => {
138
140
  const { Effect } = await import('effect');
139
141
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
140
142
  const eventEmitter = eventEmitters.get('/test/path');
141
143
  // Initial state should be busy
142
- expect(session.state).toBe('busy');
144
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
143
145
  // Simulate output that would trigger idle state
144
146
  eventEmitter.emit('data', 'Some output without busy indicators');
145
- // Advance time less than persistence duration
146
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
147
- expect(session.pendingState).toBe('idle');
148
- expect(session.pendingStateStart).toBeDefined();
147
+ // Advance time less than persistence duration (use async to process mutex updates)
148
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
149
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
150
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
149
151
  // Simulate output that would trigger busy state again (back to original)
150
152
  eventEmitter.emit('data', 'ESC to interrupt');
151
- // Advance time to trigger another check
152
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS);
153
+ // Advance time to trigger another check (use async to process mutex updates)
154
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
153
155
  // Pending state should be cleared
154
- expect(session.state).toBe('busy');
155
- expect(session.pendingState).toBeUndefined();
156
- expect(session.pendingStateStart).toBeUndefined();
156
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
157
+ expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
158
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
157
159
  });
158
160
  it('should not confirm state changes that do not persist long enough', async () => {
159
161
  const { Effect } = await import('effect');
@@ -162,22 +164,22 @@ describe('SessionManager - State Persistence', () => {
162
164
  const stateChangeHandler = vi.fn();
163
165
  sessionManager.on('sessionStateChanged', stateChangeHandler);
164
166
  // Initial state should be busy
165
- expect(session.state).toBe('busy');
167
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
166
168
  // Try to change to idle
167
169
  eventEmitter.emit('data', 'Some idle output\n');
168
- // Wait for detection but not full persistence (less than 200ms)
169
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS); // 100ms
170
+ // Wait for detection but not full persistence (less than 200ms) (use async to process mutex updates)
171
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
170
172
  // Should have pending state but not confirmed
171
- expect(session.state).toBe('busy');
172
- expect(session.pendingState).toBe('idle');
173
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
174
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
173
175
  // Now change to a different state before idle persists
174
176
  // Clear terminal first and add waiting prompt
175
177
  eventEmitter.emit('data', '\x1b[2J\x1b[HDo you want to continue?\n❯ 1. Yes');
176
- // Advance time to detect new state but still less than persistence duration from first change
177
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS); // Another 100ms, total 200ms exactly at threshold
178
+ // Advance time to detect new state but still less than persistence duration from first change (use async to process mutex updates)
179
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // Another 100ms, total 200ms exactly at threshold
178
180
  // Pending state should have changed to waiting_input
179
- expect(session.state).toBe('busy'); // Still original state
180
- expect(session.pendingState).toBe('waiting_input');
181
+ expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
182
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
181
183
  // Since states kept changing before persisting, no state change should have been confirmed
182
184
  expect(stateChangeHandler).not.toHaveBeenCalled();
183
185
  });
@@ -187,10 +189,10 @@ describe('SessionManager - State Persistence', () => {
187
189
  const eventEmitter = eventEmitters.get('/test/path');
188
190
  // Simulate output that would trigger idle state
189
191
  eventEmitter.emit('data', 'Some output without busy indicators');
190
- // Advance time less than persistence duration
191
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
192
- expect(session.pendingState).toBe('idle');
193
- expect(session.pendingStateStart).toBeDefined();
192
+ // Advance time less than persistence duration (use async to process mutex updates)
193
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
194
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
195
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
194
196
  // Destroy the session
195
197
  sessionManager.destroySession('/test/path');
196
198
  // Check that pending state is cleared
@@ -204,26 +206,26 @@ describe('SessionManager - State Persistence', () => {
204
206
  const eventEmitter1 = eventEmitters.get('/test/path1');
205
207
  const eventEmitter2 = eventEmitters.get('/test/path2');
206
208
  // Both should start as busy
207
- expect(session1.state).toBe('busy');
208
- expect(session2.state).toBe('busy');
209
+ expect(session1.stateMutex.getSnapshot().state).toBe('busy');
210
+ expect(session2.stateMutex.getSnapshot().state).toBe('busy');
209
211
  // Simulate different outputs for each session
210
212
  // Session 1 goes to idle
211
213
  eventEmitter1.emit('data', 'Idle output for session 1');
212
214
  // Session 2 goes to waiting_input
213
215
  eventEmitter2.emit('data', 'Do you want to continue?\n❯ 1. Yes');
214
- // Advance time to check but not confirm
215
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
216
+ // Advance time to check but not confirm (use async to process mutex updates)
217
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
216
218
  // Both should have pending states but not changed yet
217
- expect(session1.state).toBe('busy');
218
- expect(session1.pendingState).toBe('idle');
219
- expect(session2.state).toBe('busy');
220
- expect(session2.pendingState).toBe('waiting_input');
221
- // Advance time to confirm both
222
- vi.advanceTimersByTime(STATE_PERSISTENCE_DURATION_MS);
219
+ expect(session1.stateMutex.getSnapshot().state).toBe('busy');
220
+ expect(session1.stateMutex.getSnapshot().pendingState).toBe('idle');
221
+ expect(session2.stateMutex.getSnapshot().state).toBe('busy');
222
+ expect(session2.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
223
+ // Advance time to confirm both (use async to process mutex updates)
224
+ await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
223
225
  // Both should now be in their new states
224
- expect(session1.state).toBe('idle');
225
- expect(session1.pendingState).toBeUndefined();
226
- expect(session2.state).toBe('waiting_input');
227
- expect(session2.pendingState).toBeUndefined();
226
+ expect(session1.stateMutex.getSnapshot().state).toBe('idle');
227
+ expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
228
+ expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
229
+ expect(session2.stateMutex.getSnapshot().pendingState).toBeUndefined();
228
230
  });
229
231
  });