ccmanager 3.11.2 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,3 @@
1
1
  export declare const STATE_PERSISTENCE_DURATION_MS = 200;
2
2
  export declare const STATE_CHECK_INTERVAL_MS = 100;
3
+ export declare const STATE_MINIMUM_DURATION_MS = 500;
@@ -2,3 +2,5 @@
2
2
  export const STATE_PERSISTENCE_DURATION_MS = 200;
3
3
  // Check interval for state detection in milliseconds
4
4
  export const STATE_CHECK_INTERVAL_MS = 100;
5
+ // Minimum duration in current state before allowing transition to a new state
6
+ export const STATE_MINIMUM_DURATION_MS = 500;
@@ -1,13 +1,15 @@
1
1
  import { Worktree } from '../types/index.js';
2
2
  /**
3
- * Custom hook for polling git status of worktrees with Effect-based execution
3
+ * Custom hook for polling git status and commit dates of worktrees with Effect-based execution
4
4
  *
5
- * Fetches git status for each worktree at regular intervals using Effect.runPromiseExit
6
- * and updates worktree state with results. Handles cancellation via AbortController.
5
+ * Fetches git status and last commit date for each worktree at regular intervals
6
+ * using Effect.runPromiseExit and updates worktree state with results.
7
+ * Both are fetched together so they appear at the same time.
8
+ * Handles cancellation via AbortController.
7
9
  *
8
10
  * @param worktrees - Array of worktrees to monitor
9
11
  * @param defaultBranch - Default branch for comparisons (null disables polling)
10
12
  * @param updateInterval - Polling interval in milliseconds (default: 5000)
11
- * @returns Array of worktrees with updated gitStatus and gitStatusError fields
13
+ * @returns Array of worktrees with updated gitStatus, gitStatusError, and lastCommitDate fields
12
14
  */
13
15
  export declare function useGitStatus(worktrees: Worktree[], defaultBranch: string | null, updateInterval?: number): Worktree[];
@@ -1,16 +1,18 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { Effect, Exit, Cause, Option } from 'effect';
3
- import { getGitStatusLimited } from '../utils/gitStatus.js';
3
+ import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStatus.js';
4
4
  /**
5
- * Custom hook for polling git status of worktrees with Effect-based execution
5
+ * Custom hook for polling git status and commit dates of worktrees with Effect-based execution
6
6
  *
7
- * Fetches git status for each worktree at regular intervals using Effect.runPromiseExit
8
- * and updates worktree state with results. Handles cancellation via AbortController.
7
+ * Fetches git status and last commit date for each worktree at regular intervals
8
+ * using Effect.runPromiseExit and updates worktree state with results.
9
+ * Both are fetched together so they appear at the same time.
10
+ * Handles cancellation via AbortController.
9
11
  *
10
12
  * @param worktrees - Array of worktrees to monitor
11
13
  * @param defaultBranch - Default branch for comparisons (null disables polling)
12
14
  * @param updateInterval - Polling interval in milliseconds (default: 5000)
13
- * @returns Array of worktrees with updated gitStatus and gitStatusError fields
15
+ * @returns Array of worktrees with updated gitStatus, gitStatusError, and lastCommitDate fields
14
16
  */
15
17
  export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
16
18
  const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees);
@@ -22,12 +24,17 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
22
24
  const activeRequests = new Map();
23
25
  let isCleanedUp = false;
24
26
  const fetchStatus = async (worktree, abortController) => {
25
- // Execute the Effect to get git status with cancellation support
26
- const exit = await Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
27
- signal: abortController.signal,
28
- });
29
- // Update worktree state based on exit result
30
- handleStatusExit(exit, worktree.path, setWorktreesWithStatus);
27
+ // Fetch git status and last commit date in parallel
28
+ const [statusExit, dateExit] = await Promise.all([
29
+ Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
30
+ signal: abortController.signal,
31
+ }),
32
+ Effect.runPromiseExit(getLastCommitDateLimited(worktree.path), {
33
+ signal: abortController.signal,
34
+ }),
35
+ ]);
36
+ // Update worktree state with both results at once
37
+ handleStatusExit(statusExit, dateExit, worktree.path, setWorktreesWithStatus);
31
38
  };
32
39
  const scheduleUpdate = (worktree) => {
33
40
  const abortController = new AbortController();
@@ -62,35 +69,42 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
62
69
  return worktreesWithStatus;
63
70
  }
64
71
  /**
65
- * Handle the Exit result from Effect.runPromiseExit and update worktree state
72
+ * Handle the Exit results from Effect.runPromiseExit and update worktree state
66
73
  *
67
- * Uses pattern matching on Exit to distinguish between success, failure, and interruption.
68
- * Success updates gitStatus, failure updates gitStatusError, interruption is ignored.
74
+ * Updates both gitStatus and lastCommitDate in a single state update so they
75
+ * appear at the same time in the UI.
69
76
  *
70
- * @param exit - Exit result from Effect execution
77
+ * @param statusExit - Exit result from git status Effect
78
+ * @param dateExit - Exit result from commit date Effect
71
79
  * @param worktreePath - Path of the worktree being updated
72
80
  * @param setWorktreesWithStatus - State setter function
73
81
  */
