ccmanager 4.0.2 → 4.0.4

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.
@@ -42,6 +42,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
42
42
  const [pendingWorktreeCreation, setPendingWorktreeCreation] = useState(null);
43
43
  // State for loading context - track flags for message composition
44
44
  const [loadingContext, setLoadingContext] = useState({});
45
+ // State for streaming devcontainer up logs
46
+ const [devcontainerLogs, setDevcontainerLogs] = useState([]);
45
47
  // Helper function to format error messages based on error type using _tag discrimination
46
48
  const formatErrorMessage = (error) => {
47
49
  switch (error._tag) {
@@ -59,8 +61,15 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
59
61
  };
60
62
  // Helper function to create session with Effect-based error handling
61
63
  const createSessionWithEffect = useCallback(async (worktreePath, presetId, initialPrompt) => {
64
+ setDevcontainerLogs([]);
62
65
  const sessionEffect = devcontainerConfig
63
- ? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt)
66
+ ? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt, (line) => {
67
+ setDevcontainerLogs(prev => {
68
+ const next = [...prev, line];
69
+ // Keep only the last 10 lines to avoid unbounded growth
70
+ return next.length > 10 ? next.slice(-10) : next;
71
+ });
72
+ })
64
73
  : sessionManager.createSessionWithPresetEffect(worktreePath, presetId, initialPrompt);
65
74
  const result = await Effect.runPromise(Effect.either(sessionEffect));
66
75
  if (result._tag === 'Left') {
@@ -449,6 +458,11 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
449
458
  setLoadingContext({ deleteBranch });
450
459
  setView('deleting-worktree');
451
460
  setError(null);
461
+ // Yield to the event loop so Ink can paint `deleting-worktree` before git work runs.
462
+ // Otherwise the confirmation UI stays visible until deletion finishes (no spinner).
463
+ await new Promise(resolve => {
464
+ setTimeout(resolve, 0);
465
+ });
452
466
  // Delete the worktrees sequentially using Effect
453
467
  let hasError = false;
454
468
  for (const path of worktreePaths) {
@@ -619,7 +633,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
619
633
  // Use yellow color for devcontainer operations (longer duration),
620
634
  // cyan for standard session creation
621
635
  const color = devcontainerConfig ? 'yellow' : 'cyan';
622
- return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: message, color: color }) }));
636
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LoadingSpinner, { message: message, color: color }), devcontainerLogs.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: devcontainerLogs.map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) }))] }));
623
637
  }
624
638
  if (view === 'creating-session-preset') {
625
639
  // Always display preset-specific message
@@ -631,7 +645,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
631
645
  : 'Creating session with preset...';
632
646
  // Use yellow color for devcontainer, cyan for standard
633
647
  const color = devcontainerConfig ? 'yellow' : 'cyan';
634
- return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: message, color: color }) }));
648
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LoadingSpinner, { message: message, color: color }), devcontainerLogs.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: devcontainerLogs.map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) }))] }));
635
649
  }
636
650
  if (view === 'clearing') {
637
651
  // Render nothing during the clearing phase to ensure clean transition
@@ -27,6 +27,7 @@ export interface IPtyForkOptions {
27
27
  rows?: number;
28
28
  cwd?: string;
29
29
  env?: Record<string, string | undefined>;
30
+ rawMode?: boolean;
30
31
  }
31
32
  /**
32
33
  * Interface for interacting with a pseudo-terminal (PTY) instance.
@@ -57,9 +57,10 @@ class BunTerminal {
57
57
  this._processBuffer();
58
58
  },
59
59
  });
60
- // Match node-pty behavior by starting in raw mode (no canonical input/echo),
61
- // while keeping Bun's output processing defaults intact.
62
- this._terminal.setRawMode(true);
60
+ // Most interactive CLIs work best when the PTY starts in raw mode, but
61
+ // terminal proxy commands such as `devcontainer exec` manage termios
62
+ // themselves and break if the outer PTY is forced raw first.
63
+ this._terminal.setRawMode(options.rawMode ?? true);
63
64
  // Disable ONLCR in the PTY output flags to avoid double CRLF translation
64
65
  // when forwarding PTY output to the real stdout TTY.
65
66
  const ONLCR_FLAG = 0x0002;
@@ -59,9 +59,8 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
59
59
  /**
60
60
  * Sets up exit handler for the session process.
61
61
  * When the process exits with code 1 and it's the primary command,
62
- * it will attempt to spawn a fallback process.
63
- * If fallbackArgs are configured, they will be used.
64
- * If no fallbackArgs are configured, the command will be retried with no arguments.
62
+ * it will attempt a single retry using the configured command with fallback args.
63
+ * If fallbackArgs are not configured, it retries the configured command with no args.
65
64
  */
66
65
  private setupExitHandler;
67
66
  private setupBackgroundHandler;
@@ -96,7 +95,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
96
95
  * Create session with devcontainer integration using Effect-based error handling
97
96
  * @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
98
97
  */
99
- createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string, initialPrompt?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
98
+ createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string, initialPrompt?: string, onLog?: (line: string) => void): Effect.Effect<Session, ProcessError | ConfigError, never>;
100
99
  destroy(): void;
