ccmanager 3.3.2 → 3.4.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.
Files changed (34) hide show
  1. package/dist/components/Menu.recent-projects.test.js +2 -0
  2. package/dist/components/Menu.test.js +2 -0
  3. package/dist/constants/statusIcons.d.ts +4 -1
  4. package/dist/constants/statusIcons.js +10 -1
  5. package/dist/constants/statusIcons.test.d.ts +1 -0
  6. package/dist/constants/statusIcons.test.js +42 -0
  7. package/dist/services/sessionManager.autoApproval.test.js +4 -1
  8. package/dist/services/sessionManager.d.ts +2 -0
  9. package/dist/services/sessionManager.js +26 -6
  10. package/dist/services/sessionManager.test.js +19 -2
  11. package/dist/services/stateDetector/base.d.ts +1 -0
  12. package/dist/services/stateDetector/claude.d.ts +1 -0
  13. package/dist/services/stateDetector/claude.js +8 -0
  14. package/dist/services/stateDetector/claude.test.js +102 -0
  15. package/dist/services/stateDetector/cline.d.ts +1 -0
  16. package/dist/services/stateDetector/cline.js +3 -0
  17. package/dist/services/stateDetector/codex.d.ts +1 -0
  18. package/dist/services/stateDetector/codex.js +3 -0
  19. package/dist/services/stateDetector/cursor.d.ts +1 -0
  20. package/dist/services/stateDetector/cursor.js +3 -0
  21. package/dist/services/stateDetector/gemini.d.ts +1 -0
  22. package/dist/services/stateDetector/gemini.js +3 -0
  23. package/dist/services/stateDetector/github-copilot.d.ts +1 -0
  24. package/dist/services/stateDetector/github-copilot.js +3 -0
  25. package/dist/services/stateDetector/opencode.d.ts +1 -0
  26. package/dist/services/stateDetector/opencode.js +3 -0
  27. package/dist/services/stateDetector/types.d.ts +1 -0
  28. package/dist/types/index.d.ts +6 -0
  29. package/dist/utils/hookExecutor.test.js +5 -0
  30. package/dist/utils/mutex.d.ts +1 -0
  31. package/dist/utils/mutex.js +1 -0
  32. package/dist/utils/worktreeUtils.js +3 -2
  33. package/dist/utils/worktreeUtils.test.js +2 -0
  34. package/package.json +6 -6
@@ -123,6 +123,7 @@ describe('Menu - Recent Projects', () => {
123
123
  waiting_input: 0,
124
124
  pending_auto_approval: 0,
125
125
  total: 0,
126
+ backgroundTasks: 0,
126
127
  });
127
128
  vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
128
129
  const { lastFrame } = render(React.createElement(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true }));
@@ -153,6 +154,7 @@ describe('Menu - Recent Projects', () => {
153
154
  waiting_input: 0,
154
155
  pending_auto_approval: 0,
155
156
  total: 0,
157
+ backgroundTasks: 0,
156
158
  });
157
159
  vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
158
160
  const { lastFrame } = render(React.createElement(Menu, { sessionManager: mockSessionManager, worktreeService: mockWorktreeService, onSelectWorktree: vi.fn(), onSelectRecentProject: vi.fn(), multiProject: true }));
@@ -276,6 +276,7 @@ describe('Menu component rendering', () => {
276
276
  waiting_input: 0,
277
277
  pending_auto_approval: 0,
278
278
  total: 0,
279
+ backgroundTasks: 0,
279
280
  });
280
281
  vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
281
282
  const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onSelectRecentProject: onSelectRecentProject, multiProject: true }));
@@ -317,6 +318,7 @@ describe('Menu component rendering', () => {
317
318
  waiting_input: 0,
318
319
  pending_auto_approval: 0,
319
320
  total: 0,
321
+ backgroundTasks: 0,
320
322
  });
321
323
  vi.spyOn(SessionManager, 'formatSessionCounts').mockReturnValue('');
322
324
  const { lastFrame } = render(React.createElement(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onSelectWorktree: onSelectWorktree, onSelectRecentProject: onSelectRecentProject, multiProject: true }));
