ccmanager 4.0.3 → 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.
@@ -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;
@@ -207,6 +207,8 @@ export class SessionManager extends EventEmitter {
207
207
  worktreePath,
208
208
  sessionNumber: maxNumber + 1,
209
209
  sessionName: undefined,
210
+ command: options.command ?? 'claude',
211
+ fallbackArgs: options.fallbackArgs,
210
212
  lastAccessedAt: Date.now(),
211
213
  process: ptyProcess,
212
214
  output: [],
@@ -257,6 +259,8 @@ export class SessionManager extends EventEmitter {
257
259
  const ptyProcess = await this.spawn(command, args, worktreePath);
258
260
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
259
261
  isPrimaryCommand: true,
262
+ command,
263
+ fallbackArgs: preset.fallbackArgs,
260
264
  presetName: preset.name,
261
265
  detectionStrategy: preset.detectionStrategy,
262
266
  });
@@ -315,9 +319,8 @@ export class SessionManager extends EventEmitter {
315
319
  /**
316
320
  * Sets up exit handler for the session process.
317
321
  * When the process exits with code 1 and it's the primary command,
318
- * it will attempt to spawn a fallback process.
319
- * If fallbackArgs are configured, they will be used.
320
- * 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.
321
324
  */
322
325
  setupExitHandler(session) {
323
326
  session.process.onExit(async (e) => {
@@ -325,26 +328,23 @@ export class SessionManager extends EventEmitter {
325
328
  if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
326
329
  try {
327
330
  let fallbackProcess;
331
+ const fallbackArgs = injectTeammateMode(session.command, session.fallbackArgs ?? [], session.detectionStrategy);
328
332
  // Check if we're in a devcontainer session
329
333
  if (session.devcontainerConfig) {
330
334
  // Parse the exec command to extract arguments
331
335
  const execParts = session.devcontainerConfig.execCommand.split(/\s+/);
332
336
  const devcontainerCmd = execParts[0] || 'devcontainer';
333
337
  const execArgs = execParts.slice(1);
334
- // Build fallback command for devcontainer
335
- const fallbackClaudeArgs = injectTeammateMode('claude', [], session.detectionStrategy);
336
338
  const fallbackFullArgs = [
337
339
  ...execArgs,
338
340
  '--',
339
- 'claude',
340
- ...fallbackClaudeArgs,
341
+ session.command,
342
+ ...fallbackArgs,
341
343
  ];
342
344
  fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath, { rawMode: false });
343
345
  }
344
346
  else {
345
- // Regular fallback without devcontainer
346
- const fallbackArgs = injectTeammateMode('claude', [], session.detectionStrategy);
347
- fallbackProcess = await this.spawn('claude', fallbackArgs, session.worktreePath);
347
+ fallbackProcess = await this.spawn(session.command, fallbackArgs, session.worktreePath);
348
348
  }
349
349
  // Replace the process
350
350
  session.process = fallbackProcess;
@@ -691,6 +691,8 @@ export class SessionManager extends EventEmitter {
691
691
  const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath, { rawMode: false });
692
692
  const session = await this.createSessionInternal(worktreePath, ptyProcess, {
693
693
  isPrimaryCommand: true,
694
+ command: preset.command,
695
+ fallbackArgs: preset.fallbackArgs,
694
696
  presetName: preset.name,
695
697
  detectionStrategy: preset.detectionStrategy,
696
698
  devcontainerConfig,
@@ -238,13 +238,14 @@ describe('SessionManager', () => {
238
238
  // Expect createSessionWithPresetEffect to throw the original error
239
239
  await expect(Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'))).rejects.toThrow('Command not found');
240
240
  });
241
- 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 () => {
242
242
  // Setup mock preset with args
243
243
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
244
244
  id: '1',
245
245
  name: 'Main',
246
246
  command: 'claude',
247
247
  args: ['--invalid-flag'],
248
+ fallbackArgs: ['--safe-flag'],
248
249
  });
249
250
  // First spawn attempt - will exit with code 1
250
251
  const firstMockPty = new MockPty();
@@ -262,11 +263,13 @@ describe('SessionManager', () => {
262
263
  firstMockPty.emit('exit', { exitCode: 1 });
263
264
  // Wait for fallback to occur
264
265
  await new Promise(resolve => setTimeout(resolve, 50));
265
- // Verify fallback spawn was called (with no args since commandConfig was removed)
266
+ // Verify fallback spawn was called with the configured fallback args
266
267
  expect(spawn).toHaveBeenCalledTimes(2);
267
- 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' }));
268
269
  // Verify session process was replaced
269
270
  expect(session.process).toBe(secondMockPty);
271
+ expect(session.command).toBe('claude');
272
+ expect(session.fallbackArgs).toEqual(['--safe-flag']);
270
273
  expect(session.isPrimaryCommand).toBe(false);
271
274
  });
272
275
  it('should not use fallback if main command succeeds', async () => {
@@ -318,8 +321,37 @@ describe('SessionManager', () => {
318
321
  expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--teammate-mode', 'in-process'], expect.objectContaining({ cwd: '/test/worktree' }));
319
322
  // Verify session process was replaced
320
323
  expect(session.process).toBe(secondMockPty);
324
+ expect(session.command).toBe('claude');
325
+ expect(session.fallbackArgs).toBeUndefined();
321
326
  expect(session.isPrimaryCommand).toBe(false);
322
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
+ });
323
355
  it('should handle custom command configuration', async () => {
324
356
  // Setup mock preset with custom command
325
357
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
@@ -655,13 +687,14 @@ describe('SessionManager', () => {
655
687
  expect(session.process).toBe(secondMockPty);
656
688
  expect(session.isPrimaryCommand).toBe(false);
657
689
  });
658
- it('should fallback to default command in devcontainer when primary command exits with code 1', async () => {
690
+ it('should retry the configured command with fallback args in devcontainer when primary command exits with code 1', async () => {
659
691
  // Setup preset with args
660
692
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
661
693
  id: '1',
662
694
  name: 'Main',
663
695
  command: 'claude',
664
696
  args: ['--bad-flag'],
697
+ fallbackArgs: ['--safe-flag'],
665
698
  });
666
699
  // First spawn attempt - will exit with code 1
667
700
  const firstMockPty = new MockPty();
@@ -690,7 +723,7 @@ describe('SessionManager', () => {
690
723
  firstMockPty.emit('exit', { exitCode: 1 });
691
724
  // Wait for fallback to occur
692
725
  await new Promise(resolve => setTimeout(resolve, 50));
693
- // Verify fallback spawn was called with teammate-mode args
726
+ // Verify fallback spawn was called with the configured fallback args
694
727
  expect(spawn).toHaveBeenCalledTimes(2);
695
728
  expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', [
696
729
  'exec',
@@ -698,6 +731,7 @@ describe('SessionManager', () => {
698
731
  '.',
699
732
  '--',
700
733
  'claude',
734
+ '--safe-flag',
701
735
  '--teammate-mode',
702
736
  'in-process',
703
737
  ], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
@@ -13,7 +13,11 @@ export class CursorStateDetector extends BaseStateDetector {
13
13
  /auto .* \(shift\+tab\)/.test(lowerContent) ||
14
14
  /allow .+ \(y\)/.test(lowerContent) ||
15
15
  /run .+ \(y\)/.test(lowerContent) ||
16
- 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'))) {
17
21
  return 'waiting_input';
18
22
  }
19
23
  // Check for busy state - Priority 2
@@ -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([
@@ -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.3",
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.3",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.0.3",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.0.3",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.0.3",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.0.3"
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",