101
100
  static getSessionCounts(sessions: Session[]): SessionCounts;
102
101
  static formatSessionCounts(counts: SessionCounts): string;
@@ -9,8 +9,17 @@ vi.mock('./bunTerminal.js', () => ({
9
9
  return null;
10
10
  }),
11
11
  }));
12
+ // Helper to create a mock child process for child_process.spawn
13
+ function createMockChildProcess(exitCode = 0) {
14
+ const stdout = new EventEmitter();
15
+ const stderr = new EventEmitter();
16
+ const proc = Object.assign(new EventEmitter(), { stdout, stderr });
17
+ process.nextTick(() => proc.emit('close', exitCode));
18
+ return proc;
19
+ }
12
20
  // Mock child_process
13
21
  vi.mock('child_process', () => ({
22
+ spawn: vi.fn(() => createMockChildProcess(0)),
14
23
  exec: vi.fn(),
15
24
  execFile: vi.fn(),
16
25
  }));
@@ -167,18 +176,6 @@ describe('SessionManager Effect-based Operations', () => {
167
176
  });
168
177
  // Setup spawn mock
169
178
  vi.mocked(spawn).mockReturnValue(mockPty);
170
- // Mock exec to succeed
171
- const { exec } = await import('child_process');
172
- const mockExec = vi.mocked(exec);
173
- mockExec.mockImplementation((cmd, options, callback) => {
174
- if (typeof options === 'function') {
175
- callback = options;
176
- }
177
- if (callback && typeof callback === 'function') {
178
- callback(null, 'Container started', '');
179
- }
180
- return {};
181
- });
182
179
  const devcontainerConfig = {
183
180
  upCommand: 'devcontainer up --workspace-folder .',
184
181
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -192,18 +189,9 @@ describe('SessionManager Effect-based Operations', () => {
192
189
  expect(session.devcontainerConfig).toEqual(devcontainerConfig);
193
190
  });
194
191
  it('should return Effect that fails with ProcessError when devcontainer up fails', async () => {
195
- // Mock exec to fail
196
- const { exec } = await import('child_process');
197
- const mockExec = vi.mocked(exec);
198
- mockExec.mockImplementation((cmd, options, callback) => {
199
- if (typeof options === 'function') {
200
- callback = options;
201
- }
202
- if (callback && typeof callback === 'function') {
203
- callback(new Error('Container failed to start'), '', '');
204
- }
205
- return {};
206
- });
192
+ // Mock spawn to return a process that exits with code 1
193
+ const { spawn: childSpawn } = await import('child_process');
194
+ vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(1));
207
195
  const devcontainerConfig = {
208
196
  upCommand: 'devcontainer up --workspace-folder .',
209
197
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -217,11 +205,14 @@ describe('SessionManager Effect-based Operations', () => {
217
205
  expect(result.left._tag).toBe('ProcessError');
218
206
  if (result.left._tag === 'ProcessError') {
219
207
  expect(result.left.command).toContain('devcontainer up');
220
- expect(result.left.message).toContain('Container failed');
208
+ expect(result.left.message).toContain('Command exited with code 1');
221
209
  }
222
210
  }
223
211
  });
224
212
  it('should return Effect that fails with ConfigError when preset not found', async () => {
213
+ // Reset childSpawn mock to succeed (devcontainer up should pass)
214
+ const { spawn: childSpawn } = await import('child_process');
215
+ vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(0));
225
216
  // Setup mocks - getPresetByIdEffect returns Left, getDefaultPreset returns undefined
226
217
  vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
227
218
  field: 'presetId',
@@ -229,18 +220,6 @@ describe('SessionManager Effect-based Operations', () => {
229
220
  receivedValue: 'invalid-preset',
230
221
  })));
231
222
  vi.mocked(configReader.getDefaultPreset).mockReturnValue(undefined);
232
- // Mock exec to succeed (devcontainer up)
233
- const { exec } = await import('child_process');
234
- const mockExec = vi.mocked(exec);
235
- mockExec.mockImplementation((cmd, options, callback) => {
236
- if (typeof options === 'function') {
237
- callback = options;
238
- }
239
- if (callback && typeof callback === 'function') {
240
- callback(null, 'Container started', '');
241
- }
242
- return {};
243
- });
244
223
  const devcontainerConfig = {
245
224
  upCommand: 'devcontainer up',
246
225
  execCommand: 'devcontainer exec',
@@ -1,8 +1,7 @@
1
1
  import { spawn } from './bunTerminal.js';
2
2
  import { EventEmitter } from 'events';
3
3
  import pkg from '@xterm/headless';
4
- import { exec } from 'child_process';
5
- import { promisify } from 'util';
4
+ import { spawn as childSpawn } from 'child_process';
6
5
  import { configReader } from './config/configReader.js';
7
6
  import { executeStatusHook } from '../utils/hookExecutor.js';
8
7
  import { createStateDetector } from './stateDetector/index.js';
@@ -17,20 +16,20 @@ import { getTerminalScreenContent } from '../utils/screenCapture.js';
17
16
  import { injectTeammateMode } from '../utils/commandArgs.js';
18
17
  import { preparePresetLaunch } from '../utils/presetPrompt.js';
19
18
  const { Terminal } = pkg;
20
- const execAsync = promisify(exec);
21
19
  const TERMINAL_CONTENT_MAX_LINES = 300;
22
20
  export class SessionManager extends EventEmitter {
23
21
  sessions;
24
22
  waitingWithBottomBorder = new Map();
25
23
  busyTimers = new Map();
26
24
  autoApprovalDisabledWorktrees = new Set();
27
- async spawn(command, args, worktreePath) {
25
+ async spawn(command, args, worktreePath, options = {}) {
28
26
  const spawnOptions = {
29
27
  name: 'xterm-256color',
30
28
  cols: process.stdout.columns || 80,
31
29
  rows: process.stdout.rows || 24,
32
30
  cwd: worktreePath,
33
31
  env: process.env,
32
+ ...(options.rawMode === undefined ? {} : { rawMode: options.rawMode }),
34
33
  };
35
34
  return spawn(command, args, spawnOptions);
36
35
  }
@@ -208,6 +207,8 @@ export class SessionManager extends EventEmitter {
208
207
  worktreePath,
209
208
  sessionNumber: maxNumber + 1,
210
209
  sessionName: undefined,
210
+ command: options.command ?? 'claude',
211
+ fallbackArgs: options.fallbackArgs,
211
212
  lastAccessedAt: Date.now(),
212
213
  process: ptyProcess,
213
214
  output: [],
@@ -258,6 +259,8 @@ export class SessionManager extends EventEmitter {
258
259
  const ptyProcess = await this.spawn(command, args, worktreePath);
259
260
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
260
261
  isPrimaryCommand: true,
262
+ command,
263
+ fallbackArgs: preset.fallbackArgs,
261
264
  presetName: preset.name,
262
265
  detectionStrategy: preset.detectionStrategy,
263
266
  });
@@ -316,9 +319,8 @@ export class SessionManager extends EventEmitter {
316
319
  /**
317
320
  * Sets up exit handler for the session process.
318
321
  * When the process exits with code 1 and it's the primary command,
319
- * it will attempt to spawn a fallback process.
320
- * If fallbackArgs are configured, they will be used.
321
- * If no fallbackArgs are configured, the command will be retried with no arguments.
322
+ * it will attempt a single retry using the configured command with fallback args.
323
+ * If fallbackArgs are not configured, it retries the configured command with no args.
322
324
  */
323
325
  setupExitHandler(session) {
324
326
  session.process.onExit(async (e) => {
@@ -326,26 +328,23 @@ export class SessionManager extends EventEmitter {
326
328
  if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
327
329
  try {
328
330
  let fallbackProcess;
331
+ const fallbackArgs = injectTeammateMode(session.command, session.fallbackArgs ?? [], session.detectionStrategy);
329
332
  // Check if we're in a devcontainer session
330
333
  if (session.devcontainerConfig) {
331
334
  // Parse the exec command to extract arguments
332
335
  const execParts = session.devcontainerConfig.execCommand.split(/\s+/);
333
336
  const devcontainerCmd = execParts[0] || 'devcontainer';
334
337
  const execArgs = execParts.slice(1);
335
- // Build fallback command for devcontainer
336
- const fallbackClaudeArgs = injectTeammateMode('claude', [], session.detectionStrategy);
337
338
  const fallbackFullArgs = [
338
339
  ...execArgs,
339
340
  '--',
340
- 'claude',
341
- ...fallbackClaudeArgs,
341
+ session.command,
342
+ ...fallbackArgs,
342
343
  ];
343
- fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath);
344
+ fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath, { rawMode: false });
344
345
  }
345
346
  else {
346
- // Regular fallback without devcontainer
347
- const fallbackArgs = injectTeammateMode('claude', [], session.detectionStrategy);
348
- fallbackProcess = await this.spawn('claude', fallbackArgs, session.worktreePath);
347
+ fallbackProcess = await this.spawn(session.command, fallbackArgs, session.worktreePath);
349
348
  }
350
349
  // Replace the process
351
350
  session.process = fallbackProcess;
@@ -635,12 +634,43 @@ export class SessionManager extends EventEmitter {
635
634
  * Create session with devcontainer integration using Effect-based error handling
636
635
  * @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
637
636
  */
638
- createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt) {
637
+ createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt, onLog) {
639
638
  return Effect.tryPromise({
640
639
  try: async () => {
641
- // Execute devcontainer up command first
640
+ // Execute devcontainer up command, streaming output in real-time
642
641
  try {
643
- await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
642
+ await new Promise((resolve, reject) => {
643
+ const parts = devcontainerConfig.upCommand.split(/\s+/);
644
+ const cmd = parts[0];
645
+ const args = parts.slice(1);
646
+ const proc = childSpawn(cmd, args, {
647
+ cwd: worktreePath,
648
+ stdio: ['ignore', 'pipe', 'pipe'],
649
+ shell: false,
650
+ });
651
+ const handleData = (data) => {
652
+ const text = data.toString();
653
+ for (const line of text.split('\n')) {
654
+ const trimmed = line.trimEnd();
655
+ if (trimmed) {
656
+ onLog?.(trimmed);
657
+ }
658
+ }
659
+ };
660
+ proc.stdout?.on('data', handleData);
661
+ proc.stderr?.on('data', handleData);
662
+ proc.on('error', err => {
663
+ reject(err);
664
+ });
665
+ proc.on('close', code => {
666
+ if (code === 0) {
667
+ resolve();
668
+ }
669
+ else {
670
+ reject(new Error(`Command exited with code ${code}`));
671
+ }
672
+ });
673
+ });
644
674
  }
645
675
  catch (error) {
646
676
  throw new ProcessError({
@@ -658,9 +688,11 @@ export class SessionManager extends EventEmitter {
658
688
  const presetArgs = launch.args;
659
689
  const fullArgs = [...execArgs, '--', preset.command, ...presetArgs];
660
690
  // Spawn the process within devcontainer
661
- const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
691
+ const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath, { rawMode: false });
662
692
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
663
693
  isPrimaryCommand: true,
694
+ command: preset.command,
695
+ fallbackArgs: preset.fallbackArgs,
664
696
  presetName: preset.name,
665
697
  detectionStrategy: preset.detectionStrategy,
666
698
  devcontainerConfig,
@@ -3,7 +3,16 @@ import { Effect, Either } from 'effect';
3
3
  import { ValidationError } from '../types/errors.js';
4
4
  import { spawn } from './bunTerminal.js';
5
5
  import { EventEmitter } from 'events';
6
- import { exec } from 'child_process';
6
+ import { spawn as childSpawn } from 'child_process';
7
+ // Helper to create a mock child process for child_process.spawn
8
+ function createMockChildProcess(exitCode = 0) {
9
+ const stdout = new EventEmitter();
10
+ const stderr = new EventEmitter();
11
+ const proc = Object.assign(new EventEmitter(), { stdout, stderr });
12
+ // Emit 'close' asynchronously so listeners can be attached
13
+ process.nextTick(() => proc.emit('close', exitCode));
14
+ return proc;
15
+ }
7
16
  // Mock bunTerminal
8
17
  vi.mock('./bunTerminal.js', () => ({
9
18
  spawn: vi.fn(function () {
@@ -12,12 +21,11 @@ vi.mock('./bunTerminal.js', () => ({
12
21
  }));
13
22
  // Mock child_process
14
23
  vi.mock('child_process', () => ({
15
- exec: vi.fn(function () {
16
- return null;
17
- }),
18
- execFile: vi.fn(function () {
19
- return null;
24
+ spawn: vi.fn(function () {
25
+ return createMockChildProcess(0);
20
26
  }),
27
+ exec: vi.fn(),
28
+ execFile: vi.fn(),
21
29
  }));
22
30
  // Mock configuration manager
23
31
  vi.mock('./config/configReader.js', () => ({
@@ -230,13 +238,14 @@ describe('SessionManager', () => {
230
238
  // Expect createSessionWithPresetEffect to throw the original error
231
239
  await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command not found');
232
240
  });
233
- it('should fallback to default command when main command exits with code 1', async () => {
241
+ it('should retry the configured command with fallback args when main command exits with code 1', async () => {
234
242
  // Setup mock preset with args
235
243
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
236
244
  id: '1',
237
245
  name: 'Main',
238
246
  command: 'claude',
239
247
  args: ['--invalid-flag'],
248
+ fallbackArgs: ['--safe-flag'],
240
249
  });
241
250
  // First spawn attempt - will exit with code 1
242
251
  const firstMockPty = new MockPty();
@@ -254,11 +263,13 @@ describe('SessionManager', () => {
254
263
  firstMockPty.emit('exit', { exitCode: 1 });
255
264
  // Wait for fallback to occur
256
265
  await new Promise(resolve => setTimeout(resolve, 50));
257
- // Verify fallback spawn was called (with no args since commandConfig was removed)
266
+ // Verify fallback spawn was called with the configured fallback args
258
267
  expect(spawn).toHaveBeenCalledTimes(2);
259
- expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
268
+ expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--safe-flag', '--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
260
269
  // Verify session process was replaced
261
270
  expect(session.process).toBe(secondMockPty);
271
+ expect(session.command).toBe('claude');
272
+ expect(session.fallbackArgs).toEqual(['--safe-flag']);
262
273
  expect(session.isPrimaryCommand).toBe(false);
263
274
  });
264
275
  it('should not use fallback if main command succeeds', async () => {
@@ -310,8 +321,37 @@ describe('SessionManager', () => {
310
321
  expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
311
322
  // Verify session process was replaced
312
323
  expect(session.process).toBe(secondMockPty);
324
+ expect(session.command).toBe('claude');
325
+ expect(session.fallbackArgs).toBeUndefined();
313
326
  expect(session.isPrimaryCommand).toBe(false);
314
327
  });
328
+ it('should cleanup and emit exit when fallback command also exits with code 1', async () => {
329
+ vi.mocked(configReader.getDefaultPreset).mockReturnValue({
330
+ id: '1',
331
+ name: 'Main',
332
+ command: 'opencode',
333
+ args: ['run', '--bad-flag'],
334
+ fallbackArgs: ['run', '--safe-mode'],
335
+ detectionStrategy: 'opencode',
336
+ });
337
+ const firstMockPty = new MockPty();
338
+ const secondMockPty = new MockPty();
339
+ let exitedSession = null;
340
+ vi.mocked(spawn)
341
+ .mockReturnValueOnce(firstMockPty)
342
+ .mockReturnValueOnce(secondMockPty);
343
+ sessionManager.on('sessionExit', (session) => {
344
+ exitedSession = session;
345
+ });
346
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
347
+ firstMockPty.emit('exit', { exitCode: 1 });
348
+ await new Promise(resolve => setTimeout(resolve, 50));
349
+ secondMockPty.emit('exit', { exitCode: 1 });
350
+ await new Promise(resolve => setTimeout(resolve, 50));
351
+ expect(spawn).toHaveBeenNthCalledWith(2, 'opencode', ['run', '--safe-mode'], expect.objectContaining({ cwd: '/test/worktree' }));
352
+ expect(exitedSession).toBe(session);
353
+ expect(sessionManager.getSessionsForWorktree('/test/worktree')).toHaveLength(0);
354
+ });
315
355
  it('should handle custom command configuration', async () => {
316
356
  // Setup mock preset with custom command
317
357
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -388,24 +428,8 @@ describe('SessionManager', () => {
388
428
  });
389
429
  describe('createSessionWithDevcontainerEffect', () => {
390
430
  beforeEach(() => {
391
- // Reset shouldFail flag
392
- const mockExec = vi.mocked(exec);
393
- mockExec.shouldFail = false;
394
- // Setup exec mock to work with promisify
395
- mockExec.mockImplementation(((...args) => {
396
- const [command, , callback] = args;
397
- if (callback) {
398
- // Handle callback style
399
- if (command.includes('devcontainer up')) {
400
- if (mockExec.shouldFail) {
401
- callback(new Error('Container startup failed'));
402
- }
403
- else {
404
- callback(null, '', '');
405
- }
406
- }
407
- }
408
- }));
431
+ // Setup childSpawn mock to return a successful mock child process
432
+ vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(0));
409
433
  });
410
434
  it('should execute devcontainer up command before creating session', async () => {
411
435
  // Setup mock preset
@@ -434,7 +458,7 @@ describe('SessionManager', () => {
434
458
  '--resume',
435
459
  '--teammate-mode',
436
460
  'in-process',
437
- ], expect.objectContaining({ cwd: '/test/worktree' }));
461
+ ], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
438
462
  });
439
463
  it('should use specific preset when ID provided', async () => {
440
464
  // Setup mock preset
@@ -465,15 +489,14 @@ describe('SessionManager', () => {
465
489
  ], expect.any(Object));
466
490
  });
467
491
  it('should throw error when devcontainer up fails', async () => {
468
- // Setup exec to fail
469
- const mockExec = vi.mocked(exec);
470
- mockExec.shouldFail = true;
492
+ // Setup childSpawn to return a process that exits with code 1
493
+ vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(1));
471
494
  // Create session with devcontainer
472
495
  const devcontainerConfig = {
473
496
  upCommand: 'devcontainer up',
474
497
  execCommand: 'devcontainer exec',
475
498
  };
476
- await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer: Container startup failed');
499
+ await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer: Command exited with code 1');
477
500
  });
478
501
  it('should create a new session each time for multi-session support', async () => {
479
502
  // Setup mock preset
@@ -540,17 +563,6 @@ describe('SessionManager', () => {
540
563
  });
541
564
  // Setup spawn mock
542
565
  vi.mocked(spawn).mockReturnValue(mockPty);
543
- const mockExec = vi.mocked(exec);
544
- mockExec.mockImplementation((cmd, options, callback) => {
545
- if (typeof options === 'function') {
546
- callback = options;
547
- options = undefined;
548
- }
549
- if (callback && typeof callback === 'function') {
550
- callback(null, 'Container started', '');
551
- }
552
- return {};
553
- });
554
566
  await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree2', {
555
567
  upCommand: 'devcontainer up --workspace-folder .',
556
568
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -566,20 +578,10 @@ describe('SessionManager', () => {
566
578
  'in-process',
567
579
  ], expect.objectContaining({
568
580
  cwd: '/test/worktree2',
581
+ rawMode: false,
569
582
  }));
570
583
  });
571
584
  it('should use preset with devcontainer', async () => {
572
- const mockExec = vi.mocked(exec);
573
- mockExec.mockImplementation((cmd, options, callback) => {
574
- if (typeof options === 'function') {
575
- callback = options;
576
- options = undefined;
577
- }
578
- if (callback && typeof callback === 'function') {
579
- callback(null, 'Container started', '');
580
- }
581
- return {};
582
- });
583
585
  await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
584
586
  upCommand: 'devcontainer up --workspace-folder .',
585
587
  execCommand: 'devcontainer exec --workspace-folder .',
@@ -594,17 +596,6 @@ describe('SessionManager', () => {
594
596
  });
595
597
  });
596
598
  it('should parse exec command and append preset command', async () => {
597
- const mockExec = vi.mocked(exec);
598
- mockExec.mockImplementation((cmd, options, callback) => {
599
- if (typeof options === 'function') {
600
- callback = options;
601
- options = undefined;
602
- }
603
- if (callback && typeof callback === 'function') {
604
- callback(null, 'Container started', '');
605
- }
606
- return {};
607
- });
608
599
  const config = {
609
600
  upCommand: 'devcontainer up --workspace-folder /path/to/project',
610
601
  execCommand: 'devcontainer exec --workspace-folder /path/to/project --user vscode',
@@ -623,17 +614,6 @@ describe('SessionManager', () => {
623
614
  ], expect.any(Object));
624
615
  });
625
616
  it('should handle preset with args in devcontainer', async () => {
626
- const mockExec = vi.mocked(exec);
627
- mockExec.mockImplementation((cmd, options, callback) => {
628
- if (typeof options === 'function') {
629
- callback = options;
630
- options = undefined;
631
- }
632
- if (callback && typeof callback === 'function') {
633
- callback(null, 'Container started', '');
634
- }
635
- return {};
636
- });
637
617
  vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.right({
638
618
  id: 'claude-with-args',
639
619
  name: 'Claude with Args',
@@ -657,17 +637,6 @@ describe('SessionManager', () => {
657
637
  ], expect.any(Object));
658
638
  });
659
639
  it('should use empty args as fallback in devcontainer when no fallback args specified', async () => {
660
- const mockExec = vi.mocked(exec);
661
- mockExec.mockImplementation((cmd, options, callback) => {
662
- if (typeof options === 'function') {
663
- callback = options;
664
- options = undefined;
665
- }
666
- if (callback && typeof callback === 'function') {
667
- callback(null, 'Container started', '');
668
- }
669
- return {};
670
- });
671
640
  // Setup preset without fallback args
672
641
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
673
642
  id: '1',
@@ -713,29 +682,19 @@ describe('SessionManager', () => {
713
682
  'claude',
714
683
  '--teammate-mode',
715
684
  'in-process',
716
- ], expect.objectContaining({ cwd: '/test/worktree' }));
685
+ ], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
717
686
  // Verify session process was replaced
718
687
  expect(session.process).toBe(secondMockPty);
719
688
  expect(session.isPrimaryCommand).toBe(false);
720
689
  });
721
- it('should fallback to default command in devcontainer when primary command exits with code 1', async () => {
722
- const mockExec = vi.mocked(exec);
723
- mockExec.mockImplementation((cmd, options, callback) => {
724
- if (typeof options === 'function') {
725
- callback = options;
726
- options = undefined;
727
- }
728
- if (callback && typeof callback === 'function') {
729
- callback(null, 'Container started', '');
730
- }
731
- return {};
732
- });
690
+ it('should retry the configured command with fallback args in devcontainer when primary command exits with code 1', async () => {
733
691
  // Setup preset with args
734
692
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
735
693
  id: '1',
736
694
  name: 'Main',
737
695
  command: 'claude',
738
696
  args: ['--bad-flag'],
697
+ fallbackArgs: ['--safe-flag'],
739
698
  });
740
699
  // First spawn attempt - will exit with code 1
741
700
  const firstMockPty = new MockPty();
@@ -764,7 +723,7 @@ describe('SessionManager', () => {
764
723
  firstMockPty.emit('exit', { exitCode: 1 });
765
724
  // Wait for fallback to occur
766
725
  await new Promise(resolve => setTimeout(resolve, 50));
767
- // Verify fallback spawn was called with teammate-mode args
726
+ // Verify fallback spawn was called with the configured fallback args
768
727
  expect(spawn).toHaveBeenCalledTimes(2);
769
728
  expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', [
770
729
  'exec',
@@ -772,9 +731,10 @@ describe('SessionManager', () => {
772
731
  '.',
773
732
  '--',
774
733
  'claude',
734
+ '--safe-flag',
775
735
  '--teammate-mode',
776
736
  'in-process',
777
- ], expect.objectContaining({ cwd: '/test/worktree' }));
737
+ ], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
778
738
  // Verify session process was replaced
779
739
  expect(session.process).toBe(secondMockPty);
780
740
  expect(session.isPrimaryCommand).toBe(false);
@@ -1,4 +1,8 @@
1
1
  import { BaseStateDetector } from './base.js';
2
+ // Spinner symbols used by Cursor during active processing
3
+ const CURSOR_SPINNER_CHARS = '⬡⬢';
4
+ // Like Claude's spinner activity: "<symbol> <word>ing…"; Cursor often uses ASCII dots (.. or …)
5
+ const SPINNER_ACTIVITY_PATTERN = new RegExp(`^\\s*[${CURSOR_SPINNER_CHARS}] \\S+ing(?:.*\u2026|.*\\.{2,})`, 'm');
2
6
  export class CursorStateDetector extends BaseStateDetector {
3
7
  detectState(terminal, _currentState) {
4
8
  const content = this.getTerminalContent(terminal, 30);
@@ -9,13 +13,21 @@ export class CursorStateDetector extends BaseStateDetector {
9
13
  /auto .* \(shift\+tab\)/.test(lowerContent) ||
10
14
  /allow .+ \(y\)/.test(lowerContent) ||
11
15
  /run .+ \(y\)/.test(lowerContent) ||
12
- lowerContent.includes('skip (esc or n)')) {
16
+ lowerContent.includes('skip (esc or n)') ||
17
+ lowerContent.includes('write to this file?') ||
18
+ lowerContent.includes('reject & propose changes') ||
19
+ (lowerContent.includes('add write(') &&
20
+ lowerContent.includes('allowlist'))) {
13
21
  return 'waiting_input';
14
22
  }
15
23
  // Check for busy state - Priority 2
16
24
  if (lowerContent.includes('ctrl+c to stop')) {
17
25
  return 'busy';
18
26
  }
27
+ // Spinner activity (e.g. "⬡ Grepping..", "⬢ Reading…") — case-sensitive on original buffer
28
+ if (SPINNER_ACTIVITY_PATTERN.test(content)) {
29
+ return 'busy';
30
+ }
19
31
  // Otherwise idle - Priority 3
20
32
  return 'idle';
21
33
  }
@@ -114,6 +114,21 @@ describe('CursorStateDetector', () => {
114
114
  // Assert
115
115
  expect(state).toBe('waiting_input');
116
116
  });
117
+ it('should detect waiting_input state for Write to this file? prompt', () => {
118
+ // Arrange
119
+ terminal = createMockTerminal([
120
+ ' │ Write to this file? │',
121
+ ' │ in /Users/kbwo/go/projects/github.com/kbwo/ccmanager--feature-takt/src/services/stateDetector/takt.ts │',
122
+ ' │ → Proceed (y) │',
123
+ ' │ Reject & propose changes (esc or n or p) │',
124
+ ' │ Add Write(/Users/.../takt.ts) to allowlist? (tab) │',
125
+ ' │ Run Everything (shift+tab) │',
126
+ ]);
127
+ // Act
128
+ const state = detector.detectState(terminal, 'idle');
129
+ // Assert
130
+ expect(state).toBe('waiting_input');
131
+ });
117
132
  it('should detect busy state for ctrl+c to stop pattern', () => {
118
133
  // Arrange
119
134
  terminal = createMockTerminal([
@@ -138,6 +153,18 @@ describe('CursorStateDetector', () => {
138
153
  // Assert
139
154
  expect(state).toBe('busy');
140
155
  });
156
+ it('should detect busy state for spinner activity (⬡ …ing..)', () => {
157
+ terminal = createMockTerminal([' ⬡ Grepping..', 'Some footer']);
158
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
159
+ });
160
+ it('should detect busy state for spinner activity (⬢ …ing...)', () => {
161
+ terminal = createMockTerminal([' ⬢ Reading...']);
162
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
163
+ });
164
+ it('should detect busy state for spinner activity with Unicode ellipsis', () => {
165
+ terminal = createMockTerminal(['⬡ Searching\u2026']);
166
+ expect(detector.detectState(terminal, 'idle')).toBe('busy');
167
+ });
141
168
  it('should detect idle state when no patterns match', () => {
142
169
  // Arrange
143
170
  terminal = createMockTerminal(['Normal output', 'Some message', 'Ready']);
@@ -20,6 +20,8 @@ export interface Session {
20
20
  worktreePath: string;
21
21
  sessionNumber: number;
22
22
  sessionName?: string;
23
+ command: string;
24
+ fallbackArgs?: string[];
23
25
  lastAccessedAt: number;
24
26
  process: IPty;
25
27
  output: string[];
@@ -377,6 +377,8 @@ describe('hookExecutor Integration Tests', () => {
377
377
  id: 'test-session-123',
378
378
  worktreePath: tmpDir, // Use tmpDir as the worktree path
379
379
  sessionNumber: 1,
380
+ command: 'claude',
381
+ fallbackArgs: undefined,
380
382
  lastAccessedAt: Date.now(),
381
383
  process: {},
382
384
  terminal: {},
@@ -434,6 +436,8 @@ describe('hookExecutor Integration Tests', () => {
434
436
  id: 'test-session-456',
435
437
  worktreePath: tmpDir, // Use tmpDir as the worktree path
436
438
  sessionNumber: 1,
439
+ command: 'claude',
440
+ fallbackArgs: undefined,
437
441
  lastAccessedAt: Date.now(),
438
442
  process: {},
439
443
  terminal: {},
@@ -489,6 +493,8 @@ describe('hookExecutor Integration Tests', () => {
489
493
  id: 'test-session-789',
490
494
  worktreePath: tmpDir, // Use tmpDir as the worktree path
491
495
  sessionNumber: 1,
496
+ command: 'claude',
497
+ fallbackArgs: undefined,
492
498
  lastAccessedAt: Date.now(),
493
499
  process: {},
494
500
  terminal: {},
@@ -546,6 +552,8 @@ describe('hookExecutor Integration Tests', () => {
546
552
  id: 'test-session-failure',
547
553
  worktreePath: tmpDir,
548
554
  sessionNumber: 1,
555
+ command: 'claude',
556
+ fallbackArgs: undefined,
549
557
  lastAccessedAt: Date.now(),
550
558
  process: {},
551
559
  terminal: {},
@@ -124,6 +124,8 @@ describe('prepareSessionItems', () => {
124
124
  id: 'test-session',
125
125
  worktreePath: '/path/to/worktree',
126
126
  sessionNumber: 1,
127
+ command: 'claude',
128
+ fallbackArgs: undefined,
127
129
  lastAccessedAt: Date.now(),
128
130
  process: {},
129
131
  output: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.0.2",
3
+ "version": "4.0.4",
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.0.2",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.0.2",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.0.2",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.0.2",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.0.2"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.0.4",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.0.4",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.0.4",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.0.4",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.0.4"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",