@@ -10,6 +10,9 @@ export declare const STATUS_LABELS: {
10
10
  readonly PENDING_AUTO_APPROVAL: "Pending Auto Approval";
11
11
  readonly IDLE: "Idle";
12
12
  };
13
+ export declare const STATUS_TAGS: {
14
+ readonly BACKGROUND_TASK: "\u001B[2m[BG]\u001B[0m";
15
+ };
13
16
  export declare const MENU_ICONS: {
14
17
  readonly NEW_WORKTREE: "⊕";
15
18
  readonly MERGE_WORKTREE: "⇄";
@@ -17,4 +20,4 @@ export declare const MENU_ICONS: {
17
20
  readonly CONFIGURE_SHORTCUTS: "⌨";
18
21
  readonly EXIT: "⏻";
19
22
  };
20
- export declare const getStatusDisplay: (status: SessionState) => string;
23
+ export declare const getStatusDisplay: (status: SessionState, hasBackgroundTask?: boolean) => string;
@@ -9,6 +9,9 @@ export const STATUS_LABELS = {
9
9
  PENDING_AUTO_APPROVAL: 'Pending Auto Approval',
10
10
  IDLE: 'Idle',
11
11
  };
12
+ export const STATUS_TAGS = {
13
+ BACKGROUND_TASK: '\x1b[2m[BG]\x1b[0m',
14
+ };
12
15
  export const MENU_ICONS = {
13
16
  NEW_WORKTREE: '⊕',
14
17
  MERGE_WORKTREE: '⇄',
@@ -16,7 +19,7 @@ export const MENU_ICONS = {
16
19
  CONFIGURE_SHORTCUTS: '⌨',
17
20
  EXIT: '⏻',
18
21
  };
19
- export const getStatusDisplay = (status) => {
22
+ const getBaseStatusDisplay = (status) => {
20
23
  switch (status) {
21
24
  case 'busy':
22
25
  return `${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY}`;
@@ -28,3 +31,9 @@ export const getStatusDisplay = (status) => {
28
31
  return `${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`;
29
32
  }
30
33
  };
34
+ export const getStatusDisplay = (status, hasBackgroundTask = false) => {
35
+ const display = getBaseStatusDisplay(status);
36
+ return hasBackgroundTask
37
+ ? `${display} ${STATUS_TAGS.BACKGROUND_TASK}`
38
+ : display;
39
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getStatusDisplay, STATUS_ICONS, STATUS_LABELS, STATUS_TAGS, } from './statusIcons.js';
3
+ describe('getStatusDisplay', () => {
4
+ it('should return busy display for busy state', () => {
5
+ const result = getStatusDisplay('busy');
6
+ expect(result).toBe(`${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY}`);
7
+ });
8
+ it('should return waiting display for waiting_input state', () => {
9
+ const result = getStatusDisplay('waiting_input');
10
+ expect(result).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.WAITING}`);
11
+ });
12
+ it('should return pending auto approval display', () => {
13
+ const result = getStatusDisplay('pending_auto_approval');
14
+ expect(result).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.PENDING_AUTO_APPROVAL}`);
15
+ });
16
+ it('should return idle display for idle state', () => {
17
+ const result = getStatusDisplay('idle');
18
+ expect(result).toBe(`${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`);
19
+ });
20
+ describe('background task indicator', () => {
21
+ it('should append [BG] badge when idle and hasBackgroundTask is true', () => {
22
+ const result = getStatusDisplay('idle', true);
23
+ expect(result).toBe(`${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE} ${STATUS_TAGS.BACKGROUND_TASK}`);
24
+ });
25
+ it('should not append [BG] badge when idle and hasBackgroundTask is false', () => {
26
+ const result = getStatusDisplay('idle', false);
27
+ expect(result).toBe(`${STATUS_ICONS.IDLE} ${STATUS_LABELS.IDLE}`);
28
+ });
29
+ it('should append [BG] badge when busy and hasBackgroundTask is true', () => {
30
+ const result = getStatusDisplay('busy', true);
31
+ expect(result).toBe(`${STATUS_ICONS.BUSY} ${STATUS_LABELS.BUSY} ${STATUS_TAGS.BACKGROUND_TASK}`);
32
+ });
33
+ it('should append [BG] badge when waiting_input and hasBackgroundTask is true', () => {
34
+ const result = getStatusDisplay('waiting_input', true);
35
+ expect(result).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.WAITING} ${STATUS_TAGS.BACKGROUND_TASK}`);
36
+ });
37
+ it('should append [BG] badge when pending_auto_approval and hasBackgroundTask is true', () => {
38
+ const result = getStatusDisplay('pending_auto_approval', true);
39
+ expect(result).toBe(`${STATUS_ICONS.WAITING} ${STATUS_LABELS.PENDING_AUTO_APPROVAL} ${STATUS_TAGS.BACKGROUND_TASK}`);
40
+ });
41
+ });
42
+ });
@@ -15,7 +15,10 @@ vi.mock('./bunTerminal.js', () => ({
15
15
  }),
16
16
  }));
17
17
  vi.mock('./stateDetector/index.js', () => ({
18
- createStateDetector: () => ({ detectState: detectStateMock }),
18
+ createStateDetector: () => ({
19
+ detectState: detectStateMock,
20
+ detectBackgroundTask: () => false,
21
+ }),
19
22
  }));
20
23
  vi.mock('./configurationManager.js', () => ({
21
24
  configurationManager: {
@@ -8,6 +8,7 @@ export interface SessionCounts {
8
8
  waiting_input: number;
9
9
  pending_auto_approval: number;
10
10
  total: number;
11
+ backgroundTasks: number;
11
12
  }
12
13
  export declare class SessionManager extends EventEmitter implements ISessionManager {
13
14
  sessions: Map<string, Session>;
@@ -15,6 +16,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
15
16
  private busyTimers;
16
17
  private spawn;
17
18
  detectTerminalState(session: Session): SessionState;
19
+ detectBackgroundTask(session: Session): boolean;
18
20
  private getTerminalContent;
19
21
  private handleAutoApproval;
20
22
  private cancelAutoApprovalVerification;
@@ -12,6 +12,7 @@ import { ProcessError, ConfigError } from '../types/errors.js';
12
12
  import { autoApprovalVerifier } from './autoApprovalVerifier.js';
13
13
  import { logger } from '../utils/logger.js';
14
14
  import { Mutex, createInitialSessionStateData } from '../utils/mutex.js';
15
+ import { STATUS_TAGS } from '../constants/statusIcons.js';
15
16
  import { getTerminalScreenContent } from '../utils/screenCapture.js';
16
17
  const { Terminal } = pkg;
17
18
  const execAsync = promisify(exec);
@@ -28,11 +29,8 @@ export class SessionManager extends EventEmitter {
28
29
  return spawn(command, args, spawnOptions);
29
30
  }
30
31
  detectTerminalState(session) {
31
- // Create a detector based on the session's detection strategy
32
- const strategy = session.detectionStrategy || 'claude';
33
- const detector = createStateDetector(strategy);
34
32
  const stateData = session.stateMutex.getSnapshot();
35
- const detectedState = detector.detectState(session.terminal, stateData.state);
33
+ const detectedState = session.stateDetector.detectState(session.terminal, stateData.state);
36
34
  // If auto-approval is enabled and state is waiting_input, convert to pending_auto_approval
37
35
  if (detectedState === 'waiting_input' &&
38
36
  configurationManager.isAutoApprovalEnabled() &&
@@ -41,6 +39,9 @@ export class SessionManager extends EventEmitter {
41
39
  }
42
40
  return detectedState;
43
41
  }
42
+ detectBackgroundTask(session) {
43
+ return session.stateDetector.detectBackgroundTask(session.terminal);
44
+ }
44
45
  getTerminalContent(session) {
45
46
  // Use the new screen capture utility that correctly handles
46
47
  // both normal and alternate screen buffers
@@ -190,6 +191,8 @@ export class SessionManager extends EventEmitter {
190
191
  async createSessionInternal(worktreePath, ptyProcess, commandConfig, options = {}) {
191
192
  const id = this.createSessionId();
192
193
  const terminal = this.createTerminal();
194
+ const detectionStrategy = options.detectionStrategy ?? 'claude';
195
+ const stateDetector = createStateDetector(detectionStrategy);
193
196
  const session = {
194
197
  id,
195
198
  worktreePath,
@@ -202,9 +205,10 @@ export class SessionManager extends EventEmitter {
202
205
  stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
203
206
  isPrimaryCommand: options.isPrimaryCommand ?? true,
204
207
  commandConfig,
205
- detectionStrategy: options.detectionStrategy ?? 'claude',
208
+ detectionStrategy,
206
209
  devcontainerConfig: options.devcontainerConfig ?? undefined,
207
210
  stateMutex: new Mutex(createInitialSessionStateData()),
211
+ stateDetector,
208
212
  };
209
213
  // Set up persistent background data handler for state detection
210
214
  this.setupBackgroundHandler(session);
@@ -426,6 +430,14 @@ export class SessionManager extends EventEmitter {
426
430
  !currentStateData.autoApprovalAbortController) {
427
431
  this.handleAutoApproval(session);
428
432
  }
433
+ // Detect and update background task flag
434
+ const hasBackgroundTask = this.detectBackgroundTask(session);
435
+ if (currentStateData.hasBackgroundTask !== hasBackgroundTask) {
436
+ void session.stateMutex.update(data => ({
437
+ ...data,
438
+ hasBackgroundTask,
439
+ }));
440
+ }
429
441
  }, STATE_CHECK_INTERVAL_MS);
430
442
  // Setup exit handler
431
443
  this.setupExitHandler(session);
@@ -677,6 +689,7 @@ export class SessionManager extends EventEmitter {
677
689
  waiting_input: 0,
678
690
  pending_auto_approval: 0,
679
691
  total: sessions.length,
692
+ backgroundTasks: 0,
680
693
  };
681
694
  sessions.forEach(session => {
682
695
  const stateData = session.stateMutex.getSnapshot();
@@ -694,6 +707,9 @@ export class SessionManager extends EventEmitter {
694
707
  counts.pending_auto_approval++;
695
708
  break;
696
709
  }
710
+ if (stateData.hasBackgroundTask) {
711
+ counts.backgroundTasks++;
712
+ }
697
713
  });
698
714
  return counts;
699
715
  }
@@ -711,6 +727,10 @@ export class SessionManager extends EventEmitter {
711
727
  if (counts.waiting_input > 0) {
712
728
  parts.push(`${counts.waiting_input} Waiting`);
713
729
  }
714
- return parts.length > 0 ? ` (${parts.join(' / ')})` : '';
730
+ if (parts.length === 0) {
731
+ return '';
732
+ }
733
+ const bgTag = counts.backgroundTasks > 0 ? ` ${STATUS_TAGS.BACKGROUND_TASK}` : '';
734
+ return ` (${parts.join(' / ')}${bgTag})`;
715
735
  }
716
736
  }
@@ -720,10 +720,10 @@ describe('SessionManager', () => {
720
720
  describe('static methods', () => {
721
721
  describe('getSessionCounts', () => {
722
722
  // Helper to create mock session with stateMutex
723
- const createMockSession = (id, state) => ({
723
+ const createMockSession = (id, state, hasBackgroundTask = false) => ({
724
724
  id,
725
725
  stateMutex: {
726
- getSnapshot: () => ({ state }),
726
+ getSnapshot: () => ({ state, hasBackgroundTask }),
727
727
  },
728
728
  });
729
729
  it('should count sessions by state', () => {
@@ -768,6 +768,7 @@ describe('SessionManager', () => {
768
768
  waiting_input: 1,
769
769
  pending_auto_approval: 0,
770
770
  total: 4,
771
+ backgroundTasks: 0,
771
772
  };
772
773
  const formatted = SessionManager.formatSessionCounts(counts);
773
774
  expect(formatted).toBe(' (1 Idle / 2 Busy / 1 Waiting)');
@@ -779,6 +780,7 @@ describe('SessionManager', () => {
779
780
  waiting_input: 1,
780
781
  pending_auto_approval: 0,
781
782
  total: 3,
783
+ backgroundTasks: 0,
782
784
  };
783
785
  const formatted = SessionManager.formatSessionCounts(counts);
784
786
  expect(formatted).toBe(' (2 Idle / 1 Waiting)');
@@ -790,6 +792,7 @@ describe('SessionManager', () => {
790
792
  waiting_input: 0,
791
793
  pending_auto_approval: 0,
792
794
  total: 3,
795
+ backgroundTasks: 0,
793
796
  };
794
797
  const formatted = SessionManager.formatSessionCounts(counts);
795
798
  expect(formatted).toBe(' (3 Busy)');
@@ -801,10 +804,24 @@ describe('SessionManager', () => {
801
804
  waiting_input: 0,
802
805
  pending_auto_approval: 0,
803
806
  total: 0,
807
+ backgroundTasks: 0,
804
808
  };
805
809
  const formatted = SessionManager.formatSessionCounts(counts);
806
810
  expect(formatted).toBe('');
807
811
  });
812
+ it('should append [BG] tag when background tasks exist', () => {
813
+ const counts = {
814
+ idle: 1,
815
+ busy: 1,
816
+ waiting_input: 0,
817
+ pending_auto_approval: 0,
818
+ total: 2,
819
+ backgroundTasks: 1,
820
+ };
821
+ const formatted = SessionManager.formatSessionCounts(counts);
822
+ expect(formatted).toContain('[BG]');
823
+ expect(formatted).toBe(' (1 Idle / 1 Busy \x1b[2m[BG]\x1b[0m)');
824
+ });
808
825
  });
809
826
  });
810
827
  });
@@ -4,4 +4,5 @@ export declare abstract class BaseStateDetector implements StateDetector {
4
4
  abstract detectState(terminal: Terminal, currentState: SessionState): SessionState;
5
5
  protected getTerminalLines(terminal: Terminal, maxLines?: number): string[];
6
6
  protected getTerminalContent(terminal: Terminal, maxLines?: number): string;
7
+ abstract detectBackgroundTask(terminal: Terminal): boolean;
7
8
  }
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
3
  export declare class ClaudeStateDetector extends BaseStateDetector {
4
4
  detectState(terminal: Terminal, currentState: SessionState): SessionState;
5
+ detectBackgroundTask(terminal: Terminal): boolean;
5
6
  }
@@ -24,4 +24,12 @@ export class ClaudeStateDetector extends BaseStateDetector {
24
24
  // Otherwise idle
25
25
  return 'idle';
26
26
  }
27
+ detectBackgroundTask(terminal) {
28
+ const lines = this.getTerminalLines(terminal, 3);
29
+ const content = lines.join('\n').toLowerCase();
30
+ // Detect background task patterns:
31
+ // - "N background task(s)" in status bar
32
+ // - "(running)" in status bar for active background commands
33
+ return content.includes('background task') || content.includes('(running)');
34
+ }
27
35
  }
@@ -222,4 +222,106 @@ describe('ClaudeStateDetector', () => {
222
222
  expect(state).toBe('waiting_input');
223
223
  });
224
224
  });
225
+ describe('detectBackgroundTask', () => {
226
+ it('should detect background task when pattern is in last 3 lines (status bar)', () => {
227
+ // Arrange
228
+ terminal = createMockTerminal([
229
+ 'Previous conversation content',
230
+ 'More content',
231
+ '> Some command output',
232
+ '1 background task | api-call',
233
+ ]);
234
+ // Act
235
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
236
+ // Assert
237
+ expect(hasBackgroundTask).toBe(true);
238
+ });
239
+ it('should detect background task with plural "background tasks"', () => {
240
+ // Arrange
241
+ terminal = createMockTerminal([
242
+ 'Some output',
243
+ 'More output',
244
+ '2 background tasks running',
245
+ ]);
246
+ // Act
247
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
248
+ // Assert
249
+ expect(hasBackgroundTask).toBe(true);
250
+ });
251
+ it('should detect background task case-insensitively', () => {
252
+ // Arrange
253
+ terminal = createMockTerminal([
254
+ 'Output line 1',
255
+ 'Output line 2',
256
+ '1 BACKGROUND TASK running',
257
+ ]);
258
+ // Act
259
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
260
+ // Assert
261
+ expect(hasBackgroundTask).toBe(true);
262
+ });
263
+ it('should return false when no background task pattern in last 3 lines', () => {
264
+ // Arrange
265
+ terminal = createMockTerminal([
266
+ 'Command completed successfully',
267
+ 'Ready for next command',
268
+ '> ',
269
+ ]);
270
+ // Act
271
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
272
+ // Assert
273
+ expect(hasBackgroundTask).toBe(false);
274
+ });
275
+ it('should not detect background task when pattern is in conversation content (not status bar)', () => {
276
+ // Arrange - "background task" mentioned earlier in conversation, but not in last 3 lines
277
+ terminal = createMockTerminal([
278
+ 'User: Tell me about background task handling',
279
+ 'Assistant: Background task detection works by...',
280
+ 'The pattern "background task" appears in text but...',
281
+ 'This is the status bar area',
282
+ '> idle',
283
+ 'Ready',
284
+ ]);
285
+ // Act
286
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
287
+ // Assert - should only check last 3 lines, not the conversation content
288
+ expect(hasBackgroundTask).toBe(false);
289
+ });
290
+ it('should handle empty terminal', () => {
291
+ // Arrange
292
+ terminal = createMockTerminal([]);
293
+ // Act
294
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
295
+ // Assert
296
+ expect(hasBackgroundTask).toBe(false);
297
+ });
298
+ it('should handle terminal with fewer than 3 lines', () => {
299
+ // Arrange
300
+ terminal = createMockTerminal(['1 background task']);
301
+ // Act
302
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
303
+ // Assert
304
+ expect(hasBackgroundTask).toBe(true);
305
+ });
306
+ it('should detect "(running)" status bar indicator', () => {
307
+ // Arrange
308
+ terminal = createMockTerminal([
309
+ 'Some conversation output',
310
+ 'More output',
311
+ 'bypass permissions on - uv run pytest tests/integration/e2e/tes... (running)',
312
+ ]);
313
+ // Act
314
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
315
+ // Assert
316
+ expect(hasBackgroundTask).toBe(true);
317
+ });
318
+ it('should detect "(running)" case-insensitively', () => {
319
+ // Arrange
320
+ terminal = createMockTerminal(['Some output', 'command name (RUNNING)']);
321
+ // Act
322
+ const hasBackgroundTask = detector.detectBackgroundTask(terminal);
323
+ // Assert
324
+ expect(hasBackgroundTask).toBe(true);
325
+ });
326
+ });
225
327
  });
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
3
  export declare class ClineStateDetector extends BaseStateDetector {
4
4
  detectState(terminal: Terminal, _currentState: SessionState): SessionState;
5
+ detectBackgroundTask(_terminal: Terminal): boolean;
5
6
  }
@@ -21,4 +21,7 @@ export class ClineStateDetector extends BaseStateDetector {
21
21
  // Otherwise busy - Priority 3
22
22
  return 'busy';
23
23
  }
24
+ detectBackgroundTask(_terminal) {
25
+ return false;
26
+ }
24
27
  }
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
3
  export declare class CodexStateDetector extends BaseStateDetector {
4
4
  detectState(terminal: Terminal, _currentState: SessionState): SessionState;
5
+ detectBackgroundTask(_terminal: Terminal): boolean;
5
6
  }
@@ -24,4 +24,7 @@ export class CodexStateDetector extends BaseStateDetector {
24
24
  // Otherwise idle
25
25
  return 'idle';
26
26
  }
27
+ detectBackgroundTask(_terminal) {
28
+ return false;
29
+ }
27
30
  }
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
3
  export declare class CursorStateDetector extends BaseStateDetector {
4
4
  detectState(terminal: Terminal, _currentState: SessionState): SessionState;
5
+ detectBackgroundTask(_terminal: Terminal): boolean;
5
6
  }
@@ -16,4 +16,7 @@ export class CursorStateDetector extends BaseStateDetector {
16
16
  // Otherwise idle - Priority 3
17
17
  return 'idle';
18
18
  }
19
+ detectBackgroundTask(_terminal) {
20
+ return false;
21
+ }
19
22
  }
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
3
  export declare class GeminiStateDetector extends BaseStateDetector {
4
4
  detectState(terminal: Terminal, _currentState: SessionState): SessionState;
5
+ detectBackgroundTask(_terminal: Terminal): boolean;
5
6
  }
@@ -25,4 +25,7 @@ export class GeminiStateDetector extends BaseStateDetector {
25
25
  // Otherwise idle
26
26
  return 'idle';
27
27
  }
28
+ detectBackgroundTask(_terminal) {
29
+ return false;
30
+ }
28
31
  }
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
3
  export declare class GitHubCopilotStateDetector extends BaseStateDetector {
4
4
  detectState(terminal: Terminal, _currentState: SessionState): SessionState;
5
+ detectBackgroundTask(_terminal: Terminal): boolean;
5
6
  }
@@ -18,4 +18,7 @@ export class GitHubCopilotStateDetector extends BaseStateDetector {
18
18
  // Otherwise idle as priority 4
19
19
  return 'idle';
20
20
  }
21
+ detectBackgroundTask(_terminal) {
22
+ return false;
23
+ }
21
24
  }
@@ -2,4 +2,5 @@ import { SessionState, Terminal } from '../../types/index.js';
2
2
  import { BaseStateDetector } from './base.js';
3
3
  export declare class OpenCodeStateDetector extends BaseStateDetector {
4
4
  detectState(terminal: Terminal, _currentState: SessionState): SessionState;
5
+ detectBackgroundTask(_terminal: Terminal): boolean;
5
6
  }
@@ -14,4 +14,7 @@ export class OpenCodeStateDetector extends BaseStateDetector {
14
14
  // Otherwise idle
15
15
  return 'idle';
16
16
  }
17
+ detectBackgroundTask(_terminal) {
18
+ return false;
19
+ }
17
20
  }
@@ -1,4 +1,5 @@
1
1
  import { SessionState, Terminal } from '../../types/index.js';
2
2
  export interface StateDetector {
3
3
  detectState(terminal: Terminal, currentState: SessionState): SessionState;
4
+ detectBackgroundTask(terminal: Terminal): boolean;
4
5
  }
@@ -2,6 +2,7 @@ import type { IPty } from '../services/bunTerminal.js';
2
2
  import type pkg from '@xterm/headless';
3
3
  import { GitStatus } from '../utils/gitStatus.js';
4
4
  import { Mutex, SessionStateData } from '../utils/mutex.js';
5
+ import type { StateDetector } from '../services/stateDetector/types.js';
5
6
  export type Terminal = InstanceType<typeof pkg.Terminal>;
6
7
  export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'pending_auto_approval';
7
8
  export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor' | 'github-copilot' | 'cline' | 'opencode';
@@ -33,6 +34,11 @@ export interface Session {
33
34
  * Contains: state, pendingState, pendingStateStart, autoApprovalFailed, autoApprovalReason, autoApprovalAbortController
34
35
  */
35
36
  stateMutex: Mutex<SessionStateData>;
37
+ /**
38
+ * State detector instance for this session.
39
+ * Created once during session initialization based on detectionStrategy.
40
+ */
41
+ stateDetector: StateDetector;
36
42
  }
37
43
  export interface AutoApprovalResponse {
38
44
  needsPermission: boolean;
@@ -8,6 +8,7 @@ import { configurationManager } from '../services/configurationManager.js';
8
8
  import { WorktreeService } from '../services/worktreeService.js';
9
9
  import { GitError } from '../types/errors.js';
10
10
  import { Mutex, createInitialSessionStateData } from './mutex.js';
11
+ import { createStateDetector } from '../services/stateDetector/index.js';
11
12
  // Mock the configurationManager
12
13
  vi.mock('../services/configurationManager.js', () => ({
13
14
  configurationManager: {
@@ -275,6 +276,7 @@ describe('hookExecutor Integration Tests', () => {
275
276
  lastActivity: new Date(),
276
277
  isActive: true,
277
278
  stateMutex: new Mutex(createInitialSessionStateData()),
279
+ stateDetector: createStateDetector('claude'),
278
280
  };
279
281
  // Mock WorktreeService to return a worktree with the tmpDir path
280
282
  vi.mocked(WorktreeService).mockImplementation(function () {
@@ -329,6 +331,7 @@ describe('hookExecutor Integration Tests', () => {
329
331
  lastActivity: new Date(),
330
332
  isActive: true,
331
333
  stateMutex: new Mutex(createInitialSessionStateData()),
334
+ stateDetector: createStateDetector('claude'),
332
335
  };
333
336
  // Mock WorktreeService to return a worktree with the tmpDir path
334
337
  vi.mocked(WorktreeService).mockImplementation(function () {
@@ -381,6 +384,7 @@ describe('hookExecutor Integration Tests', () => {
381
384
  lastActivity: new Date(),
382
385
  isActive: true,
383
386
  stateMutex: new Mutex(createInitialSessionStateData()),
387
+ stateDetector: createStateDetector('claude'),
384
388
  };
385
389
  // Mock WorktreeService to return a worktree with the tmpDir path
386
390
  vi.mocked(WorktreeService).mockImplementation(function () {
@@ -435,6 +439,7 @@ describe('hookExecutor Integration Tests', () => {
435
439
  lastActivity: new Date(),
436
440
  isActive: true,
437
441
  stateMutex: new Mutex(createInitialSessionStateData()),
442
+ stateDetector: createStateDetector('claude'),
438
443
  };
439
444
  // Mock WorktreeService to fail with GitError
440
445
  vi.mocked(WorktreeService).mockImplementation(function () {
@@ -47,6 +47,7 @@ export interface SessionStateData {
47
47
  autoApprovalFailed: boolean;
48
48
  autoApprovalReason: string | undefined;
49
49
  autoApprovalAbortController: AbortController | undefined;
50
+ hasBackgroundTask: boolean;
50
51
  }
51
52
  /**
52
53
  * Create initial session state data with default values.
@@ -100,5 +100,6 @@ export function createInitialSessionStateData() {
100
100
  autoApprovalFailed: false,
101
101
  autoApprovalReason: undefined,
102
102
  autoApprovalAbortController: undefined,
103
+ hasBackgroundTask: false,
103
104
  };
104
105
  }
@@ -71,8 +71,9 @@ export function extractBranchParts(branchName) {
71
71
  export function prepareWorktreeItems(worktrees, sessions) {
72
72
  return worktrees.map(wt => {
73
73
  const session = sessions.find(s => s.worktreePath === wt.path);
74
- const status = session
75
- ? ` [${getStatusDisplay(session.stateMutex.getSnapshot().state)}]`
74
+ const stateData = session?.stateMutex.getSnapshot();
75
+ const status = stateData
76
+ ? ` [${getStatusDisplay(stateData.state, stateData.hasBackgroundTask)}]`
76
77
  : '';
77
78
  const fullBranchName = wt.branch
78
79
  ? wt.branch.replace('refs/heads/', '')
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from './worktreeUtils.js';
3
3
  import { execSync } from 'child_process';
4
4
  import { Mutex, createInitialSessionStateData } from './mutex.js';
5
+ import { createStateDetector } from '../services/stateDetector/index.js';
5
6
  // Mock child_process module
6
7
  vi.mock('child_process');
7
8
  describe('generateWorktreeDirectory', () => {
@@ -137,6 +138,7 @@ describe('prepareWorktreeItems', () => {
137
138
  ...createInitialSessionStateData(),
138
139
  state: 'idle',
139
140
  }),
141
+ stateDetector: createStateDetector('claude'),
140
142
  };
141
143
  it('should prepare basic worktree without git status', () => {
142
144
  const items = prepareWorktreeItems([mockWorktree], []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.3.2",
3
+ "version": "3.4.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.3.2",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.3.2",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.3.2",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.3.2",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.3.2"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "3.4.0",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "3.4.0",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "3.4.0",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "3.4.0",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "3.4.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",