74
- function handleStatusExit(exit, worktreePath, setWorktreesWithStatus) {
75
- if (Exit.isSuccess(exit)) {
76
- // Success: update gitStatus and clear error
77
- const gitStatus = exit.value;
78
- setWorktreesWithStatus(prev => prev.map(wt => wt.path === worktreePath
79
- ? { ...wt, gitStatus, gitStatusError: undefined }
80
- : wt));
82
+ function handleStatusExit(statusExit, dateExit, worktreePath, setWorktreesWithStatus) {
83
+ // Build the update object from both results
84
+ const update = {};
85
+ let hasUpdate = false;
86
+ if (Exit.isSuccess(statusExit)) {
87
+ update.gitStatus = statusExit.value;
88
+ update.gitStatusError = undefined;
89
+ hasUpdate = true;
81
90
  }
82
- else if (Exit.isFailure(exit)) {
83
- // Failure: extract error and update gitStatusError
84
- const failure = Cause.failureOption(exit.cause);
91
+ else if (Exit.isFailure(statusExit)) {
92
+ const failure = Cause.failureOption(statusExit.cause);
85
93
  if (Option.isSome(failure)) {
86
94
  const gitError = failure.value;
87
- const errorMessage = formatGitError(gitError);
88
- setWorktreesWithStatus(prev => prev.map(wt => wt.path === worktreePath
89
- ? { ...wt, gitStatus: undefined, gitStatusError: errorMessage }
90
- : wt));
95
+ update.gitStatus = undefined;
96
+ update.gitStatusError = formatGitError(gitError);
97
+ hasUpdate = true;
91
98
  }
92
99
  }
93
- // Interruption: no state update - the request was cancelled
100
+ if (Exit.isSuccess(dateExit)) {
101
+ update.lastCommitDate = dateExit.value;
102
+ hasUpdate = true;
103
+ }
104
+ // Silently ignore commit date errors (e.g., empty repo)
105
+ if (hasUpdate) {
106
+ setWorktreesWithStatus(prev => prev.map(wt => (wt.path === worktreePath ? { ...wt, ...update } : wt)));
107
+ }
94
108
  }
95
109
  /**
96
110
  * Format GitError into a user-friendly error message
@@ -4,14 +4,16 @@ import { render, cleanup } from 'ink-testing-library';
4
4
  import { Text } from 'ink';
5
5
  import { Effect, Exit } from 'effect';
6
6
  import { useGitStatus } from './useGitStatus.js';
7
- import { getGitStatusLimited } from '../utils/gitStatus.js';
7
+ import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStatus.js';
8
8
  import { GitError } from '../types/errors.js';
9
9
  // Mock the gitStatus module
10
10
  vi.mock('../utils/gitStatus.js', () => ({
11
11
  getGitStatusLimited: vi.fn(),
12
+ getLastCommitDateLimited: vi.fn(),
12
13
  }));
13
14
  describe('useGitStatus', () => {
14
15
  const mockGetGitStatus = getGitStatusLimited;
16
+ const mockGetLastCommitDate = getLastCommitDateLimited;
15
17
  const createWorktree = (path) => ({
16
18
  path,
17
19
  branch: 'main',
@@ -28,6 +30,9 @@ describe('useGitStatus', () => {
28
30
  beforeEach(() => {
29
31
  vi.useFakeTimers();
30
32
  mockGetGitStatus.mockClear();
33
+ mockGetLastCommitDate.mockClear();
34
+ // Default: return a date for all worktrees
35
+ mockGetLastCommitDate.mockReturnValue(Effect.succeed(new Date('2025-01-01T00:00:00Z')));
31
36
  });
32
37
  afterEach(() => {
33
38
  vi.useRealTimers();
@@ -123,6 +128,13 @@ describe('useGitStatus', () => {
123
128
  resolveEffect = resume;
124
129
  });
125
130
  });
131
+ // Also make commit date async so Promise.all waits for both
132
+ let resolveDateEffect = null;
133
+ mockGetLastCommitDate.mockImplementation(() => {
134
+ return Effect.async(resume => {
135
+ resolveDateEffect = resume;
136
+ });
137
+ });
126
138
  const TestComponent = () => {
127
139
  useGitStatus(worktrees, 'main', 100);
128
140
  return React.createElement(Text, null, 'test');
@@ -136,8 +148,9 @@ describe('useGitStatus', () => {
136
148
  await vi.advanceTimersByTimeAsync(250);
137
149
  // Should not have started a second fetch yet
138
150
  expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
139
- // Complete the first fetch
151
+ // Complete the first fetch (both status and date)
140
152
  resolveEffect(Exit.succeed(createGitStatus(1, 0)));
153
+ resolveDateEffect(Exit.succeed(new Date('2025-01-01T00:00:00Z')));
141
154
  // Wait for the promise to resolve
142
155
  await vi.waitFor(() => {
143
156
  expect(fetchCount).toBe(1);
@@ -162,6 +175,12 @@ describe('useGitStatus', () => {
162
175
  });
163
176
  });
164
177
  });
178
+ // Also make commit date async so it doesn't resolve before status
179
+ mockGetLastCommitDate.mockImplementation(() => {
180
+ return Effect.async(_resume => {
181
+ return Effect.sync(() => { });
182
+ });
183
+ });
165
184
  const TestComponent = ({ worktrees }) => {
166
185
  useGitStatus(worktrees, 'main', 100);
167
186
  return React.createElement(Text, null, 'test');
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { EventEmitter } from 'events';
3
3
  import { spawn } from './bunTerminal.js';
4
- import { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
4
+ import { STATE_PERSISTENCE_DURATION_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
5
5
  import { Effect, Either } from 'effect';
6
6
  const detectStateMock = vi.fn();
7
7
  // Create a deferred promise pattern for controllable mock
@@ -107,24 +107,8 @@ describe('SessionManager - Auto Approval Recovery', () => {
107
107
  mockPtyInstances.set(path, mockPty);
108
108
  return mockPty;
109
109
  });
110
- // Detection sequence: first prompt (no auto-approval), back to busy, second prompt (should auto-approve)
111
- const detectionStates = [
112
- 'waiting_input',
113
- 'waiting_input',
114
- 'waiting_input',
115
- 'busy',
116
- 'busy',
117
- 'busy',
118
- 'waiting_input',
119
- 'waiting_input',
120
- 'waiting_input',
121
- ];
122
- let callIndex = 0;
123
- detectStateMock.mockImplementation(() => {
124
- const state = detectionStates[Math.min(callIndex, detectionStates.length - 1)];
125
- callIndex++;
126
- return state;
127
- });
110
+ // Start with waiting_input; tests will change the mock return value between phases
111
+ detectStateMock.mockReturnValue('waiting_input');
128
112
  const sessionManagerModule = await import('./sessionManager.js');
129
113
  SessionManager = sessionManagerModule.SessionManager;
130
114
  sessionManager = new SessionManager();
@@ -141,16 +125,19 @@ describe('SessionManager - Auto Approval Recovery', () => {
141
125
  ...data,
142
126
  autoApprovalFailed: true,
143
127
  }));
144
- // First waiting_input cycle (auto-approval suppressed) (use async to process mutex updates)
145
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
128
+ // Phase 1: waiting_input (auto-approval suppressed due to prior failure)
129
+ detectStateMock.mockReturnValue('waiting_input');
130
+ await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
146
131
  expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
147
132
  expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
148
- // Transition back to busy should reset the failure flag (use async to process mutex updates)
149
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
133
+ // Phase 2: busy - should reset the failure flag
134
+ detectStateMock.mockReturnValue('busy');
135
+ await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
150
136
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
151
137
  expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
152
- // Next waiting_input should trigger pending_auto_approval (use async to process mutex updates)
153
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
138
+ // Phase 3: waiting_input again - should trigger pending_auto_approval
139
+ detectStateMock.mockReturnValue('waiting_input');
140
+ await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
154
141
  // State should now be pending_auto_approval (waiting for verification)
155
142
  expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
156
143
  expect(verifyNeedsPermissionMock).toHaveBeenCalled();
@@ -192,8 +179,9 @@ describe('SessionManager - Auto Approval Recovery', () => {
192
179
  expect(mockPty).toBeDefined();
193
180
  const handler = vi.fn();
194
181
  sessionManager.on('sessionStateChanged', handler);
195
- // Advance to pending_auto_approval state (use async to process mutex updates)
196
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
182
+ // Phase 1: waiting_input pending_auto_approval
183
+ detectStateMock.mockReturnValue('waiting_input');
184
+ await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
197
185
  // State should be pending_auto_approval (waiting for verification)
198
186
  expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
199
187
  expect(verifyNeedsPermissionMock).toHaveBeenCalled();
@@ -7,7 +7,7 @@ import { configReader } from './config/configReader.js';
7
7
  import { setWorktreeLastOpened } from './worktreeService.js';
8
8
  import { executeStatusHook } from '../utils/hookExecutor.js';
9
9
  import { createStateDetector } from './stateDetector/index.js';
10
- import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
10
+ import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
11
11
  import { Effect, Either } from 'effect';
12
12
  import { ProcessError, ConfigError } from '../types/errors.js';
13
13
  import { autoApprovalVerifier } from './autoApprovalVerifier.js';
@@ -177,6 +177,7 @@ export class SessionManager extends EventEmitter {
177
177
  state: newState,
178
178
  pendingState: undefined,
179
179
  pendingStateStart: undefined,
180
+ stateConfirmedAt: Date.now(),
180
181
  ...additionalUpdates,
181
182
  }));
182
183
  if (oldState !== newState) {
@@ -393,8 +394,11 @@ export class SessionManager extends EventEmitter {
393
394
  else if (stateData.pendingState !== undefined &&
394
395
  stateData.pendingStateStart !== undefined) {
395
396
  // Check if the pending state has persisted long enough
397
+ // and that the current state has been active for the minimum duration
396
398
  const duration = now - stateData.pendingStateStart;
397
- if (duration >= STATE_PERSISTENCE_DURATION_MS) {
399
+ const timeInCurrentState = now - stateData.stateConfirmedAt;
400
+ if (duration >= STATE_PERSISTENCE_DURATION_MS &&
401
+ timeInCurrentState >= STATE_MINIMUM_DURATION_MS) {
398
402
  // Cancel auto-approval verification if state is changing away from pending_auto_approval
399
403
  if (stateData.autoApprovalAbortController &&
400
404
  detectedState !== 'pending_auto_approval') {
@@ -417,10 +421,13 @@ export class SessionManager extends EventEmitter {
417
421
  }
418
422
  else {
419
423
  // Detected state matches current state, clear any pending state
424
+ // and update stateConfirmedAt so the minimum duration guard
425
+ // tracks "last time current state was seen" rather than "first confirmed"
420
426
  void session.stateMutex.update(data => ({
421
427
  ...data,
422
428
  pendingState: undefined,
423
429
  pendingStateStart: undefined,
430
+ stateConfirmedAt: now,
424
431
  }));
425
432
  }
426
433
  // Handle auto-approval if state is pending_auto_approval and no verification is in progress.
@@ -3,7 +3,7 @@ import { Either } from 'effect';
3
3
  import { SessionManager } from './sessionManager.js';
4
4
  import { spawn } from './bunTerminal.js';
5
5
  import { EventEmitter } from 'events';
6
- import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
6
+ import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
7
7
  vi.mock('./bunTerminal.js', () => ({
8
8
  spawn: vi.fn(function () {
9
9
  return null;
@@ -97,7 +97,7 @@ describe('SessionManager - State Persistence', () => {
97
97
  expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
98
98
  expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
99
99
  });
100
- it('should change state after persistence duration is met', async () => {
100
+ it('should change state after both persistence and minimum duration are met', async () => {
101
101
  const { Effect } = await import('effect');
102
102
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
103
103
  const eventEmitter = eventEmitters.get('/test/path');
@@ -111,8 +111,8 @@ describe('SessionManager - State Persistence', () => {
111
111
  await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 2);
112
112
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
113
113
  expect(stateChangeHandler).not.toHaveBeenCalled();
114
- // Advance time to exceed persistence duration
115
- await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
114
+ // Advance time past both persistence and minimum duration
115
+ await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS);
116
116
  // State should now be changed
117
117
  expect(session.stateMutex.getSnapshot().state).toBe('idle');
118
118
  expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
@@ -201,6 +201,72 @@ describe('SessionManager - State Persistence', () => {
201
201
  const destroyedSession = sessionManager.getSession('/test/path');
202
202
  expect(destroyedSession).toBeUndefined();
203
203
  });
204
+ it('should not transition state before minimum duration in current state has elapsed', async () => {
205
+ const { Effect } = await import('effect');
206
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
207
+ const eventEmitter = eventEmitters.get('/test/path');
208
+ const stateChangeHandler = vi.fn();
209
+ sessionManager.on('sessionStateChanged', stateChangeHandler);
210
+ // Initial state should be busy
211
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
212
+ // Simulate output that would trigger idle state
213
+ eventEmitter.emit('data', 'Some output without busy indicators');
214
+ // Advance time enough for persistence duration but less than minimum duration
215
+ // STATE_PERSISTENCE_DURATION_MS (200ms) < STATE_MINIMUM_DURATION_MS (500ms)
216
+ await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS + STATE_CHECK_INTERVAL_MS * 2);
217
+ // State should still be busy because minimum duration hasn't elapsed
218
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
219
+ expect(stateChangeHandler).not.toHaveBeenCalled();
220
+ });
221
+ it('should transition state after both persistence and minimum duration are met', async () => {
222
+ const { Effect } = await import('effect');
223
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
224
+ const eventEmitter = eventEmitters.get('/test/path');
225
+ const stateChangeHandler = vi.fn();
226
+ sessionManager.on('sessionStateChanged', stateChangeHandler);
227
+ // Initial state should be busy
228
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
229
+ // Simulate output that would trigger idle state
230
+ eventEmitter.emit('data', 'Some output without busy indicators');
231
+ // Advance time past STATE_MINIMUM_DURATION_MS (which is longer than STATE_PERSISTENCE_DURATION_MS)
232
+ await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
233
+ // State should now be idle since both durations are satisfied
234
+ expect(session.stateMutex.getSnapshot().state).toBe('idle');
235
+ expect(stateChangeHandler).toHaveBeenCalledWith(session);
236
+ });
237
+ it('should not transition during brief screen redraw even after long time in current state', async () => {
238
+ const { Effect } = await import('effect');
239
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
240
+ const eventEmitter = eventEmitters.get('/test/path');
241
+ const stateChangeHandler = vi.fn();
242
+ sessionManager.on('sessionStateChanged', stateChangeHandler);
243
+ // Initial state should be busy
244
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
245
+ // Keep busy state active for a long time (simulating normal operation)
246
+ // Each check re-detects "busy" and updates stateConfirmedAt
247
+ eventEmitter.emit('data', 'ESC to interrupt');
248
+ await vi.advanceTimersByTimeAsync(2000); // 2 seconds of confirmed busy
249
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
250
+ // Now simulate a brief screen redraw: busy indicators disappear temporarily
251
+ eventEmitter.emit('data', '\x1b[2J\x1b[H'); // Clear screen
252
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS); // 100ms
253
+ // Pending state should be set to idle
254
+ expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
255
+ // Advance past persistence duration (200ms) but NOT past minimum duration (500ms)
256
+ // Since stateConfirmedAt was updated at ~2000ms, and now is ~2200ms,
257
+ // timeInCurrentState = ~200ms which is < 500ms
258
+ await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
259
+ // State should still be busy because minimum duration since last busy detection hasn't elapsed
260
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
261
+ expect(stateChangeHandler).not.toHaveBeenCalled();
262
+ // Simulate busy indicators coming back (screen redraw complete)
263
+ eventEmitter.emit('data', 'ESC to interrupt');
264
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
265
+ // State should still be busy and pending should be cleared
266
+ expect(session.stateMutex.getSnapshot().state).toBe('busy');
267
+ expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
268
+ expect(stateChangeHandler).not.toHaveBeenCalled();
269
+ });
204
270
  it('should handle multiple sessions with independent state persistence', async () => {
205
271
  const { Effect } = await import('effect');
206
272
  const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
@@ -222,8 +288,8 @@ describe('SessionManager - State Persistence', () => {
222
288
  expect(session1.stateMutex.getSnapshot().pendingState).toBe('idle');
223
289
  expect(session2.stateMutex.getSnapshot().state).toBe('busy');
224
290
  expect(session2.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
225
- // Advance time to confirm both (use async to process mutex updates)
226
- await vi.advanceTimersByTimeAsync(STATE_PERSISTENCE_DURATION_MS);
291
+ // Advance time to confirm both - need to exceed STATE_MINIMUM_DURATION_MS (use async to process mutex updates)
292
+ await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS);
227
293
  // Both should now be in their new states
228
294
  expect(session1.stateMutex.getSnapshot().state).toBe('idle');
229
295
  expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
@@ -1,6 +1,17 @@
1
1
  import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
3
  export declare class ClaudeStateDetector extends BaseStateDetector {
4
+ /**
5
+ * Extract content above the prompt box.
6
+ * The prompt box is delimited by ─ border lines:
7
+ * content above prompt box
8
+ * ─────────────── (top border)
9
+ * ❯ (prompt line)
10
+ * ─────────────── (bottom border)
11
+ *
12
+ * If no prompt box is found, returns all content as fallback.
13
+ */
14
+ private getContentAbovePromptBox;
4
15
  detectState(terminal: Terminal, currentState: SessionState): SessionState;
5
16
  detectBackgroundTask(terminal: Terminal): number;
6
17
  detectTeamMembers(terminal: Terminal): number;
@@ -1,30 +1,70 @@
1
1
  import { BaseStateDetector } from './base.js';
2
+ // Spinner characters used by Claude Code during active processing
3
+ const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆';
4
+ // Matches spinner activity labels like "✽ Tempering…" or "✳ Simplifying recompute_tangents…"
5
+ const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
2
6
  export class ClaudeStateDetector extends BaseStateDetector {
7
+ /**
8
+ * Extract content above the prompt box.
9
+ * The prompt box is delimited by ─ border lines:
10
+ * content above prompt box
11
+ * ─────────────── (top border)
12
+ * ❯ (prompt line)
13
+ * ─────────────── (bottom border)
14
+ *
15
+ * If no prompt box is found, returns all content as fallback.
16
+ */
17
+ getContentAbovePromptBox(terminal, maxLines) {
18
+ const lines = this.getTerminalLines(terminal, maxLines);
19
+ let borderCount = 0;
20
+ for (let i = lines.length - 1; i >= 0; i--) {
21
+ const trimmed = lines[i].trim();
22
+ if (trimmed.length > 0 && /^─+$/.test(trimmed)) {
23
+ borderCount++;
24
+ if (borderCount === 2) {
25
+ return lines.slice(0, i).join('\n');
26
+ }
27
+ }
28
+ }
29
+ // No prompt box found, return all content
30
+ return lines.join('\n');
31
+ }
3
32
  detectState(terminal, currentState) {
4
33
  // Check for search prompt (⌕ Search…) within 200 lines - always idle
5
34
  const extendedContent = this.getTerminalContent(terminal, 200);
6
35
  if (extendedContent.includes('⌕ Search…')) {
7
36
  return 'idle';
8
37
  }
9
- // Existing logic with 30 lines
10
- const content = this.getTerminalContent(terminal, 30);
11
- const lowerContent = content.toLowerCase();
38
+ // Full content (including prompt box) for waiting_input detection
39
+ const fullContent = this.getTerminalContent(terminal, 30);
40
+ const fullLowerContent = fullContent.toLowerCase();
12
41
  // Check for ctrl+r toggle prompt - maintain current state
13
- if (lowerContent.includes('ctrl+r to toggle')) {
42
+ if (fullLowerContent.includes('ctrl+r to toggle')) {
14
43
  return currentState;
15
44
  }
16
45
  // Check for "Do you want" or "Would you like" pattern with options
17
46
  // Handles both simple ("Do you want...\nYes") and complex (numbered options) formats
18
- if (/(?:do you want|would you like).+\n+[\s\S]*?(?:yes|❯)/.test(lowerContent)) {
47
+ if (/(?:do you want|would you like).+\n+[\s\S]*?(?:yes|❯)/.test(fullLowerContent)) {
48
+ return 'waiting_input';
49
+ }
50
+ // Check for selection prompt with ❯ cursor indicator and numbered options
51
+ if (/❯\s+\d+\./.test(fullContent)) {
19
52
  return 'waiting_input';
20
53
  }
21
54
  // Check for "esc to cancel" - indicates waiting for user input
22
- if (lowerContent.includes('esc to cancel')) {
55
+ if (fullLowerContent.includes('esc to cancel')) {
23
56
  return 'waiting_input';
24
57
  }
58
+ // Content above the prompt box only for busy detection
59
+ const abovePromptBox = this.getContentAbovePromptBox(terminal, 30);
60
+ const aboveLowerContent = abovePromptBox.toLowerCase();
25
61
  // Check for busy state
26
- if (lowerContent.includes('esc to interrupt') ||
27
- lowerContent.includes('ctrl+c to interrupt')) {
62
+ if (aboveLowerContent.includes('esc to interrupt') ||
63
+ aboveLowerContent.includes('ctrl+c to interrupt')) {
64
+ return 'busy';
65
+ }
66
+ // Check for spinner activity label (e.g., "✽ Tempering…", "✳ Simplifying…")
67
+ if (SPINNER_ACTIVITY_PATTERN.test(abovePromptBox)) {
28
68
  return 'busy';
29
69
  }
30
70
  // Otherwise idle
@@ -8,19 +8,22 @@ describe('ClaudeStateDetector', () => {
8
8
  detector = new ClaudeStateDetector();
9
9
  });
10
10
  describe('detectState', () => {
11
- it('should detect busy when "ESC to interrupt" is present', () => {
11
+ it('should detect busy when "ESC to interrupt" is above prompt box', () => {
12
12
  // Arrange
13
13
  terminal = createMockTerminal([
14
14
  'Processing...',
15
15
  'Press ESC to interrupt',
16
+ '──────────────────────────────',
17
+ '❯',
18
+ '──────────────────────────────',
16
19
  ]);
17
20
  // Act
18
21
  const state = detector.detectState(terminal, 'idle');
19
22
  // Assert
20
23
  expect(state).toBe('busy');
21
24
  });
22
- it('should detect busy when "esc to interrupt" is present (case insensitive)', () => {
23
- // Arrange
25
+ it('should detect busy when "esc to interrupt" is present (no prompt box fallback)', () => {
26
+ // Arrange - no prompt box borders, falls back to all content
24
27
  terminal = createMockTerminal([
25
28
  'Running command...',
26
29
  'press esc to interrupt the process',
@@ -30,11 +33,14 @@ describe('ClaudeStateDetector', () => {
30
33
  // Assert
31
34
  expect(state).toBe('busy');
32
35
  });
33
- it('should detect busy when "ctrl+c to interrupt" is present (web search)', () => {
36
+ it('should detect busy when "ctrl+c to interrupt" is above prompt box', () => {
34
37
  // Arrange
35
38
  terminal = createMockTerminal([
36
39
  'Googling. (ctrl+c to interrupt',
37
40
  'Searching for relevant information...',
41
+ '──────────────────────────────',
42
+ '❯',
43
+ '──────────────────────────────',
38
44
  ]);
39
45
  // Act
40
46
  const state = detector.detectState(terminal, 'idle');
@@ -93,7 +99,7 @@ describe('ClaudeStateDetector', () => {
93
99
  expect(state).toBe('busy');
94
100
  }
95
101
  });
96
- it('should detect waiting_input when "Do you want" with options prompt is present', () => {
102
+ it('should detect waiting_input when "Do you want" with options is above prompt box', () => {
97
103
  // Arrange
98
104
  terminal = createMockTerminal([
99
105
  'Some previous output',
@@ -101,13 +107,16 @@ describe('ClaudeStateDetector', () => {
101
107
  '❯ 1. Yes',
102
108
  '2. Yes, allow all edits during this session (shift+tab)',
103
109
  '3. No, and tell Claude what to do differently (esc)',
110
+ '──────────────────────────────',
111
+ '❯',
112
+ '──────────────────────────────',
104
113
  ]);
105
114
  // Act
106
115
  const state = detector.detectState(terminal, 'idle');
107
116
  // Assert
108
117
  expect(state).toBe('waiting_input');
109
118
  });
110
- it('should detect waiting_input when "Do you want" with options prompt is present (case insensitive)', () => {
119
+ it('should detect waiting_input when "Do you want" is present (no prompt box fallback)', () => {
111
120
  // Arrange
112
121
  terminal = createMockTerminal([
113
122
  'Some output',
@@ -127,6 +136,9 @@ describe('ClaudeStateDetector', () => {
127
136
  'Do you want to continue?',
128
137
  '❯ 1. Yes',
129
138
  '2. No',
139
+ '──────────────────────────────',
140
+ '❯',
141
+ '──────────────────────────────',
130
142
  ]);
131
143
  // Act
132
144
  const state = detector.detectState(terminal, 'idle');
@@ -190,18 +202,48 @@ describe('ClaudeStateDetector', () => {
190
202
  // Assert
191
203
  expect(state).toBe('waiting_input');
192
204
  });
193
- it('should detect waiting_input when "esc to cancel" is present', () => {
205
+ it('should detect waiting_input when plan submit prompt with ❯ cursor is present', () => {
206
+ // Arrange
207
+ terminal = createMockTerminal([
208
+ 'Ready to submit your answers?',
209
+ '',
210
+ '❯ 1. Submit answers',
211
+ ' 2. Cancel',
212
+ ]);
213
+ // Act
214
+ const state = detector.detectState(terminal, 'idle');
215
+ // Assert
216
+ expect(state).toBe('waiting_input');
217
+ });
218
+ it('should detect waiting_input for generic ❯ numbered selection prompt', () => {
219
+ // Arrange
220
+ terminal = createMockTerminal([
221
+ 'Select an option:',
222
+ '',
223
+ '❯ 1. Option A',
224
+ ' 2. Option B',
225
+ ' 3. Option C',
226
+ ]);
227
+ // Act
228
+ const state = detector.detectState(terminal, 'idle');
229
+ // Assert
230
+ expect(state).toBe('waiting_input');
231
+ });
232
+ it('should detect waiting_input when "esc to cancel" is above prompt box', () => {
194
233
  // Arrange
195
234
  terminal = createMockTerminal([
196
235
  'Enter your message:',
197
236
  'Press esc to cancel',
237
+ '──────────────────────────────',
238
+ '❯',
239
+ '──────────────────────────────',
198
240
  ]);
199
241
  // Act
200
242
  const state = detector.detectState(terminal, 'idle');
201
243
  // Assert
202
244
  expect(state).toBe('waiting_input');
203
245
  });
204
- it('should detect waiting_input when "esc to cancel" is present (case insensitive)', () => {
246
+ it('should detect waiting_input when "esc to cancel" is present (no prompt box fallback)', () => {
205
247
  // Arrange
206
248
  terminal = createMockTerminal(['Waiting for input', 'ESC TO CANCEL']);
207
249
  // Act
@@ -209,12 +251,15 @@ describe('ClaudeStateDetector', () => {
209
251
  // Assert
210
252
  expect(state).toBe('waiting_input');
211
253
  });
212
- it('should prioritize "esc to cancel" over "esc to interrupt" when both present', () => {
254
+ it('should prioritize "esc to cancel" over "esc to interrupt" when both above prompt box', () => {
213
255
  // Arrange
214
256
  terminal = createMockTerminal([
215
257
  'Press esc to interrupt',
216
258
  'Some input prompt',
217
259
  'Press esc to cancel',
260
+ '──────────────────────────────',
261
+ '❯',
262
+ '──────────────────────────────',
218
263
  ]);
219
264
  // Act
220
265
  const state = detector.detectState(terminal, 'idle');
@@ -273,6 +318,87 @@ describe('ClaudeStateDetector', () => {
273
318
  // Assert - Should detect waiting_input from viewport
274
319
  expect(state).toBe('waiting_input');
275
320
  });
321
+ it('should detect busy when spinner activity label "✽ Tempering…" is present', () => {
322
+ // Arrange
323
+ terminal = createMockTerminal([
324
+ '✽ Tempering…',
325
+ '──────────────────────────────',
326
+ '❯',
327
+ '──────────────────────────────',
328
+ ]);
329
+ // Act
330
+ const state = detector.detectState(terminal, 'idle');
331
+ // Assert
332
+ expect(state).toBe('busy');
333
+ });
334
+ it('should detect busy when spinner activity label "✳ Simplifying…" is present', () => {
335
+ // Arrange
336
+ terminal = createMockTerminal([
337
+ '✳ Simplifying recompute_tangents… (2m 18s · ↓ 4.8k tokens)',
338
+ ' ⎿ ◻ task list items...',
339
+ '──────────────────────────────',
340
+ '❯',
341
+ ]);
342
+ // Act
343
+ const state = detector.detectState(terminal, 'idle');
344
+ // Assert
345
+ expect(state).toBe('busy');
346
+ });
347
+ it('should detect busy with various spinner characters', () => {
348
+ const spinnerChars = [
349
+ '✱',
350
+ '✲',
351
+ '✳',
352
+ '✴',
353
+ '✵',
354
+ '✶',
355
+ '✷',
356
+ '✸',
357
+ '✹',
358
+ '✺',
359
+ '✻',
360
+ '✼',
361
+ '✽',
362
+ '✾',
363
+ '✿',
364
+ '❀',
365
+ '❁',
366
+ '❂',
367
+ '❃',
368
+ '❇',
369
+ '❈',
370
+ '❉',
371
+ '❊',
372
+ '❋',
373
+ '✢',
374
+ '✣',
375
+ '✤',
376
+ '✥',
377
+ '✦',
378
+ '✧',
379
+ ];
380
+ for (const char of spinnerChars) {
381
+ terminal = createMockTerminal([`${char} Kneading…`, '❯']);
382
+ const state = detector.detectState(terminal, 'idle');
383
+ expect(state).toBe('busy');
384
+ }
385
+ });
386
+ it('should not detect busy for spinner-like line without ing… suffix', () => {
387
+ // Arrange - no "ing…" at end
388
+ terminal = createMockTerminal(['✽ Some random text', '❯']);
389
+ // Act
390
+ const state = detector.detectState(terminal, 'idle');
391
+ // Assert
392
+ expect(state).toBe('idle');
393
+ });
394
+ it('should detect idle when "⌕ Search…" is present even with spinner activity', () => {
395
+ // Arrange
396
+ terminal = createMockTerminal(['⌕ Search…', '✽ Tempering…']);
397
+ // Act
398
+ const state = detector.detectState(terminal, 'busy');
399
+ // Assert - Search prompt takes precedence
400
+ expect(state).toBe('idle');
401
+ });
276
402
  it('should detect idle when "⌕ Search…" is present', () => {
277
403
  // Arrange - Search prompt should always be idle
278
404
  terminal = createMockTerminal(['⌕ Search…', 'Some content']);
@@ -297,6 +423,59 @@ describe('ClaudeStateDetector', () => {
297
423
  // Assert - Should be idle because search prompt takes precedence
298
424
  expect(state).toBe('idle');
299
425
  });
426
+ it('should ignore "esc to interrupt" inside prompt box', () => {
427
+ // Arrange - "esc to interrupt" is inside the prompt box, not above it
428
+ terminal = createMockTerminal([
429
+ 'Some idle output',
430
+ '──────────────────────────────',
431
+ 'esc to interrupt',
432
+ '──────────────────────────────',
433
+ ]);
434
+ // Act
435
+ const state = detector.detectState(terminal, 'idle');
436
+ // Assert - should be idle because "esc to interrupt" is inside prompt box
437
+ expect(state).toBe('idle');
438
+ });
439
+ it('should detect "esc to cancel" inside prompt box as waiting_input', () => {
440
+ // Arrange - waiting_input detection uses full content including prompt box
441
+ terminal = createMockTerminal([
442
+ 'Some idle output',
443
+ '──────────────────────────────',
444
+ 'esc to cancel',
445
+ '──────────────────────────────',
446
+ ]);
447
+ // Act
448
+ const state = detector.detectState(terminal, 'idle');
449
+ // Assert - waiting_input is not restricted to above prompt box
450
+ expect(state).toBe('waiting_input');
451
+ });
452
+ it('should detect "Do you want" inside prompt box as waiting_input', () => {
453
+ // Arrange - waiting_input detection uses full content including prompt box
454
+ terminal = createMockTerminal([
455
+ 'Some idle output',
456
+ '──────────────────────────────',
457
+ 'Do you want to proceed?',
458
+ '❯ 1. Yes',
459
+ '──────────────────────────────',
460
+ ]);
461
+ // Act
462
+ const state = detector.detectState(terminal, 'idle');
463
+ // Assert - waiting_input is not restricted to above prompt box
464
+ expect(state).toBe('waiting_input');
465
+ });
466
+ it('should ignore spinner activity label inside prompt box', () => {
467
+ // Arrange - spinner label is inside the prompt box
468
+ terminal = createMockTerminal([
469
+ 'Some idle output',
470
+ '──────────────────────────────',
471
+ '✽ Tempering…',
472
+ '──────────────────────────────',
473
+ ]);
474
+ // Act
475
+ const state = detector.detectState(terminal, 'idle');
476
+ // Assert - should be idle because spinner is inside prompt box
477
+ expect(state).toBe('idle');
478
+ });
300
479
  });
301
480
  describe('detectBackgroundTask', () => {
302
481
  it('should return count 1 when "1 background task" is in status bar', () => {
@@ -8,6 +8,10 @@ export class CodexStateDetector extends BaseStateDetector {
8
8
  /confirm with .+ enter/i.test(content)) {
9
9
  return 'waiting_input';
10
10
  }
11
+ // Check for plan/question prompts
12
+ if (lowerContent.includes('| enter to submit answer')) {
13
+ return 'waiting_input';
14
+ }
11
15
  // Check for waiting prompts
12
16
  if (lowerContent.includes('allow command?') ||
13
17
  lowerContent.includes('[y/n]') ||
@@ -135,6 +135,34 @@ describe('CodexStateDetector', () => {
135
135
  // Assert
136
136
  expect(state).toBe('waiting_input');
137
137
  });
138
+ it('should detect waiting_input state for plan question prompt with "| enter to submit answer"', () => {
139
+ // Arrange
140
+ terminal = createMockTerminal([
141
+ ' Question 1/3 (3 unanswered)',
142
+ ' › 1. 既知CLIのみ (Recommended)',
143
+ ' 2. 全preset対応',
144
+ ' 3. 既知CLI優先+未知は末尾引数',
145
+ ' 4. None of the above',
146
+ '',
147
+ ' tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt',
148
+ ]);
149
+ // Act
150
+ const state = detector.detectState(terminal, 'idle');
151
+ // Assert
152
+ expect(state).toBe('waiting_input');
153
+ });
154
+ it('should prioritize plan question prompt over busy state with esc interrupt', () => {
155
+ // Arrange
156
+ terminal = createMockTerminal([
157
+ ' Question 1/3',
158
+ ' › 1. Option A',
159
+ ' tab to add notes | enter to submit answer | esc to interrupt',
160
+ ]);
161
+ // Act
162
+ const state = detector.detectState(terminal, 'idle');
163
+ // Assert
164
+ expect(state).toBe('waiting_input');
165
+ });
138
166
  it('should prioritize "Confirm with ... Enter" over busy state', () => {
139
167
  // Arrange
140
168
  terminal = createMockTerminal(['Esc to interrupt', 'Confirm with Y Enter']);
@@ -662,21 +662,6 @@ export class WorktreeService {
662
662
  if (mainWorktree && mainWorktree.path.includes('.git/modules')) {
663
663
  mainWorktree.path = self.gitRootPath;
664
664
  }
665
- // Fetch last commit date for each worktree
666
- for (const wt of worktrees) {
667
- try {
668
- const dateStr = execSync('git log -1 --format=%aI', {
669
- cwd: wt.path,
670
- encoding: 'utf8',
671
- }).trim();
672
- if (dateStr) {
673
- wt.lastCommitDate = new Date(dateStr);
674
- }
675
- }
676
- catch {
677
- // Ignore errors (e.g., empty repo)
678
- }
679
- }
680
665
  // Sort worktrees by last session if requested
681
666
  if (sortByLastSession) {
682
667
  worktrees.sort((a, b) => {
@@ -41,6 +41,14 @@ export interface GitStatus {
41
41
  */
42
42
  export declare const getGitStatus: (worktreePath: string) => Effect.Effect<GitStatus, GitError>;
43
43
  export declare const getGitStatusLimited: (worktreePath: string) => Effect.Effect<GitStatus, GitError, never>;
44
+ /**
45
+ * Get the last commit date for a worktree
46
+ *
47
+ * @param worktreePath - Absolute path to the worktree directory
48
+ * @returns Effect containing the commit date or GitError
49
+ */
50
+ export declare const getLastCommitDate: (worktreePath: string) => Effect.Effect<Date, GitError>;
51
+ export declare const getLastCommitDateLimited: (worktreePath: string) => Effect.Effect<Date, GitError, never>;
44
52
  export declare function formatGitFileChanges(status: GitStatus): string;
45
53
  export declare function formatGitAheadBehind(status: GitStatus): string;
46
54
  export declare function formatGitStatus(status: GitStatus): string;
@@ -62,6 +62,24 @@ export const getGitStatus = (worktreePath) => Effect.gen(function* () {
62
62
  };
63
63
  });
64
64
  export const getGitStatusLimited = createEffectConcurrencyLimited((worktreePath) => getGitStatus(worktreePath), 10);
65
+ /**
66
+ * Get the last commit date for a worktree
67
+ *
68
+ * @param worktreePath - Absolute path to the worktree directory
69
+ * @returns Effect containing the commit date or GitError
70
+ */
71
+ export const getLastCommitDate = (worktreePath) => Effect.flatMap(runGit(['log', '-1', '--format=%aI'], worktreePath), result => {
72
+ const dateStr = result.stdout.trim();
73
+ if (dateStr) {
74
+ return Effect.succeed(new Date(dateStr));
75
+ }
76
+ return Effect.fail(new GitError({
77
+ command: 'git log -1 --format=%aI',
78
+ exitCode: 0,
79
+ stderr: 'No commits found',
80
+ }));
81
+ });
82
+ export const getLastCommitDateLimited = createEffectConcurrencyLimited((worktreePath) => getLastCommitDate(worktreePath), 10);
65
83
  export function formatGitFileChanges(status) {
66
84
  const parts = [];
67
85
  const colors = {
@@ -44,6 +44,7 @@ export interface SessionStateData {
44
44
  state: import('../types/index.js').SessionState;
45
45
  pendingState: import('../types/index.js').SessionState | undefined;
46
46
  pendingStateStart: number | undefined;
47
+ stateConfirmedAt: number;
47
48
  autoApprovalFailed: boolean;
48
49
  autoApprovalReason: string | undefined;
49
50
  autoApprovalAbortController: AbortController | undefined;
@@ -82,6 +82,7 @@ export function createInitialSessionStateData() {
82
82
  state: 'busy',
83
83
  pendingState: undefined,
84
84
  pendingStateStart: undefined,
85
+ stateConfirmedAt: Date.now(),
85
86
  autoApprovalFailed: false,
86
87
  autoApprovalReason: undefined,
87
88
  autoApprovalAbortController: undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.11.2",
3
+ "version": "3.12.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "3.11.2",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.11.2",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.11.2",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.11.2",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.11.2"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "3.12.0",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "3.12.0",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "3.12.0",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "3.12.0",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "3.12.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",