ccmanager 4.1.5 → 4.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,9 +65,9 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
65
65
  stdout.write('\x1B[2J\x1B[H');
66
66
  // Restore the current terminal state from the headless xterm snapshot.
67
67
  // The xterm serialize addon relies on auto-wrap (DECAWM) being enabled to
68
- // render wrapped lines it omits row separators for wrapped rows, expecting
69
- // characters to naturally overflow to the next line. We therefore keep
70
- // auto-wrap enabled while writing the snapshot and only disable it afterward.
68
+ // render wrapped lines. It omits row separators for wrapped rows and expects
69
+ // characters to naturally overflow to the next line, so auto-wrap must stay
70
+ // enabled while writing the snapshot and only be disabled afterward.
71
71
  const handleSessionRestore = (restoredSession, restoreSnapshot) => {
72
72
  if (restoredSession.id === session.id) {
73
73
  if (restoreSnapshot.length > 0) {
@@ -109,15 +109,12 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
109
109
  /* empty */
110
110
  }
111
111
  // Mark session as active after resizing so the restore snapshot matches
112
- // the current terminal dimensions. setSessionActive synchronously emits
113
- // the 'sessionRestore' event, so the snapshot is written to stdout before
114
- // we proceed.
112
+ // the current terminal dimensions. setSessionActive synchronously emits the
113
+ // restore event, so the snapshot is written to stdout before we proceed.
115
114
  sessionManager.setSessionActive(session.id, true);
116
115
  // Prevent line wrapping from drifting redraws in TUIs that rely on
117
- // cursor-up clears. This MUST come after the restore snapshot write
118
- // because the xterm serialize addon relies on auto-wrap (DECAWM) being
119
- // enabled — it omits row separators for wrapped rows, expecting characters
120
- // to naturally overflow to the next line.
116
+ // cursor-up clears. This must happen after the restore snapshot write,
117
+ // otherwise wrapped restore content can overlap on the same row.
121
118
  stdout.write('\x1b[?7l');
122
119
  // Handle terminal resize
123
120
  const handleResize = () => {
@@ -1,8 +1,13 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { EventEmitter } from 'events';
3
3
  import { spawn } from './bunTerminal.js';
4
- import { STATE_PERSISTENCE_DURATION_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
5
4
  import { Effect, Either } from 'effect';
5
+ /**
6
+ * Must match `STATE_CHECK_INTERVAL_MS` in sessionManager.ts.
7
+ * State updates are async; the next interval tick sees the new state and runs auto-approval.
8
+ */
9
+ const STATE_CHECK_INTERVAL_MS = 100;
10
+ const STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE = 2;
6
11
  const detectStateMock = vi.fn();
7
12
  // Create a deferred promise pattern for controllable mock
8
13
  let verifyResolve = null;
@@ -64,13 +69,25 @@ vi.mock('@xterm/addon-serialize', () => ({
64
69
  vi.mock('@xterm/headless', () => ({
65
70
  default: {
66
71
  Terminal: vi.fn().mockImplementation(function () {
72
+ const normalBuffer = {
73
+ type: 'normal',
74
+ baseY: 0,
75
+ cursorY: 0,
76
+ cursorX: 0,
77
+ length: 0,
78
+ getLine: vi.fn(),
79
+ };
67
80
  return {
68
81
  rows: 24,
69
82
  cols: 80,
70
83
  buffer: {
71
- active: {
72
- type: 'normal',
84
+ active: normalBuffer,
85
+ normal: normalBuffer,
86
+ alternate: {
87
+ type: 'alternate',
73
88
  baseY: 0,
89
+ cursorY: 0,
90
+ cursorX: 0,
74
91
  length: 0,
75
92
  getLine: vi.fn(),
76
93
  },
@@ -139,17 +156,17 @@ describe('SessionManager - Auto Approval Recovery', () => {
139
156
  }));
140
157
  // Phase 1: waiting_input (auto-approval suppressed due to prior failure)
141
158
  detectStateMock.mockReturnValue('waiting_input');
142
- await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
159
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
143
160
  expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
144
161
  expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
145
162
  // Phase 2: busy - should reset the failure flag
146
163
  detectStateMock.mockReturnValue('busy');
147
- await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
164
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
148
165
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
149
166
  expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
150
167
  // Phase 3: waiting_input again - should trigger pending_auto_approval
151
168
  detectStateMock.mockReturnValue('waiting_input');
152
- await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
169
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
153
170
  // State should now be pending_auto_approval (waiting for verification)
154
171
  expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
155
172
  expect(verifyNeedsPermissionMock).toHaveBeenCalled();
@@ -165,8 +182,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
165
182
  ...data,
166
183
  state: 'pending_auto_approval',
167
184
  autoApprovalAbortController: abortController,
168
- pendingState: 'pending_auto_approval',
169
- pendingStateStart: Date.now(),
170
185
  }));
171
186
  const handler = vi.fn();
172
187
  sessionManager.on('sessionStateChanged', handler);
@@ -181,7 +196,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
181
196
  expect(stateData.autoApprovalAbortController).toBeUndefined();
182
197
  expect(stateData.autoApprovalFailed).toBe(true);
183
198
  expect(stateData.state).toBe('waiting_input');
184
- expect(stateData.pendingState).toBeUndefined();
185
199
  expect(handler).toHaveBeenCalledWith(session);
186
200
  sessionManager.off('sessionStateChanged', handler);
187
201
  });
@@ -193,7 +207,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
193
207
  sessionManager.on('sessionStateChanged', handler);
194
208
  // Phase 1: waiting_input → pending_auto_approval
195
209
  detectStateMock.mockReturnValue('waiting_input');
196
- await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS + STATE_PERSISTENCE_DURATION_MS);
210
+ await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * STATE_DETECTION_TICKS_FOR_ASYNC_UPDATE);
197
211
  // State should be pending_auto_approval (waiting for verification)
198
212
  expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
199
213
  expect(verifyNeedsPermissionMock).toHaveBeenCalled();
@@ -204,8 +218,6 @@ describe('SessionManager - Auto Approval Recovery', () => {
204
218
  await vi.waitFor(() => {
205
219
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
206
220
  });
207
- expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
208
- expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
209
221
  // Verify Enter key was sent to approve
210
222
  expect(mockPty.write).toHaveBeenCalledWith('\r');
211
223
  // Verify sessionStateChanged was emitted with session containing state=busy
@@ -16,6 +16,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
16
16
  private waitingWithBottomBorder;
17
17
  private busyTimers;
18
18
  private autoApprovalDisabledWorktrees;
19
+ private restoringSessions;
20
+ private bufferedRestoreData;
19
21
  private spawn;
20
22
  private resolvePreset;
21
23
  detectTerminalState(session: Session): SessionState;
@@ -35,6 +37,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
35
37
  private updateSessionState;
36
38
  constructor();
37
39
  private createTerminal;
40
+ private shouldResetRestoreScrollback;
38
41
  private getRestoreSnapshot;
39
42
  private createSessionInternal;
40
43
  /**
@@ -46,13 +46,25 @@ vi.mock('@xterm/addon-serialize', () => ({
46
46
  vi.mock('@xterm/headless', () => ({
47
47
  default: {
48
48
  Terminal: vi.fn().mockImplementation(function () {
49
+ const normalBuffer = {
50
+ type: 'normal',
51
+ baseY: 0,
52
+ cursorY: 0,
53
+ cursorX: 0,
54
+ length: 0,
55
+ getLine: vi.fn(),
56
+ };
49
57
  return {
50
58
  rows: 24,
51
59
  cols: 80,
52
60
  buffer: {
53
- active: {
54
- type: 'normal',
61
+ active: normalBuffer,
62
+ normal: normalBuffer,
63
+ alternate: {
64
+ type: 'alternate',
55
65
  baseY: 0,
66
+ cursorY: 0,
67
+ cursorX: 0,
56
68
  length: 0,
57
69
  getLine: vi.fn(),
58
70
  },
@@ -6,7 +6,8 @@ import { spawn as childSpawn } from 'child_process';
6
6
  import { configReader } from './config/configReader.js';
7
7
  import { executeStatusHook } from '../utils/hookExecutor.js';
8
8
  import { createStateDetector } from './stateDetector/index.js';
9
- import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
9
+ /** Interval in milliseconds for polling terminal state detection. */
10
+ const STATE_CHECK_INTERVAL_MS = 100;
10
11
  import { Effect, Either } from 'effect';
11
12
  import { ProcessError, ConfigError } from '../types/errors.js';
12
13
  import { autoApprovalVerifier } from './autoApprovalVerifier.js';
@@ -19,11 +20,14 @@ import { preparePresetLaunch } from '../utils/presetPrompt.js';
19
20
  const { Terminal } = pkg;
20
21
  const TERMINAL_CONTENT_MAX_LINES = 300;
21
22
  const TERMINAL_SCROLLBACK_LINES = 5000;
23
+ const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
22
24
  export class SessionManager extends EventEmitter {
23
25
  sessions;
24
26
  waitingWithBottomBorder = new Map();
25
27
  busyTimers = new Map();
26
28
  autoApprovalDisabledWorktrees = new Set();
29
+ restoringSessions = new Set();
30
+ bufferedRestoreData = new Map();
27
31
  async spawn(command, args, worktreePath, options = {}) {
28
32
  const spawnOptions = {
29
33
  name: 'xterm-256color',
@@ -176,9 +180,6 @@ export class SessionManager extends EventEmitter {
176
180
  await session.stateMutex.update(data => ({
177
181
  ...data,
178
182
  state: newState,
179
- pendingState: undefined,
180
- pendingStateStart: undefined,
181
- stateConfirmedAt: Date.now(),
182
183
  ...additionalUpdates,
183
184
  }));
184
185
  if (oldState !== newState) {
@@ -199,10 +200,36 @@ export class SessionManager extends EventEmitter {
199
200
  logLevel: 'off',
200
201
  });
201
202
  }
203
+ shouldResetRestoreScrollback(data) {
204
+ return (data.includes('\x1b[2J') ||
205
+ data.includes('\x1b[3J') ||
206
+ data.includes('\x1bc'));
207
+ }
202
208
  getRestoreSnapshot(session) {
203
- return session.serializer.serialize({
204
- scrollback: 0,
209
+ const activeBuffer = session.terminal.buffer.active;
210
+ if (activeBuffer.type !== 'normal') {
211
+ return session.serializer.serialize({
212
+ scrollback: 0,
213
+ });
214
+ }
215
+ const normalBuffer = session.terminal.buffer.normal;
216
+ const bufferLength = normalBuffer.length;
217
+ if (bufferLength === 0) {
218
+ return '';
219
+ }
220
+ const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
221
+ const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
222
+ const rangeEnd = bufferLength - 1;
223
+ const snapshot = session.serializer.serialize({
224
+ range: {
225
+ start: rangeStart,
226
+ end: rangeEnd,
227
+ },
228
+ excludeAltBuffer: true,
205
229
  });
230
+ const cursorRow = normalBuffer.cursorY + 1;
231
+ const cursorCol = normalBuffer.cursorX + 1;
232
+ return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
206
233
  }
207
234
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
208
235
  const existingSessions = this.getSessionsForWorktree(worktreePath);
@@ -226,6 +253,7 @@ export class SessionManager extends EventEmitter {
226
253
  isActive: false,
227
254
  terminal,
228
255
  serializer,
256
+ restoreScrollbackBaseLine: 0,
229
257
  stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
230
258
  isPrimaryCommand: options.isPrimaryCommand ?? true,
231
259
  presetName: options.presetName,
@@ -301,9 +329,19 @@ export class SessionManager extends EventEmitter {
301
329
  session.process.onData((data) => {
302
330
  // Write data to virtual terminal
303
331
  session.terminal.write(data);
332
+ if (this.shouldResetRestoreScrollback(data)) {
333
+ session.restoreScrollbackBaseLine =
334
+ session.terminal.buffer.normal.baseY;
335
+ }
304
336
  session.lastActivity = new Date();
305
337
  // Only emit data events when session is active
306
338
  if (session.isActive) {
339
+ if (this.restoringSessions.has(session.id)) {
340
+ const bufferedData = this.bufferedRestoreData.get(session.id) ?? [];
341
+ bufferedData.push(data);
342
+ this.bufferedRestoreData.set(session.id, bufferedData);
343
+ return;
344
+ }
307
345
  this.emit('sessionData', session, data);
308
346
  }
309
347
  });
@@ -361,60 +399,27 @@ export class SessionManager extends EventEmitter {
361
399
  setupBackgroundHandler(session) {
362
400
  // Setup data handler
363
401
  this.setupDataHandler(session);
364
- // Set up interval-based state detection with persistence
402
+ // Set up interval-based state detection
365
403
  session.stateCheckInterval = setInterval(() => {
366
404
  const stateData = session.stateMutex.getSnapshot();
367
405
  const oldState = stateData.state;
368
406
  const detectedState = this.detectTerminalState(session);
369
- const now = Date.now();
370
- // If detected state is different from current state
371
407
  if (detectedState !== oldState) {
372
- // If this is a new pending state or the pending state changed
373
- if (stateData.pendingState !== detectedState) {
374
- void session.stateMutex.update(data => ({
375
- ...data,
376
- pendingState: detectedState,
377
- pendingStateStart: now,
378
- }));
408
+ // Cancel auto-approval verification if state is changing away from pending_auto_approval
409
+ if (stateData.autoApprovalAbortController &&
410
+ detectedState !== 'pending_auto_approval') {
411
+ this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
379
412
  }
380
- else if (stateData.pendingState !== undefined &&
381
- stateData.pendingStateStart !== undefined) {
382
- // Check if the pending state has persisted long enough
383
- // and that the current state has been active for the minimum duration
384
- const duration = now - stateData.pendingStateStart;
385
- const timeInCurrentState = now - stateData.stateConfirmedAt;
386
- if (duration >= STATE_PERSISTENCE_DURATION_MS &&
387
- timeInCurrentState >= STATE_MINIMUM_DURATION_MS) {
388
- // Cancel auto-approval verification if state is changing away from pending_auto_approval
389
- if (stateData.autoApprovalAbortController &&
390
- detectedState !== 'pending_auto_approval') {
391
- this.cancelAutoApprovalVerification(session, `state changed to ${detectedState}`);
392
- }
393
- // Build additional updates for auto-approval reset
394
- const additionalUpdates = {};
395
- // If we previously blocked auto-approval and have moved out of a user prompt,
396
- // allow future auto-approval attempts.
397
- if (stateData.autoApprovalFailed &&
398
- detectedState !== 'waiting_input' &&
399
- detectedState !== 'pending_auto_approval') {
400
- additionalUpdates.autoApprovalFailed = false;
401
- additionalUpdates.autoApprovalReason = undefined;
402
- }
403
- // Confirm the state change with hook execution
404
- void this.updateSessionState(session, detectedState, additionalUpdates);
405
- }
413
+ const additionalUpdates = {};
414
+ // If we previously blocked auto-approval and have moved out of a user prompt,
415
+ // allow future auto-approval attempts.
416
+ if (stateData.autoApprovalFailed &&
417
+ detectedState !== 'waiting_input' &&
418
+ detectedState !== 'pending_auto_approval') {
419
+ additionalUpdates.autoApprovalFailed = false;
420
+ additionalUpdates.autoApprovalReason = undefined;
406
421
  }
407
- }
408
- else {
409
- // Detected state matches current state, clear any pending state
410
- // and update stateConfirmedAt so the minimum duration guard
411
- // tracks "last time current state was seen" rather than "first confirmed"
412
- void session.stateMutex.update(data => ({
413
- ...data,
414
- pendingState: undefined,
415
- pendingStateStart: undefined,
416
- stateConfirmedAt: now,
417
- }));
422
+ void this.updateSessionState(session, detectedState, additionalUpdates);
418
423
  }
419
424
  // Handle auto-approval if state is pending_auto_approval and no verification is in progress.
420
425
  // This ensures auto-approval is retried when the state remains pending_auto_approval
@@ -471,10 +476,27 @@ export class SessionManager extends EventEmitter {
471
476
  session.isActive = active;
472
477
  if (active) {
473
478
  session.lastAccessedAt = Date.now();
474
- const restoreSnapshot = this.getRestoreSnapshot(session);
475
- if (restoreSnapshot.length > 0) {
476
- this.emit('sessionRestore', session, restoreSnapshot);
479
+ this.restoringSessions.add(session.id);
480
+ try {
481
+ const restoreSnapshot = this.getRestoreSnapshot(session);
482
+ if (restoreSnapshot.length > 0) {
483
+ this.emit('sessionRestore', session, restoreSnapshot);
484
+ }
477
485
  }
486
+ finally {
487
+ this.restoringSessions.delete(session.id);
488
+ const bufferedData = this.bufferedRestoreData.get(session.id);
489
+ if (bufferedData && bufferedData.length > 0) {
490
+ this.bufferedRestoreData.delete(session.id);
491
+ for (const chunk of bufferedData) {
492
+ this.emit('sessionData', session, chunk);
493
+ }
494
+ }
495
+ }
496
+ }
497
+ else {
498
+ this.restoringSessions.delete(session.id);
499
+ this.bufferedRestoreData.delete(session.id);
478
500
  }
479
501
  }
480
502
  }
@@ -502,8 +524,6 @@ export class SessionManager extends EventEmitter {
502
524
  ...data,
503
525
  autoApprovalFailed: true,
504
526
  autoApprovalReason: reason,
505
- pendingState: undefined,
506
- pendingStateStart: undefined,
507
527
  }));
508
528
  }
509
529
  }
@@ -549,6 +569,8 @@ export class SessionManager extends EventEmitter {
549
569
  }
550
570
  this.sessions.delete(sessionId);
551
571
  this.waitingWithBottomBorder.delete(sessionId);
572
+ this.restoringSessions.delete(sessionId);
573
+ this.bufferedRestoreData.delete(sessionId);
552
574
  this.emit('sessionDestroyed', session);
553
575
  }
554
576
  }
@@ -3,8 +3,9 @@ import { Either } from 'effect';
3
3
  import { SessionManager } from './sessionManager.js';
4
4
  import { spawn } from './bunTerminal.js';
5
5
  import { EventEmitter } from 'events';
6
- import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, STATE_MINIMUM_DURATION_MS, } from '../constants/statePersistence.js';
7
6
  import { IDLE_DEBOUNCE_MS } from './stateDetector/claude.js';
7
+ /** Must match `STATE_CHECK_INTERVAL_MS` in sessionManager.ts */
8
+ const STATE_CHECK_INTERVAL_MS = 100;
8
9
  vi.mock('./bunTerminal.js', () => ({
9
10
  spawn: vi.fn(function () {
10
11
  return null;
@@ -41,7 +42,7 @@ vi.mock('./config/configReader.js', () => ({
41
42
  setAutoApprovalEnabled: vi.fn(),
42
43
  },
43
44
  }));
44
- describe('SessionManager - State Persistence', () => {
45
+ describe('SessionManager - state detection', () => {
45
46
  let sessionManager;
46
47
  let mockPtyInstances;
47
48
  let eventEmitters;
@@ -50,7 +51,6 @@ describe('SessionManager - State Persistence', () => {
50
51
  sessionManager = new SessionManager();
51
52
  mockPtyInstances = new Map();
52
53
  eventEmitters = new Map();
53
- // Create mock PTY process factory
54
54
  spawn.mockImplementation((command, args, options) => {
55
55
  const path = options.cwd;
56
56
  const eventEmitter = new EventEmitter();
@@ -79,211 +79,35 @@ describe('SessionManager - State Persistence', () => {
79
79
  vi.useRealTimers();
80
80
  vi.clearAllMocks();
81
81
  });
82
- it('should not change state immediately when detected state changes', async () => {
82
+ it('transitions busy to idle after idle debounce and the next poll', async () => {
83
83
  const { Effect } = await import('effect');
84
84
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
85
85
  const eventEmitter = eventEmitters.get('/test/path');
86
- // Initial state should be busy
87
86
  expect(session.stateMutex.getSnapshot().state).toBe('busy');
88
- // Simulate output that would trigger idle state
89
87
  eventEmitter.emit('data', 'Some output without busy indicators');
90
- // Advance time past idle debounce so detector starts returning idle,
91
- // but not enough for persistence to confirm
92
88
  await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
93
- // State should still be busy, but pending state should be set
94
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
95
- expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
96
- expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
97
- });
98
- it('should change state after both persistence and minimum duration are met', async () => {
99
- const { Effect } = await import('effect');
100
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
101
- const eventEmitter = eventEmitters.get('/test/path');
102
- const stateChangeHandler = vi.fn();
103
- sessionManager.on('sessionStateChanged', stateChangeHandler);
104
- // Initial state should be busy
105
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
106
- // Simulate output that would trigger idle state
107
- eventEmitter.emit('data', 'Some output without busy indicators');
108
- // Advance time past idle debounce but not persistence+minimum
109
- await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
110
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
111
- expect(stateChangeHandler).not.toHaveBeenCalled();
112
- // Advance time past both persistence and minimum duration
113
- await vi.advanceTimersByTimeAsync(STATE_MINIMUM_DURATION_MS);
114
- // State should now be changed
115
89
  expect(session.stateMutex.getSnapshot().state).toBe('idle');
116
- expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
117
- expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
118
- expect(stateChangeHandler).toHaveBeenCalledWith(session);
119
90
  });
120
- it('should cancel pending state if detected state changes again before persistence', async () => {
91
+ it('transitions busy to waiting_input on the next poll without idle debounce', async () => {
121
92
  const { Effect } = await import('effect');
122
93
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
123
94
  const eventEmitter = eventEmitters.get('/test/path');
124
- // Initial state should be busy
125
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
126
- // Simulate output that would trigger idle state
127
- eventEmitter.emit('data', 'Some output without busy indicators');
128
- // Advance time past idle debounce so detector starts returning idle
129
- await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
130
- expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
131
- // Simulate output that would trigger waiting_input state
132
95
  eventEmitter.emit('data', 'Do you want to continue?\n❯ 1. Yes');
133
- // Advance time to trigger another check (use async to process mutex updates)
134
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
135
- // Pending state should now be waiting_input, not idle
136
- expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
137
- expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
138
- });
139
- it('should clear pending state if detected state returns to current state', async () => {
140
- const { Effect } = await import('effect');
141
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
142
- const eventEmitter = eventEmitters.get('/test/path');
143
- // Initial state should be busy
144
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
145
- // Simulate output that would trigger idle state
146
- eventEmitter.emit('data', 'Some output without busy indicators');
147
- // Advance time past idle debounce so detector starts returning idle
148
- await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
149
- expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
150
- expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
151
- // Simulate output that would trigger busy state again (back to original)
152
- eventEmitter.emit('data', 'ESC to interrupt');
153
- // Advance time to trigger another check (use async to process mutex updates)
154
96
  await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
155
- // Pending state should be cleared
156
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
157
- expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
158
- expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
97
+ expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
159
98
  });
160
- it('should not confirm state changes that do not persist long enough', async () => {
161
- const { Effect } = await import('effect');
162
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
163
- const eventEmitter = eventEmitters.get('/test/path');
164
- const stateChangeHandler = vi.fn();
165
- sessionManager.on('sessionStateChanged', stateChangeHandler);
166
- // Initial state should be busy
167
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
168
- // Try to change to idle
169
- eventEmitter.emit('data', 'Some idle output\n');
170
- // Wait past idle debounce so detector returns idle, then one check
171
- await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
172
- // Should have pending state but not confirmed
173
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
174
- expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
175
- // Now change to a different state before idle persists
176
- // Clear terminal first and add waiting prompt
177
- eventEmitter.emit('data', '\x1b[2J\x1b[HDo you want to continue?\n❯ 1. Yes');
178
- // Advance time to detect new state
179
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
180
- // Pending state should have changed to waiting_input
181
- expect(session.stateMutex.getSnapshot().state).toBe('busy'); // Still original state
182
- expect(session.stateMutex.getSnapshot().pendingState).toBe('waiting_input');
183
- // Since states kept changing before persisting, no state change should have been confirmed
184
- expect(stateChangeHandler).not.toHaveBeenCalled();
185
- });
186
- it('should properly clean up pending state when session is destroyed', async () => {
187
- const { Effect } = await import('effect');
188
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
189
- const eventEmitter = eventEmitters.get('/test/path');
190
- // Simulate output that would trigger idle state
191
- eventEmitter.emit('data', 'Some output without busy indicators');
192
- // Advance time past idle debounce so detector returns idle
193
- await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
194
- expect(session.stateMutex.getSnapshot().pendingState).toBe('idle');
195
- expect(session.stateMutex.getSnapshot().pendingStateStart).toBeDefined();
196
- // Destroy the session
197
- sessionManager.destroySession(session.id);
198
- // Check that pending state is cleared
199
- const remainingSessions = sessionManager.getSessionsForWorktree('/test/path');
200
- expect(remainingSessions).toHaveLength(0);
201
- });
202
- it('should not transition state before minimum duration in current state has elapsed', async () => {
203
- const { Effect } = await import('effect');
204
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
205
- const eventEmitter = eventEmitters.get('/test/path');
206
- const stateChangeHandler = vi.fn();
207
- sessionManager.on('sessionStateChanged', stateChangeHandler);
208
- // Initial state should be busy
209
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
210
- // Simulate output that would trigger idle state
211
- eventEmitter.emit('data', 'Some output without busy indicators');
212
- // Advance past idle debounce but not past persistence + minimum duration
213
- await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS +
214
- STATE_PERSISTENCE_DURATION_MS -
215
- STATE_CHECK_INTERVAL_MS);
216
- // State should still be busy because minimum duration hasn't elapsed
217
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
218
- expect(stateChangeHandler).not.toHaveBeenCalled();
219
- });
220
- it('should transition state after both persistence and minimum duration are met', async () => {
221
- const { Effect } = await import('effect');
222
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
223
- const eventEmitter = eventEmitters.get('/test/path');
224
- const stateChangeHandler = vi.fn();
225
- sessionManager.on('sessionStateChanged', stateChangeHandler);
226
- // Initial state should be busy
227
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
228
- // Simulate output that would trigger idle state
229
- eventEmitter.emit('data', 'Some output without busy indicators');
230
- // Advance past idle debounce + persistence/minimum duration
231
- await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
232
- // State should now be idle since both durations are satisfied
233
- expect(session.stateMutex.getSnapshot().state).toBe('idle');
234
- expect(stateChangeHandler).toHaveBeenCalledWith(session);
235
- });
236
- it('should not transition during brief screen redraw even after long time in current state', async () => {
237
- const { Effect } = await import('effect');
238
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
239
- const eventEmitter = eventEmitters.get('/test/path');
240
- const stateChangeHandler = vi.fn();
241
- sessionManager.on('sessionStateChanged', stateChangeHandler);
242
- // Initial state should be busy
243
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
244
- // Keep busy state active for a long time (simulating normal operation)
245
- // Each check re-detects "busy" and updates stateConfirmedAt
246
- eventEmitter.emit('data', 'ESC to interrupt');
247
- await vi.advanceTimersByTimeAsync(2000); // 2 seconds of confirmed busy
248
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
249
- // Now simulate a brief screen redraw: busy indicators disappear temporarily
250
- eventEmitter.emit('data', '\x1b[2J\x1b[H'); // Clear screen
251
- // Idle debounce prevents the detector from returning idle for IDLE_DEBOUNCE_MS,
252
- // so during a brief redraw (< 1500ms), no pending idle state is set.
253
- // Advance a short time — still within the idle debounce window
254
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3); // 300ms
255
- // State should still be busy — idle debounce hasn't elapsed
256
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
257
- expect(stateChangeHandler).not.toHaveBeenCalled();
258
- // Simulate busy indicators coming back (screen redraw complete)
259
- eventEmitter.emit('data', 'ESC to interrupt');
260
- await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS);
261
- // State should still be busy and pending should be cleared
262
- expect(session.stateMutex.getSnapshot().state).toBe('busy');
263
- expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
264
- expect(stateChangeHandler).not.toHaveBeenCalled();
265
- });
266
- it('should handle multiple sessions with independent state persistence', async () => {
99
+ it('handles multiple sessions independently', async () => {
267
100
  const { Effect } = await import('effect');
268
101
  const session1 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path1'));
269
102
  const session2 = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path2'));
270
103
  const eventEmitter1 = eventEmitters.get('/test/path1');
271
104
  const eventEmitter2 = eventEmitters.get('/test/path2');
272
- // Both should start as busy
273
105
  expect(session1.stateMutex.getSnapshot().state).toBe('busy');
274
106
  expect(session2.stateMutex.getSnapshot().state).toBe('busy');
275
- // Simulate different outputs for each session
276
- // Session 1 goes to idle
277
107
  eventEmitter1.emit('data', 'Idle output for session 1');
278
- // Session 2 goes to waiting_input (no idle debounce for waiting_input)
279
108
  eventEmitter2.emit('data', 'Do you want to continue?\n❯ 1. Yes');
280
- // Advance past idle debounce for session 1 + persistence/minimum for both
281
- await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_MINIMUM_DURATION_MS + STATE_CHECK_INTERVAL_MS);
282
- // Both should now be in their new states
283
- // Session 2 (waiting_input) transitions faster since it's not debounced
109
+ await vi.advanceTimersByTimeAsync(IDLE_DEBOUNCE_MS + STATE_CHECK_INTERVAL_MS);
284
110
  expect(session1.stateMutex.getSnapshot().state).toBe('idle');
285
- expect(session1.stateMutex.getSnapshot().pendingState).toBeUndefined();
286
111
  expect(session2.stateMutex.getSnapshot().state).toBe('waiting_input');
287
- expect(session2.stateMutex.getSnapshot().pendingState).toBeUndefined();
288
112
  });
289
113
  });
@@ -50,13 +50,27 @@ vi.mock('@xterm/addon-serialize', () => ({
50
50
  vi.mock('@xterm/headless', () => ({
51
51
  default: {
52
52
  Terminal: vi.fn(function () {
53
+ const normalBuffer = {
54
+ type: 'normal',
55
+ baseY: 0,
56
+ cursorY: 0,
57
+ cursorX: 0,
58
+ length: 0,
59
+ getLine: vi.fn(function () {
60
+ return null;
61
+ }),
62
+ };
53
63
  return {
54
64
  rows: 24,
55
65
  cols: 80,
56
66
  buffer: {
57
- active: {
58
- type: 'normal',
67
+ active: normalBuffer,
68
+ normal: normalBuffer,
69
+ alternate: {
70
+ type: 'alternate',
59
71
  baseY: 0,
72
+ cursorY: 0,
73
+ cursorX: 0,
60
74
  length: 0,
61
75
  getLine: vi.fn(function () {
62
76
  return null;
@@ -760,7 +774,7 @@ describe('SessionManager', () => {
760
774
  });
761
775
  });
762
776
  describe('session restore snapshots', () => {
763
- it('should emit serialized terminal output when activating a session', async () => {
777
+ it('should emit a bounded normal-buffer restore snapshot and restore the cursor position', async () => {
764
778
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
765
779
  id: '1',
766
780
  name: 'Main',
@@ -768,14 +782,44 @@ describe('SessionManager', () => {
768
782
  });
769
783
  vi.mocked(spawn).mockReturnValue(mockPty);
770
784
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
785
+ const normalBuffer = session.terminal.buffer.normal;
786
+ normalBuffer.baseY = 260;
787
+ normalBuffer.length = 300;
788
+ normalBuffer.cursorY = 7;
789
+ normalBuffer.cursorX = 11;
790
+ session.restoreScrollbackBaseLine = 120;
771
791
  const serializeMock = vi
772
792
  .spyOn(session.serializer, 'serialize')
773
793
  .mockReturnValue('\u001b[31mrestored\u001b[0m');
774
794
  const restoreHandler = vi.fn();
775
795
  sessionManager.on('sessionRestore', restoreHandler);
776
796
  sessionManager.setSessionActive(session.id, true);
797
+ expect(serializeMock).toHaveBeenCalledWith({
798
+ range: {
799
+ start: 120,
800
+ end: 299,
801
+ },
802
+ excludeAltBuffer: true,
803
+ });
804
+ expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
805
+ });
806
+ it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
807
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
808
+ id: '1',
809
+ name: 'Main',
810
+ command: 'claude',
811
+ });
812
+ vi.mocked(spawn).mockReturnValue(mockPty);
813
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
814
+ session.terminal.buffer.active = session.terminal.buffer.alternate;
815
+ const serializeMock = vi
816
+ .spyOn(session.serializer, 'serialize')
817
+ .mockReturnValue('\u001b[31malt\u001b[0m');
818
+ const restoreHandler = vi.fn();
819
+ sessionManager.on('sessionRestore', restoreHandler);
820
+ sessionManager.setSessionActive(session.id, true);
777
821
  expect(serializeMock).toHaveBeenCalledWith({ scrollback: 0 });
778
- expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m');
822
+ expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31malt\u001b[0m');
779
823
  });
780
824
  it('should skip restore event when serialized output is empty', async () => {
781
825
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -791,6 +835,43 @@ describe('SessionManager', () => {
791
835
  sessionManager.setSessionActive(session.id, true);
792
836
  expect(restoreHandler).not.toHaveBeenCalled();
793
837
  });
838
+ it('should reset restore scrollback baseline after a clear-screen sequence', async () => {
839
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
840
+ id: '1',
841
+ name: 'Main',
842
+ command: 'claude',
843
+ });
844
+ vi.mocked(spawn).mockReturnValue(mockPty);
845
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
846
+ session.terminal.buffer.normal.baseY = 17;
847
+ mockPty.emit('data', '\x1b[2J\x1b[Hfresh');
848
+ expect(session.restoreScrollbackBaseLine).toBe(17);
849
+ });
850
+ it('should flush live session data after the restore snapshot completes', async () => {
851
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
852
+ id: '1',
853
+ name: 'Main',
854
+ command: 'claude',
855
+ });
856
+ vi.mocked(spawn).mockReturnValue(mockPty);
857
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
858
+ session.terminal.buffer.normal.length = 1;
859
+ vi.spyOn(session.serializer, 'serialize').mockReturnValue('restored');
860
+ const eventOrder = [];
861
+ sessionManager.on('sessionRestore', restoredSession => {
862
+ if (restoredSession.id === session.id) {
863
+ eventOrder.push('restore');
864
+ mockPty.emit('data', 'live-output');
865
+ }
866
+ });
867
+ sessionManager.on('sessionData', activeSession => {
868
+ if (activeSession.id === session.id) {
869
+ eventOrder.push('data');
870
+ }
871
+ });
872
+ sessionManager.setSessionActive(session.id, true);
873
+ expect(eventOrder).toEqual(['restore', 'data']);
874
+ });
794
875
  });
795
876
  describe('static methods', () => {
796
877
  describe('getSessionCounts', () => {
@@ -1,8 +1,11 @@
1
1
  import { BaseStateDetector } from './base.js';
2
- // Spinner characters used by Claude Code during active processing
3
- const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆';
4
- // Matches spinner activity labels like "✽ Tempering…" or "✳ Simplifying recompute_tangents…"
2
+ // Spinner / activity-prefix characters (line must still match SPINNER_ACTIVITY_PATTERN: …ing + …)
3
+ // Includes: ornament spinners; · / • / ∙ / ⋅ bullets; ⏺ (record); ▸▹ triangles; ○● circles
4
+ const SPINNER_CHARS = '✱✲✳✴✵✶✷✸✹✺✻✼✽✾✿❀❁❂❃❇❈❉❊❋✢✣✤✥✦✧✨⊛⊕⊙◉◎◍⁂⁕※⍟☼★☆·•⏺▸▹∙⋅○●';
5
+ // Matches spinner activity labels like "✽ Tempering…", "✳ Simplifying…", or "· Misting…"
5
6
  const SPINNER_ACTIVITY_PATTERN = new RegExp(`^[${SPINNER_CHARS}] \\S+ing.*\u2026`, 'm');
7
+ // Session stats above the prompt, e.g. "(9m 21s · ↓ 13.7k tokens)" — requires parens, a digit, and "tokens"
8
+ const TOKEN_STATS_LINE_PATTERN = /\([^)]*\d[^)]*tokens\s*\)/i;
6
9
  const BUSY_LOOKBACK_LINES = 5;
7
10
  // Workaround: Claude Code sometimes appears idle in terminal output while
8
11
  // still actively processing (busy). To mitigate false idle transitions,
@@ -122,6 +125,10 @@ export class ClaudeStateDetector extends BaseStateDetector {
122
125
  if (SPINNER_ACTIVITY_PATTERN.test(abovePromptBox)) {
123
126
  return 'busy';
124
127
  }
128
+ // Usage/time + token count line (often shown above the prompt while a turn is active)
129
+ if (TOKEN_STATS_LINE_PATTERN.test(abovePromptBox)) {
130
+ return 'busy';
131
+ }
125
132
  // Otherwise idle (debounced)
126
133
  return this.debounceIdle(terminal, currentState);
127
134
  }
@@ -330,6 +330,43 @@ describe('ClaudeStateDetector', () => {
330
330
  // Assert
331
331
  expect(state).toBe('busy');
332
332
  });
333
+ it('should detect busy for middle-dot activity label "· …ing…" (e.g. · Misting…)', () => {
334
+ terminal = createMockTerminal([
335
+ '· Misting…',
336
+ ' ⎿ Tip: Run /terminal-setup to enable convenient terminal integration',
337
+ '──────────────────────────────',
338
+ '❯',
339
+ '──────────────────────────────',
340
+ ]);
341
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
342
+ });
343
+ it('should detect busy when token stats line is above prompt box without interrupt or spinner', () => {
344
+ terminal = createMockTerminal([
345
+ '(9m 21s · ↓ 13.7k tokens)',
346
+ '──────────────────────────────',
347
+ '❯',
348
+ '──────────────────────────────',
349
+ ]);
350
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
351
+ });
352
+ it('should detect busy for token stats line with varied spacing and casing', () => {
353
+ terminal = createMockTerminal([
354
+ ' ( 1m · 500 TOKENS ) ',
355
+ '──────────────────────────────',
356
+ '❯',
357
+ '──────────────────────────────',
358
+ ]);
359
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
360
+ });
361
+ it('should not treat parenthetical text with "tokens" but no digit as busy', () => {
362
+ terminal = createMockTerminal([
363
+ '(see tokens in docs)',
364
+ '──────────────────────────────',
365
+ '❯',
366
+ '──────────────────────────────',
367
+ ]);
368
+ expect(detectStateAfterDebounce(detector, terminal, 'idle')).toBe('idle');
369
+ });
333
370
  it('should detect busy with various spinner characters', () => {
334
371
  const spinnerChars = [
335
372
  '✱',
@@ -30,6 +30,7 @@ export interface Session {
30
30
  isActive: boolean;
31
31
  terminal: Terminal;
32
32
  serializer: SerializeAddon;
33
+ restoreScrollbackBaseLine: number;
33
34
  stateCheckInterval: NodeJS.Timeout | undefined;
34
35
  isPrimaryCommand: boolean;
35
36
  presetName: string | undefined;
@@ -38,7 +39,7 @@ export interface Session {
38
39
  /**
39
40
  * Mutex-protected session state data.
40
41
  * Access via stateMutex.runExclusive() or stateMutex.update() to ensure thread-safe operations.
41
- * Contains: state, pendingState, pendingStateStart, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController
42
+ * Contains: state, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController, backgroundTaskCount, teamMemberCount
42
43
  */
43
44
  stateMutex: Mutex<SessionStateData>;
44
45
  /**
@@ -383,6 +383,7 @@ describe('hookExecutor Integration Tests', () => {
383
383
  process: {},
384
384
  terminal: {},
385
385
  serializer: {},
386
+ restoreScrollbackBaseLine: 0,
386
387
  output: [],
387
388
  stateCheckInterval: undefined,
388
389
  isPrimaryCommand: true,
@@ -442,6 +443,7 @@ describe('hookExecutor Integration Tests', () => {
442
443
  process: {},
443
444
  terminal: {},
444
445
  serializer: {},
446
+ restoreScrollbackBaseLine: 0,
445
447
  output: [],
446
448
  stateCheckInterval: undefined,
447
449
  isPrimaryCommand: true,
@@ -499,6 +501,7 @@ describe('hookExecutor Integration Tests', () => {
499
501
  process: {},
500
502
  terminal: {},
501
503
  serializer: {},
504
+ restoreScrollbackBaseLine: 0,
502
505
  output: [],
503
506
  stateCheckInterval: undefined,
504
507
  isPrimaryCommand: true,
@@ -558,6 +561,7 @@ describe('hookExecutor Integration Tests', () => {
558
561
  process: {},
559
562
  terminal: {},
560
563
  serializer: {},
564
+ restoreScrollbackBaseLine: 0,
561
565
  output: [],
562
566
  stateCheckInterval: undefined,
563
567
  isPrimaryCommand: true,
@@ -42,9 +42,6 @@ export declare class Mutex<T> {
42
42
  */
43
43
  export interface SessionStateData {
44
44
  state: import('../types/index.js').SessionState;
45
- pendingState: import('../types/index.js').SessionState | undefined;
46
- pendingStateStart: number | undefined;
47
- stateConfirmedAt: number;
48
45
  autoApprovalFailed: boolean;
49
46
  autoApprovalReason: string | undefined;
50
47
  autoApprovalAbortController: AbortController | undefined;
@@ -80,9 +80,6 @@ export class Mutex {
80
80
  export function createInitialSessionStateData() {
81
81
  return {
82
82
  state: 'busy',
83
- pendingState: undefined,
84
- pendingStateStart: undefined,
85
- stateConfirmedAt: Date.now(),
86
83
  autoApprovalFailed: false,
87
84
  autoApprovalReason: undefined,
88
85
  autoApprovalAbortController: undefined,
@@ -133,6 +133,7 @@ describe('prepareSessionItems', () => {
133
133
  isActive: true,
134
134
  terminal: {},
135
135
  serializer: {},
136
+ restoreScrollbackBaseLine: 0,
136
137
  stateCheckInterval: undefined,
137
138
  isPrimaryCommand: true,
138
139
  presetName: undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.5",
3
+ "version": "4.1.7",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.5",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.5",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.5",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.5",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.5"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.7",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.7",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.7",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.7",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.7"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",
@@ -1,3 +0,0 @@
1
- export declare const STATE_PERSISTENCE_DURATION_MS = 1000;
2
- export declare const STATE_CHECK_INTERVAL_MS = 100;
3
- export declare const STATE_MINIMUM_DURATION_MS = 1000;
@@ -1,9 +0,0 @@
1
- // Duration in milliseconds that a detected state must persist before being confirmed.
2
- // A higher value prevents transient flicker (e.g., brief "idle" during terminal re-renders)
3
- // at the cost of slightly slower state transitions.
4
- export const STATE_PERSISTENCE_DURATION_MS = 1000;
5
- // Check interval for state detection in milliseconds
6
- export const STATE_CHECK_INTERVAL_MS = 100;
7
- // Minimum duration in current state before allowing transition to a new state.
8
- // Prevents rapid back-and-forth flicker (e.g., busy → idle → busy).
9
- export const STATE_MINIMUM_DURATION_MS = 1000;