ccmanager 3.1.3 → 3.1.5

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.
@@ -5,17 +5,18 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
5
5
  const { stdout } = useStdout();
6
6
  const [isExiting, setIsExiting] = useState(false);
7
7
  const deriveStatus = (currentSession) => {
8
+ const stateData = currentSession.stateMutex.getSnapshot();
8
9
  // Always prioritize showing the manual approval notice when verification failed
9
- if (currentSession.autoApprovalFailed) {
10
- const reason = currentSession.autoApprovalReason
11
- ? ` Reason: ${currentSession.autoApprovalReason}.`
10
+ if (stateData.autoApprovalFailed) {
11
+ const reason = stateData.autoApprovalReason
12
+ ? ` Reason: ${stateData.autoApprovalReason}.`
12
13
  : '';
13
14
  return {
14
15
  message: `Auto-approval failed.${reason} Manual approval required—respond to the prompt.`,
15
16
  variant: 'error',
16
17
  };
17
18
  }
18
- if (currentSession.state === 'pending_auto_approval') {
19
+ if (stateData.state === 'pending_auto_approval') {
19
20
  return {
20
21
  message: 'Auto-approval pending... verifying permissions (press any key to cancel)',
21
22
  variant: 'pending',
@@ -192,7 +193,7 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
192
193
  onReturnToMenu();
193
194
  return;
194
195
  }
195
- if (session.state === 'pending_auto_approval') {
196
+ if (session.stateMutex.getSnapshot().state === 'pending_auto_approval') {
196
197
  sessionManager.cancelAutoApproval(session.worktreePath, 'User input received during auto-approval');
197
198
  }
198
199
  // Pass all other input directly to the PTY
@@ -4,7 +4,11 @@ import { spawn } from 'node-pty';
4
4
  import { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
5
5
  import { Effect } from 'effect';
6
6
  const detectStateMock = vi.fn();
7
- const verifyNeedsPermissionMock = vi.fn(() => Effect.succeed({ needsPermission: false }));
7
+ // Create a deferred promise pattern for controllable mock
8
+ let verifyResolve = null;
9
+ const verifyNeedsPermissionMock = vi.fn(() => Effect.promise(() => new Promise(resolve => {
10
+ verifyResolve = resolve;
11
+ })));
8
12
  vi.mock('node-pty', () => ({
9
13
  spawn: vi.fn(),
10
14
  }));
@@ -70,6 +74,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
70
74
  vi.useFakeTimers();
71
75
  detectStateMock.mockReset();
72
76
  verifyNeedsPermissionMock.mockClear();
77
+ verifyResolve = null;
73
78
  mockPtyInstances = new Map();
74
79
  eventEmitters = new Map();
75
80
  spawn.mockImplementation((_command, _args, options) => {
@@ -124,37 +129,81 @@ describe('SessionManager - Auto Approval Recovery', () => {
124
129
  it('re-enables auto approval after leaving waiting_input', async () => {
125
130
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
126
131
  // Simulate a prior auto-approval failure
127
- session.autoApprovalFailed = true;
128
- // First waiting_input cycle (auto-approval suppressed)
129
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3);
130
- expect(session.state).toBe('waiting_input');
131
- expect(session.autoApprovalFailed).toBe(true);
132
- // Transition back to busy should reset the failure flag
133
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3);
134
- expect(session.state).toBe('busy');
135
- expect(session.autoApprovalFailed).toBe(false);
136
- // Next waiting_input should trigger pending_auto_approval
137
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
138
- expect(session.state).toBe('pending_auto_approval');
139
- await Promise.resolve(); // allow handleAutoApproval promise to resolve
132
+ await session.stateMutex.update(data => ({
133
+ ...data,
134
+ autoApprovalFailed: true,
135
+ }));
136
+ // First waiting_input cycle (auto-approval suppressed) (use async to process mutex updates)
137
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
138
+ expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
139
+ expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
140
+ // Transition back to busy should reset the failure flag (use async to process mutex updates)
141
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
142
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
143
+ expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
144
+ // Next waiting_input should trigger pending_auto_approval (use async to process mutex updates)
145
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
146
+ // State should now be pending_auto_approval (waiting for verification)
147
+ expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
140
148
  expect(verifyNeedsPermissionMock).toHaveBeenCalled();
149
+ // Resolve the verification (needsPermission: false means auto-approve)
150
+ expect(verifyResolve).not.toBeNull();
151
+ verifyResolve({ needsPermission: false });
152
+ await Promise.resolve(); // allow handleAutoApproval promise to resolve
141
153
  });
142
154
  it('cancels auto approval when user input is detected', async () => {
143
155
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
144
156
  const abortController = new AbortController();
145
- session.state = 'pending_auto_approval';
146
- session.autoApprovalAbortController = abortController;
147
- session.pendingState = 'pending_auto_approval';
148
- session.pendingStateStart = Date.now();
157
+ await session.stateMutex.update(data => ({
158
+ ...data,
159
+ state: 'pending_auto_approval',
160
+ autoApprovalAbortController: abortController,
161
+ pendingState: 'pending_auto_approval',
162
+ pendingStateStart: Date.now(),
163
+ }));
149
164
  const handler = vi.fn();
150
165
  sessionManager.on('sessionStateChanged', handler);
151
166
  sessionManager.cancelAutoApproval(session.worktreePath, 'User pressed a key');
167
+ // Wait for async mutex update to complete (use vi.waitFor for proper async handling)
168
+ await vi.waitFor(() => {
169
+ const stateData = session.stateMutex.getSnapshot();
170
+ expect(stateData.autoApprovalAbortController).toBeUndefined();
171
+ });
172
+ const stateData = session.stateMutex.getSnapshot();
152
173
  expect(abortController.signal.aborted).toBe(true);
153
- expect(session.autoApprovalAbortController).toBeUndefined();
154
- expect(session.autoApprovalFailed).toBe(true);
155
- expect(session.state).toBe('waiting_input');
156
- expect(session.pendingState).toBeUndefined();
174
+ expect(stateData.autoApprovalAbortController).toBeUndefined();
175
+ expect(stateData.autoApprovalFailed).toBe(true);
176
+ expect(stateData.state).toBe('waiting_input');
177
+ expect(stateData.pendingState).toBeUndefined();
157
178
  expect(handler).toHaveBeenCalledWith(session);
158
179
  sessionManager.off('sessionStateChanged', handler);
159
180
  });
181
+ it('forces state to busy after auto-approval to prevent endless loop', async () => {
182
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
183
+ const mockPty = mockPtyInstances.get('/test/path');
184
+ expect(mockPty).toBeDefined();
185
+ const handler = vi.fn();
186
+ sessionManager.on('sessionStateChanged', handler);
187
+ // Advance to pending_auto_approval state (use async to process mutex updates)
188
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
189
+ // State should be pending_auto_approval (waiting for verification)
190
+ expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
191
+ expect(verifyNeedsPermissionMock).toHaveBeenCalled();
192
+ // Resolve the verification (needsPermission: false means auto-approve)
193
+ expect(verifyResolve).not.toBeNull();
194
+ verifyResolve({ needsPermission: false });
195
+ // Wait for handleAutoApproval promise chain to fully resolve
196
+ await vi.waitFor(() => {
197
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
198
+ });
199
+ expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
200
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
201
+ // Verify Enter key was sent to approve
202
+ expect(mockPty.write).toHaveBeenCalledWith('\r');
203
+ // Verify sessionStateChanged was emitted with session containing state=busy
204
+ const lastCall = handler.mock.calls[handler.mock.calls.length - 1];
205
+ expect(lastCall).toBeDefined();
206
+ expect(lastCall[0].stateMutex.getSnapshot().state).toBe('busy');
207
+ sessionManager.off('sessionStateChanged', handler);
208
+ });
160
209
  });
@@ -110,7 +110,7 @@ describe('SessionManager Effect-based Operations', () => {
110
110
  const session = await Effect.runPromise(effect);
111
111
  expect(session).toBeDefined();
112
112
  expect(session.worktreePath).toBe('/test/worktree');
113
- expect(session.state).toBe('busy');
113
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
114
114
  });
115
115
  it('should return Effect that fails with ConfigError when preset not found', async () => {
116
116
  // Setup mocks - both return null/undefined
@@ -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,74 +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');
102
+ // Force state to busy to prevent endless auto-approval
103
+ // when the state detection still sees pending_auto_approval
104
+ await session.stateMutex.update(data => ({
105
+ ...data,
106
+ state: 'busy',
107
+ autoApprovalReason: undefined,
108
+ pendingState: undefined,
109
+ pendingStateStart: undefined,
110
+ }));
111
+ this.emit('sessionStateChanged', session);
94
112
  }
95
113
  })
96
- .catch((error) => {
114
+ .catch(async (error) => {
97
115
  if (abortController.signal.aborted) {
98
116
  logger.debug(`[${session.id}] Auto-approval verification aborted (${error?.message ?? 'aborted'})`);
99
117
  return;
100
118
  }
101
119
  // On failure, fall back to requiring explicit permission
102
120
  logger.error(`[${session.id}] Auto-approval verification failed, requiring user permission`, error);
103
- if (session.state === 'pending_auto_approval') {
104
- session.state = 'waiting_input';
105
- session.autoApprovalFailed = true;
106
- session.autoApprovalReason =
107
- error?.message ??
108
- 'Auto-approval verification failed';
109
- session.pendingState = undefined;
110
- 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
+ }));
111
132
  this.emit('sessionStateChanged', session);
112
133
  }
113
134
  })
114
- .finally(() => {
115
- if (session.autoApprovalAbortController === abortController) {
116
- 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
+ }));
117
142
  }
118
143
  });
119
144
  }
