ccmanager 3.1.4 → 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,36 +129,52 @@ 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
  });
@@ -163,19 +184,26 @@ describe('SessionManager - Auto Approval Recovery', () => {
163
184
  expect(mockPty).toBeDefined();
164
185
  const handler = vi.fn();
165
186
  sessionManager.on('sessionStateChanged', handler);
166
- // Advance to pending_auto_approval state
167
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
168
- expect(session.state).toBe('pending_auto_approval');
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 });
169
195
  // Wait for handleAutoApproval promise chain to fully resolve
170
196
  await vi.waitFor(() => {
171
- expect(session.state).toBe('busy');
197
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
172
198
  });
173
- expect(session.pendingState).toBeUndefined();
174
- expect(session.pendingStateStart).toBeUndefined();
199
+ expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
200
+ expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
175
201
  // Verify Enter key was sent to approve
176
202
  expect(mockPty.write).toHaveBeenCalledWith('\r');
177
- // Verify sessionStateChanged was emitted
178
- expect(handler).toHaveBeenCalledWith(expect.objectContaining({ state: 'busy' }));
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');
179
207
  sessionManager.off('sessionStateChanged', handler);
180
208
  });
181
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,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;
@@ -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.4",
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",