120
145
  cancelAutoApprovalVerification(session, reason) {
121
- const controller = session.autoApprovalAbortController;
146
+ const stateData = session.stateMutex.getSnapshot();
147
+ const controller = stateData.autoApprovalAbortController;
122
148
  if (!controller) {
123
149
  return;
124
150
  }
125
151
  if (!controller.signal.aborted) {
126
152
  controller.abort();
127
153
  }
128
- session.autoApprovalAbortController = undefined;
154
+ void session.stateMutex.update(data => ({
155
+ ...data,
156
+ autoApprovalAbortController: undefined,
157
+ }));
129
158
  logger.info(`[${session.id}] Cancelled auto-approval verification: ${reason}`);
130
159
  }
131
160
  constructor() {
@@ -168,7 +197,6 @@ export class SessionManager extends EventEmitter {
168
197
  id,
169
198
  worktreePath,
170
199
  process: ptyProcess,
171
- state: 'busy', // Session starts as busy when created
172
200
  output: [],
173
201
  outputHistory: [],
174
202
  lastActivity: new Date(),
@@ -179,11 +207,7 @@ export class SessionManager extends EventEmitter {
179
207
  commandConfig,
180
208
  detectionStrategy: options.detectionStrategy ?? 'claude',
181
209
  devcontainerConfig: options.devcontainerConfig ?? undefined,
182
- pendingState: undefined,
183
- pendingStateStart: undefined,
184
- autoApprovalFailed: false,
185
- autoApprovalReason: undefined,
186
- autoApprovalAbortController: undefined,
210
+ stateMutex: new Mutex(createInitialSessionStateData()),
187
211
  };
188
212
  // Set up persistent background data handler for state detection
189
213
  this.setupBackgroundHandler(session);
@@ -350,37 +374,47 @@ export class SessionManager extends EventEmitter {
350
374
  this.setupDataHandler(session);
351
375
  // Set up interval-based state detection with persistence
352
376
  session.stateCheckInterval = setInterval(() => {
353
- const oldState = session.state;
377
+ const stateData = session.stateMutex.getSnapshot();
378
+ const oldState = stateData.state;
354
379
  const detectedState = this.detectTerminalState(session);
355
380
  const now = Date.now();
356
381
  // If detected state is different from current state
357
382
  if (detectedState !== oldState) {
358
383
  // If this is a new pending state or the pending state changed
359
- if (session.pendingState !== detectedState) {
360
- session.pendingState = detectedState;
361
- session.pendingStateStart = now;
384
+ if (stateData.pendingState !== detectedState) {
385
+ void session.stateMutex.update(data => ({
386
+ ...data,
387
+ pendingState: detectedState,
388
+ pendingStateStart: now,
389
+ }));
362
390
  }
363
- else if (session.pendingState !== undefined &&
364
- session.pendingStateStart !== undefined) {
391
+ else if (stateData.pendingState !== undefined &&
392
+ stateData.pendingStateStart !== undefined) {
365
393
  // Check if the pending state has persisted long enough
366
- const duration = now - session.pendingStateStart;
394
+ const duration = now - stateData.pendingStateStart;
367
395
  if (duration >= STATE_PERSISTENCE_DURATION_MS) {
368
396
  // Confirm the state change
369
- session.state = detectedState;
370
- session.pendingState = undefined;
371
- session.pendingStateStart = undefined;
372
- 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 &&
373
415
  detectedState !== 'pending_auto_approval') {
374
416
  this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
375
417
  }
376
- // If we previously blocked auto-approval and have moved out of a user prompt,
377
- // allow future auto-approval attempts.
378
- if (session.autoApprovalFailed &&
379
- detectedState !== 'waiting_input' &&
380
- detectedState !== 'pending_auto_approval') {
381
- session.autoApprovalFailed = false;
382
- session.autoApprovalReason = undefined;
383
- }
384
418
  // Execute status hook asynchronously (non-blocking) using Effect
385
419
  void Effect.runPromise(executeStatusHook(oldState, detectedState, session));
386
420
  this.emit('sessionStateChanged', session);
@@ -389,14 +423,18 @@ export class SessionManager extends EventEmitter {
389
423
  }
390
424
  else {
391
425
  // Detected state matches current state, clear any pending state
392
- session.pendingState = undefined;
393
- session.pendingStateStart = undefined;
426
+ void session.stateMutex.update(data => ({
427
+ ...data,
428
+ pendingState: undefined,
429
+ pendingStateStart: undefined,
430
+ }));
394
431
  }
395
432
  // Handle auto-approval if state is pending_auto_approval and no verification is in progress.
396
433
  // This ensures auto-approval is retried when the state remains pending_auto_approval
397
434
  // but the previous verification completed (success, failure, timeout, or abort).
398
- if (session.state === 'pending_auto_approval' &&
399
- !session.autoApprovalAbortController) {
435
+ const currentStateData = session.stateMutex.getSnapshot();
436
+ if (currentStateData.state === 'pending_auto_approval' &&
437
+ !currentStateData.autoApprovalAbortController) {
400
438
  this.handleAutoApproval(session);
401
439
  }
402
440
  }, STATE_CHECK_INTERVAL_MS);
@@ -404,7 +442,8 @@ export class SessionManager extends EventEmitter {
404
442
  this.setupExitHandler(session);
405
443
  }
406
444
  cleanupSession(session) {
407
- if (session.autoApprovalAbortController) {
445
+ const stateData = session.stateMutex.getSnapshot();
446
+ if (stateData.autoApprovalAbortController) {
408
447
  this.cancelAutoApprovalVerification(session, 'Session cleanup');
409
448
  }
410
449
  // Clear the state check interval
@@ -412,11 +451,13 @@ export class SessionManager extends EventEmitter {
412
451
  clearInterval(session.stateCheckInterval);
413
452
  session.stateCheckInterval = undefined;
414
453
  }
415
- // Clear any pending state
416
- session.pendingState = undefined;
417
- session.pendingStateStart = undefined;
418
- // Update state to idle before destroying
419
- 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
+ }));
420
461
  this.emit('sessionStateChanged', session);
421
462
  this.destroySession(session.worktreePath);
422
463
  this.emit('sessionExit', session);
@@ -443,24 +484,34 @@ export class SessionManager extends EventEmitter {
443
484
  if (!session) {
444
485
  return;
445
486
  }
446
- if (session.state !== 'pending_auto_approval' &&
447
- !session.autoApprovalAbortController) {
487
+ const stateData = session.stateMutex.getSnapshot();
488
+ if (stateData.state !== 'pending_auto_approval' &&
489
+ !stateData.autoApprovalAbortController) {
448
490
  return;
449
491
  }
450
492
  this.cancelAutoApprovalVerification(session, reason);
451
- session.autoApprovalFailed = true;
452
- session.autoApprovalReason = reason;
453
- session.pendingState = undefined;
454
- session.pendingStateStart = undefined;
455
- if (session.state === 'pending_auto_approval') {
456
- 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') {
457
507
  this.emit('sessionStateChanged', session);
458
508
  }
459
509
  }
460
510
  destroySession(worktreePath) {
461
511
  const session = this.sessions.get(worktreePath);
462
512
  if (session) {
463
- if (session.autoApprovalAbortController) {
513
+ const stateData = session.stateMutex.getSnapshot();
514
+ if (stateData.autoApprovalAbortController) {
464
515
  this.cancelAutoApprovalVerification(session, 'Session destroyed');
465
516
  }
466
517
  // Clear the state check interval
@@ -644,7 +695,8 @@ export class SessionManager extends EventEmitter {
644
695
  total: sessions.length,
645
696
  };
646
697
  sessions.forEach(session => {
647
- switch (session.state) {
698
+ const stateData = session.stateMutex.getSnapshot();
699
+ switch (stateData.state) {
648
700
  case 'idle':
649
701
  counts.idle++;
650
702
  break;
@@ -83,15 +83,15 @@ describe('SessionManager - State Persistence', () => {
83
83
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
84
84
  const eventEmitter = eventEmitters.get('/test/path');
85
85
  // Initial state should be busy
86
- expect(session.state).toBe('busy');
86
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
87
87
  // Simulate output that would trigger idle state
88
88
  eventEmitter.emit('data', 'Some output without busy indicators');
89
- // Advance time less than persistence duration
90
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 2);
89
+ // Advance time less than persistence duration (use async to process mutex updates)
90
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
91
91
  // 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();
92
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
93
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
94
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
95
95
  });
96
96
  it('should change state after persistence duration is met', async () => {
97
97
  const { Effect } = await import('effect');
@@ -100,19 +100,19 @@ describe('SessionManager - State Persistence', () => {
100
100
  const stateChangeHandler = vi.fn();
101
101
  sessionManager.on('sessionStateChanged', stateChangeHandler);
102
102
  // Initial state should be busy
103
- expect(session.state).toBe('busy');
103
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
104
104
  // Simulate output that would trigger idle state
105
105
  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');
106
+ // Advance time less than persistence duration (use async to process mutex updates)
107
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
108
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
109
109
  expect(stateChangeHandler).not.toHaveBeenCalled();
110
110
  // Advance time to exceed persistence duration
111
- vi.advanceTimersByTime(STATE_PERSISTENCE_DURATION_MS);
111
+ await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
112
112
  // State should now be changed
113
- expect(session.state).toBe('idle');
114
- expect(session.pendingState).toBeUndefined();
115
- expect(session.pendingStateStart).toBeUndefined();
113
+ expect(session.stateMutex.getSnapshot().state).toBe('idle');
114
+ expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
115
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
116
116
  expect(stateChangeHandler).toHaveBeenCalledWith(session);
117
117
  });
118
118
  it('should cancel pending state if detected state changes again before persistence', async () => {
@@ -120,40 +120,40 @@ describe('SessionManager - State Persistence', () => {
120
120
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
121
121
  const eventEmitter = eventEmitters.get('/test/path');
122
122
  // Initial state should be busy
123
- expect(session.state).toBe('busy');
123
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
124
124
  // Simulate output that would trigger idle state
125
125
  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');
126
+ // Advance time less than persistence duration (use async to process mutex updates)
127
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
128
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
129
129
  // Simulate output that would trigger waiting_input state
130
130
  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);
131
+ // Advance time to trigger another check (use async to process mutex updates)
132
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
133
133
  // 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');
134
+ expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
135
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
136
136
  });
137
137
  it('should clear pending state if detected state returns to current state', async () => {
138
138
  const { Effect } = await import('effect');
139
139
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
140
140
  const eventEmitter = eventEmitters.get('/test/path');
141
141
  // Initial state should be busy
142
- expect(session.state).toBe('busy');
142
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
143
143
  // Simulate output that would trigger idle state
144
144
  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();
145
+ // Advance time less than persistence duration (use async to process mutex updates)
146
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
147
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
148
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
149
149
  // Simulate output that would trigger busy state again (back to original)
150
150
  eventEmitter.emit('data', 'ESC to interrupt');
151
- // Advance time to trigger another check
152
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS);
151
+ // Advance time to trigger another check (use async to process mutex updates)
152
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
153
153
  // Pending state should be cleared
154
- expect(session.state).toBe('busy');
155
- expect(session.pendingState).toBeUndefined();
156
- expect(session.pendingStateStart).toBeUndefined();
154
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
155
+ expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
156
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
157
157
  });
158
158
  it('should not confirm state changes that do not persist long enough', async () => {
159
159
  const { Effect } = await import('effect');
@@ -162,22 +162,22 @@ describe('SessionManager - State Persistence', () => {
162
162
  const stateChangeHandler = vi.fn();
163
163
  sessionManager.on('sessionStateChanged', stateChangeHandler);
164
164
  // Initial state should be busy
165
- expect(session.state).toBe('busy');
165
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
166
166
  // Try to change to idle
167
167
  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
168
+ // Wait for detection but not full persistence (less than 200ms) (use async to process mutex updates)
169
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
170
170
  // Should have pending state but not confirmed
171
- expect(session.state).toBe('busy');
172
- expect(session.pendingState).toBe('idle');
171
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
172
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
173
173
  // Now change to a different state before idle persists
174
174
  // Clear terminal first and add waiting prompt
175
175
  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
176
+ // Advance time to detect new state but still less than persistence duration from first change (use async to process mutex updates)
177
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // Another 100ms, total 200ms exactly at threshold
178
178
  // Pending state should have changed to waiting_input
179
- expect(session.state).toBe('busy'); // Still original state
180
- expect(session.pendingState).toBe('waiting_input');
179
+ expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
180
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
181
181
  // Since states kept changing before persisting, no state change should have been confirmed
182
182
  expect(stateChangeHandler).not.toHaveBeenCalled();
183
183
  });
@@ -187,10 +187,10 @@ describe('SessionManager - State Persistence', () => {
187
187
  const eventEmitter = eventEmitters.get('/test/path');
188
188
  // Simulate output that would trigger idle state
189
189
  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();
190
+ // Advance time less than persistence duration (use async to process mutex updates)
191
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
192
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
193
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
194
194
  // Destroy the session
195
195
  sessionManager.destroySession('/test/path');
196
196
  // Check that pending state is cleared
@@ -204,26 +204,26 @@ describe('SessionManager - State Persistence', () => {
204
204
  const eventEmitter1 = eventEmitters.get('/test/path1');
205
205
  const eventEmitter2 = eventEmitters.get('/test/path2');
206
206
  // Both should start as busy
207
- expect(session1.state).toBe('busy');
208
- expect(session2.state).toBe('busy');
207
+ expect(session1.stateMutex.getSnapshot().state).toBe('busy');
208
+ expect(session2.stateMutex.getSnapshot().state).toBe('busy');
209
209
  // Simulate different outputs for each session
210
210
  // Session 1 goes to idle
211
211
  eventEmitter1.emit('data', 'Idle output for session 1');
212
212
  // Session 2 goes to waiting_input
213
213
  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);
214
+ // Advance time to check but not confirm (use async to process mutex updates)
215
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
216
216
  // 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);
217
+ expect(session1.stateMutex.getSnapshot().state).toBe('busy');
218
+ expect(session1.stateMutex.getSnapshot().pendingState).toBe('idle');
219
+ expect(session2.stateMutex.getSnapshot().state).toBe('busy');
220
+ expect(session2.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
221
+ // Advance time to confirm both (use async to process mutex updates)
222
+ await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
223
223
  // 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();
224
+ expect(session1.stateMutex.getSnapshot().state).toBe('idle');
225
+ expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
226
+ expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
227
+ expect(session2.stateMutex.getSnapshot().pendingState).toBeUndefined();
228
228
  });
229
229
  });
@@ -705,13 +705,20 @@ describe('SessionManager', () => {
705
705
  });
706
706
  describe('static methods', () => {
707
707
  describe('getSessionCounts', () => {
708
+ // Helper to create mock session with stateMutex
709
+ const createMockSession = (id, state) => ({
710
+ id,
711
+ stateMutex: {
712
+ getSnapshot: () => ({ state }),
713
+ },
714
+ });
708
715
  it('should count sessions by state', () => {
709
716
  const sessions = [
710
- { id: '1', state: 'idle' },
711
- { id: '2', state: 'busy' },
712
- { id: '3', state: 'busy' },
713
- { id: '4', state: 'waiting_input' },
714
- { id: '5', state: 'idle' },
717
+ createMockSession('1', 'idle'),
718
+ createMockSession('2', 'busy'),
719
+ createMockSession('3', 'busy'),
720
+ createMockSession('4', 'waiting_input'),
721
+ createMockSession('5', 'idle'),
715
722
  ];
716
723
  const counts = SessionManager.getSessionCounts(sessions);
717
724
  expect(counts.idle).toBe(2);
@@ -728,9 +735,9 @@ describe('SessionManager', () => {
728
735
  });
729
736
  it('should handle sessions with single state', () => {
730
737
  const sessions = [
731
- { id: '1', state: 'busy' },
732
- { id: '2', state: 'busy' },
733
- { id: '3', state: 'busy' },
738
+ createMockSession('1', 'busy'),
739
+ createMockSession('2', 'busy'),
740
+ createMockSession('3', 'busy'),
734
741
  ];
735
742
  const counts = SessionManager.getSessionCounts(sessions);
736
743
  expect(counts.idle).toBe(0);
@@ -1,6 +1,7 @@
1
1
  import { IPty } from 'node-pty';
2
2
  import type pkg from '@xterm/headless';
3
3
  import { GitStatus } from '../utils/gitStatus.js';
4
+ import { Mutex, SessionStateData } from '../utils/mutex.js';
4
5
  export type Terminal = InstanceType<typeof pkg.Terminal>;
5
6
  export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'pending_auto_approval';
6
7
  export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor' | 'github-copilot' | 'cline';
@@ -16,7 +17,6 @@ export interface Session {
16
17
  id: string;
17
18
  worktreePath: string;
18
19
  process: IPty;
19
- state: SessionState;
20
20
  output: string[];
21
21
  outputHistory: Buffer[];
22
22
  lastActivity: Date;
@@ -27,11 +27,12 @@ export interface Session {
27
27
  commandConfig: CommandConfig | undefined;
28
28
  detectionStrategy: StateDetectionStrategy | undefined;
29
29
  devcontainerConfig: DevcontainerConfig | undefined;
30
- pendingState: SessionState | undefined;
31
- pendingStateStart: number | undefined;
32
- autoApprovalFailed: boolean;
33
- autoApprovalReason?: string;
34
- autoApprovalAbortController?: AbortController;
30
+ /**
31
+ * Mutex-protected session state data.
32
+ * Access via stateMutex.runExclusive() or stateMutex.update() to ensure thread-safe operations.
33
+ * Contains: state, pendingState, pendingStateStart, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController
34
+ */
35
+ stateMutex: Mutex<SessionStateData>;
35
36
  }
36
37
  export interface AutoApprovalResponse {
37
38
  needsPermission: boolean;
@@ -7,6 +7,7 @@ import { join } from 'path';
7
7
  import { configurationManager } from '../services/configurationManager.js';
8
8
  import { WorktreeService } from '../services/worktreeService.js';
9
9
  import { GitError } from '../types/errors.js';
10
+ import { Mutex, createInitialSessionStateData } from './mutex.js';
10
11
  // Mock the configurationManager
11
12
  vi.mock('../services/configurationManager.js', () => ({
12
13
  configurationManager: {
@@ -262,17 +263,14 @@ describe('hookExecutor Integration Tests', () => {
262
263
  terminal: {},
263
264
  output: [],
264
265
  outputHistory: [],
265
- state: 'idle',
266
266
  stateCheckInterval: undefined,
267
267
  isPrimaryCommand: true,
268
268
  commandConfig: undefined,
269
269
  detectionStrategy: 'claude',
270
270
  devcontainerConfig: undefined,
271
- pendingState: undefined,
272
- pendingStateStart: undefined,
273
271
  lastActivity: new Date(),
274
272
  isActive: true,
275
- autoApprovalFailed: false,
273
+ stateMutex: new Mutex(createInitialSessionStateData()),
276
274
  };
277
275
  // Mock WorktreeService to return a worktree with the tmpDir path
278
276
  vi.mocked(WorktreeService).mockImplementation(() => ({
@@ -317,17 +315,14 @@ describe('hookExecutor Integration Tests', () => {
317
315
  terminal: {},
318
316
  output: [],
319
317
  outputHistory: [],
320
- state: 'idle',
321
318
  stateCheckInterval: undefined,
322
319
  isPrimaryCommand: true,
323
320
  commandConfig: undefined,
324
321
  detectionStrategy: 'claude',
325
322
  devcontainerConfig: undefined,
326
- pendingState: undefined,
327
- pendingStateStart: undefined,
328
323
  lastActivity: new Date(),
329
324
  isActive: true,
330
- autoApprovalFailed: false,
325
+ stateMutex: new Mutex(createInitialSessionStateData()),
331
326
  };
332
327
  // Mock WorktreeService to return a worktree with the tmpDir path
333
328
  vi.mocked(WorktreeService).mockImplementation(() => ({
@@ -370,17 +365,14 @@ describe('hookExecutor Integration Tests', () => {
370
365
  terminal: {},
371
366
  output: [],
372
367
  outputHistory: [],
373
- state: 'idle',
374
368
  stateCheckInterval: undefined,
375
369
  isPrimaryCommand: true,
376
370
  commandConfig: undefined,
377
371
  detectionStrategy: 'claude',
378
372
  devcontainerConfig: undefined,
379
- pendingState: undefined,
380
- pendingStateStart: undefined,
381
373
  lastActivity: new Date(),
382
374
  isActive: true,
383
- autoApprovalFailed: false,
375
+ stateMutex: new Mutex(createInitialSessionStateData()),
384
376
  };
385
377
  // Mock WorktreeService to return a worktree with the tmpDir path
386
378
  vi.mocked(WorktreeService).mockImplementation(() => ({
@@ -425,17 +417,14 @@ describe('hookExecutor Integration Tests', () => {
425
417
  terminal: {},
426
418
  output: [],
427
419
  outputHistory: [],
428
- state: 'idle',
429
420
  stateCheckInterval: undefined,
430
421
  isPrimaryCommand: true,
431
422
  commandConfig: undefined,
432
423
  detectionStrategy: 'claude',
433
424
  devcontainerConfig: undefined,
434
- pendingState: undefined,
435
- pendingStateStart: undefined,
436
425
  lastActivity: new Date(),
437
426
  isActive: true,
438
- autoApprovalFailed: false,
427
+ stateMutex: new Mutex(createInitialSessionStateData()),
439
428
  };
440
429
  // Mock WorktreeService to fail with GitError
441
430
  vi.mocked(WorktreeService).mockImplementation(() => ({
@@ -0,0 +1,54 @@
1
+ /**
2
+ * A simple mutex implementation for protecting shared state.
3
+ * Provides exclusive access to wrapped data through async locking.
4
+ */
5
+ export declare class Mutex<T> {
6
+ private data;
7
+ private locked;
8
+ private waitQueue;
9
+ constructor(initialData: T);
10
+ /**
11
+ * Acquire the lock. Returns a promise that resolves when the lock is acquired.
12
+ */
13
+ private acquire;
14
+ /**
15
+ * Release the lock, allowing the next waiter to proceed.
16
+ */
17
+ private release;
18
+ /**
19
+ * Run a function with exclusive access to the protected data.
20
+ * The lock is acquired before the function runs and released after it completes.
21
+ *
22
+ * @param fn - Function that receives the current data and returns updated data or a promise of updated data
23
+ * @returns Promise that resolves with the function's return value
24
+ */
25
+ runExclusive<R>(fn: (data: T) => R | Promise<R>): Promise<R>;
26
+ /**
27
+ * Run a function with exclusive access and update the protected data.
28
+ * The lock is acquired before the function runs and released after it completes.
29
+ *
30
+ * @param fn - Function that receives the current data and returns the updated data
31
+ */
32
+ update(fn: (data: T) => T | Promise<T>): Promise<void>;
33
+ /**
34
+ * Get a snapshot of the current data without acquiring the lock.
35
+ * Use with caution - this does not guarantee consistency.
36
+ * Prefer runExclusive for reads that need to be consistent with writes.
37
+ */
38
+ getSnapshot(): T;
39
+ }
40
+ /**
41
+ * Interface for the session state data protected by mutex.
42
+ */
43
+ export interface SessionStateData {
44
+ state: import('../types/index.js').SessionState;
45
+ pendingState: import('../types/index.js').SessionState | undefined;
46
+ pendingStateStart: number | undefined;
47
+ autoApprovalFailed: boolean;
48
+ autoApprovalReason: string | undefined;
49
+ autoApprovalAbortController: AbortController | undefined;
50
+ }
51
+ /**
52
+ * Create initial session state data with default values.
53
+ */
54
+ export declare function createInitialSessionStateData(): SessionStateData;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * A simple mutex implementation for protecting shared state.
3
+ * Provides exclusive access to wrapped data through async locking.
4
+ */
5
+ export class Mutex {
6
+ constructor(initialData) {
7
+ Object.defineProperty(this, "data", {
8
+ enumerable: true,
9
+ configurable: true,
10
+ writable: true,
11
+ value: void 0
12
+ });
13
+ Object.defineProperty(this, "locked", {
14
+ enumerable: true,
15
+ configurable: true,
16
+ writable: true,
17
+ value: false
18
+ });
19
+ Object.defineProperty(this, "waitQueue", {
20
+ enumerable: true,
21
+ configurable: true,
22
+ writable: true,
23
+ value: []
24
+ });
25
+ this.data = initialData;
26
+ }
27
+ /**
28
+ * Acquire the lock. Returns a promise that resolves when the lock is acquired.
29
+ */
30
+ async acquire() {
31
+ if (!this.locked) {
32
+ this.locked = true;
33
+ return;
34
+ }
35
+ return new Promise(resolve => {
36
+ this.waitQueue.push(resolve);
37
+ });
38
+ }
39
+ /**
40
+ * Release the lock, allowing the next waiter to proceed.
41
+ */
42
+ release() {
43
+ const next = this.waitQueue.shift();
44
+ if (next) {
45
+ next();
46
+ }
47
+ else {
48
+ this.locked = false;
49
+ }
50
+ }
51
+ /**
52
+ * Run a function with exclusive access to the protected data.
53
+ * The lock is acquired before the function runs and released after it completes.
54
+ *
55
+ * @param fn - Function that receives the current data and returns updated data or a promise of updated data
56
+ * @returns Promise that resolves with the function's return value
57
+ */
58
+ async runExclusive(fn) {
59
+ await this.acquire();
60
+ try {
61
+ const result = await fn(this.data);
62
+ return result;
63
+ }
64
+ finally {
65
+ this.release();
66
+ }
67
+ }
68
+ /**
69
+ * Run a function with exclusive access and update the protected data.
70
+ * The lock is acquired before the function runs and released after it completes.
71
+ *
72
+ * @param fn - Function that receives the current data and returns the updated data
73
+ */
74
+ async update(fn) {
75
+ await this.acquire();
76
+ try {
77
+ this.data = await fn(this.data);
78
+ }
79
+ finally {
80
+ this.release();
81
+ }
82
+ }
83
+ /**
84
+ * Get a snapshot of the current data without acquiring the lock.
85
+ * Use with caution - this does not guarantee consistency.
86
+ * Prefer runExclusive for reads that need to be consistent with writes.
87
+ */
88
+ getSnapshot() {
89
+ return this.data;
90
+ }
91
+ }
92
+ /**
93
+ * Create initial session state data with default values.
94
+ */
95
+ export function createInitialSessionStateData() {
96
+ return {
97
+ state: 'busy',
98
+ pendingState: undefined,
99
+ pendingStateStart: undefined,
100
+ autoApprovalFailed: false,
101
+ autoApprovalReason: undefined,
102
+ autoApprovalAbortController: undefined,
103
+ };
104
+ }
@@ -71,7 +71,9 @@ export function extractBranchParts(branchName) {
71
71
  export function prepareWorktreeItems(worktrees, sessions) {
72
72
  return worktrees.map(wt => {
73
73
  const session = sessions.find(s => s.worktreePath === wt.path);
74
- const status = session ? ` [${getStatusDisplay(session.state)}]` : '';
74
+ const status = session
75
+ ? ` [${getStatusDisplay(session.stateMutex.getSnapshot().state)}]`
76
+ : '';
75
77
  const fullBranchName = wt.branch
76
78
  ? wt.branch.replace('refs/heads/', '')
77
79
  : 'detached';
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from './worktreeUtils.js';
3
3
  import { execSync } from 'child_process';
4
+ import { Mutex, createInitialSessionStateData } from './mutex.js';
4
5
  // Mock child_process module
5
6
  vi.mock('child_process');
6
7
  describe('generateWorktreeDirectory', () => {
@@ -121,7 +122,6 @@ describe('prepareWorktreeItems', () => {
121
122
  const mockSession = {
122
123
  id: 'test-session',
123
124
  worktreePath: '/path/to/worktree',
124
- state: 'idle',
125
125
  process: {},
126
126
  output: [],
127
127
  outputHistory: [],
@@ -133,9 +133,10 @@ describe('prepareWorktreeItems', () => {
133
133
  commandConfig: undefined,
134
134
  detectionStrategy: 'claude',
135
135
  devcontainerConfig: undefined,
136
- pendingState: undefined,
137
- pendingStateStart: undefined,
138
- autoApprovalFailed: false,
136
+ stateMutex: new Mutex({
137
+ ...createInitialSessionStateData(),
138
+ state: 'idle',
139
+ }),
139
140
  };
140
141
  it('should prepare basic worktree without git status', () => {
141
142
  const items = prepareWorktreeItems([mockWorktree], []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.1.3",
3
+ "version": "3.1.5",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",