ccmanager 1.3.0 → 1.3.1

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.
@@ -12,6 +12,13 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
12
12
  private createSessionInternal;
13
13
  createSessionWithPreset(worktreePath: string, presetId?: string): Promise<Session>;
14
14
  private setupDataHandler;
15
+ /**
16
+ * Sets up exit handler for the session process.
17
+ * When the process exits with code 1 and it's the primary command,
18
+ * it will attempt to spawn a fallback process.
19
+ * If fallbackArgs are configured, they will be used.
20
+ * If no fallbackArgs are configured, the command will be retried with no arguments.
21
+ */
15
22
  private setupExitHandler;
16
23
  private setupBackgroundHandler;
17
24
  private cleanupSession;
@@ -99,31 +99,10 @@ export class SessionManager extends EventEmitter {
99
99
  args: preset.args,
100
100
  fallbackArgs: preset.fallbackArgs,
101
101
  };
102
- // Try to spawn the process
103
- let ptyProcess;
104
- let isPrimaryCommand = true;
105
- try {
106
- ptyProcess = await this.spawn(command, args, worktreePath);
107
- }
108
- catch (error) {
109
- // If primary command fails and we have fallback args, try them
110
- if (preset.fallbackArgs) {
111
- try {
112
- ptyProcess = await this.spawn(command, preset.fallbackArgs, worktreePath);
113
- isPrimaryCommand = false;
114
- }
115
- catch (_fallbackError) {
116
- // Both attempts failed, throw the original error
117
- throw error;
118
- }
119
- }
120
- else {
121
- // No fallback args, throw the error
122
- throw error;
123
- }
124
- }
102
+ // Spawn the process - fallback will be handled by setupExitHandler
103
+ const ptyProcess = await this.spawn(command, args, worktreePath);
125
104
  return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
126
- isPrimaryCommand,
105
+ isPrimaryCommand: true,
127
106
  detectionStrategy: preset.detectionStrategy,
128
107
  });
129
108
  }
@@ -151,13 +130,40 @@ export class SessionManager extends EventEmitter {
151
130
  }
152
131
  });
153
132
  }
133
+ /**
134
+ * Sets up exit handler for the session process.
135
+ * When the process exits with code 1 and it's the primary command,
136
+ * it will attempt to spawn a fallback process.
137
+ * If fallbackArgs are configured, they will be used.
138
+ * If no fallbackArgs are configured, the command will be retried with no arguments.
139
+ */
154
140
  setupExitHandler(session) {
155
141
  session.process.onExit(async (e) => {
156
142
  // Check if we should attempt fallback
157
143
  if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
158
144
  try {
159
- // Spawn fallback process
160
- const fallbackProcess = await this.spawn(session.commandConfig?.command || 'claude', session.commandConfig?.fallbackArgs || [], session.worktreePath);
145
+ let fallbackProcess;
146
+ // Use fallback args if available, otherwise use empty args
147
+ const fallbackArgs = session.commandConfig?.fallbackArgs || [];
148
+ // Check if we're in a devcontainer session
149
+ if (session.devcontainerConfig) {
150
+ // Parse the exec command to extract arguments
151
+ const execParts = session.devcontainerConfig.execCommand.split(/\s+/);
152
+ const devcontainerCmd = execParts[0] || 'devcontainer';
153
+ const execArgs = execParts.slice(1);
154
+ // Build fallback command for devcontainer
155
+ const fallbackFullArgs = [
156
+ ...execArgs,
157
+ '--',
158
+ session.commandConfig?.command || 'claude',
159
+ ...fallbackArgs,
160
+ ];
161
+ fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath);
162
+ }
163
+ else {
164
+ // Regular fallback without devcontainer
165
+ fallbackProcess = await this.spawn(session.commandConfig?.command || 'claude', fallbackArgs, session.worktreePath);
166
+ }
161
167
  // Replace the process
162
168
  session.process = fallbackProcess;
163
169
  session.isPrimaryCommand = false;
@@ -304,7 +310,7 @@ export class SessionManager extends EventEmitter {
304
310
  preset.command,
305
311
  ...(preset.args || []),
306
312
  ];
307
- // Spawn the process within devcontainer
313
+ // Spawn the process within devcontainer - fallback will be handled by setupExitHandler
308
314
  const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
309
315
  const commandConfig = {
310
316
  command: preset.command,
@@ -155,7 +155,7 @@ describe('SessionManager', () => {
155
155
  expect(configurationManager.getDefaultPreset).toHaveBeenCalled();
156
156
  expect(spawn).toHaveBeenCalledWith('claude', [], expect.any(Object));
157
157
  });
158
- it('should try fallback args with preset if main command fails', async () => {
158
+ it('should throw error when spawn fails with preset', async () => {
159
159
  // Setup mock preset with fallback
160
160
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
161
161
  id: '1',
@@ -164,21 +164,15 @@ describe('SessionManager', () => {
164
164
  args: ['--bad-flag'],
165
165
  fallbackArgs: ['--good-flag'],
166
166
  });
167
- // Mock spawn to fail first, succeed second
168
- let callCount = 0;
167
+ // Mock spawn to fail
169
168
  vi.mocked(spawn).mockImplementation(() => {
170
- callCount++;
171
- if (callCount === 1) {
172
- throw new Error('Command failed');
173
- }
174
- return mockPty;
169
+ throw new Error('Command failed');
175
170
  });
176
- // Create session
177
- await sessionManager.createSessionWithPreset('/test/worktree');
178
- // Verify both attempts were made
179
- expect(spawn).toHaveBeenCalledTimes(2);
180
- expect(spawn).toHaveBeenNthCalledWith(1, 'claude', ['--bad-flag'], expect.any(Object));
181
- expect(spawn).toHaveBeenNthCalledWith(2, 'claude', ['--good-flag'], expect.any(Object));
171
+ // Expect createSessionWithPreset to throw
172
+ await expect(sessionManager.createSessionWithPreset('/test/worktree')).rejects.toThrow('Command failed');
173
+ // Verify only one spawn attempt was made
174
+ expect(spawn).toHaveBeenCalledTimes(1);
175
+ expect(spawn).toHaveBeenCalledWith('claude', ['--bad-flag'], expect.any(Object));
182
176
  });
183
177
  it('should return existing session if already created', async () => {
184
178
  // Setup mock preset
@@ -264,6 +258,39 @@ describe('SessionManager', () => {
264
258
  expect(spawn).toHaveBeenCalledTimes(1);
265
259
  expect(spawn).toHaveBeenCalledWith('claude', ['--resume'], expect.objectContaining({ cwd: '/test/worktree' }));
266
260
  });
261
+ it('should use empty args as fallback when no fallback args specified', async () => {
262
+ // Setup mock preset without fallback args
263
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
264
+ id: '1',
265
+ name: 'Main',
266
+ command: 'claude',
267
+ args: ['--invalid-flag'],
268
+ // No fallbackArgs
269
+ });
270
+ // First spawn attempt - will exit with code 1
271
+ const firstMockPty = new MockPty();
272
+ // Second spawn attempt - succeeds
273
+ const secondMockPty = new MockPty();
274
+ vi.mocked(spawn)
275
+ .mockReturnValueOnce(firstMockPty)
276
+ .mockReturnValueOnce(secondMockPty);
277
+ // Create session
278
+ const session = await sessionManager.createSessionWithPreset('/test/worktree');
279
+ // Verify initial spawn
280
+ expect(spawn).toHaveBeenCalledTimes(1);
281
+ expect(spawn).toHaveBeenCalledWith('claude', ['--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
282
+ // Simulate exit with code 1 on first attempt
283
+ firstMockPty.emit('exit', { exitCode: 1 });
284
+ // Wait for fallback to occur
285
+ await new Promise(resolve => setTimeout(resolve, 50));
286
+ // Verify fallback spawn was called with empty args
287
+ expect(spawn).toHaveBeenCalledTimes(2);
288
+ expect(spawn).toHaveBeenNthCalledWith(2, 'claude', [], // Empty args
289
+ expect.objectContaining({ cwd: '/test/worktree' }));
290
+ // Verify session process was replaced
291
+ expect(session.process).toBe(secondMockPty);
292
+ expect(session.isPrimaryCommand).toBe(false);
293
+ });
267
294
  it('should handle custom command configuration', async () => {
268
295
  // Setup mock preset with custom command
269
296
  vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
@@ -575,5 +602,96 @@ describe('SessionManager', () => {
575
602
  'claude-3-opus',
576
603
  ], expect.any(Object));
577
604
  });
605
+ it('should use empty args as fallback in devcontainer when no fallback args specified', async () => {
606
+ const mockExec = vi.mocked(exec);
607
+ mockExec.mockImplementation((cmd, options, callback) => {
608
+ if (typeof options === 'function') {
609
+ callback = options;
610
+ options = undefined;
611
+ }
612
+ if (callback && typeof callback === 'function') {
613
+ callback(null, 'Container started', '');
614
+ }
615
+ return {};
616
+ });
617
+ // Setup preset without fallback args
618
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
619
+ id: '1',
620
+ name: 'Main',
621
+ command: 'claude',
622
+ args: ['--invalid-flag'],
623
+ // No fallbackArgs
624
+ });
625
+ // First spawn attempt - will exit with code 1
626
+ const firstMockPty = new MockPty();
627
+ // Second spawn attempt - succeeds
628
+ const secondMockPty = new MockPty();
629
+ vi.mocked(spawn)
630
+ .mockReturnValueOnce(firstMockPty)
631
+ .mockReturnValueOnce(secondMockPty);
632
+ const session = await sessionManager.createSessionWithDevcontainer('/test/worktree', {
633
+ upCommand: 'devcontainer up --workspace-folder .',
634
+ execCommand: 'devcontainer exec --workspace-folder .',
635
+ });
636
+ // Verify initial spawn
637
+ expect(spawn).toHaveBeenCalledTimes(1);
638
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--invalid-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
639
+ // Simulate exit with code 1 on first attempt
640
+ firstMockPty.emit('exit', { exitCode: 1 });
641
+ // Wait for fallback to occur
642
+ await new Promise(resolve => setTimeout(resolve, 50));
643
+ // Verify fallback spawn was called with empty args
644
+ expect(spawn).toHaveBeenCalledTimes(2);
645
+ expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], // No args after claude
646
+ expect.objectContaining({ cwd: '/test/worktree' }));
647
+ // Verify session process was replaced
648
+ expect(session.process).toBe(secondMockPty);
649
+ expect(session.isPrimaryCommand).toBe(false);
650
+ });
651
+ it('should use fallback args in devcontainer when primary command exits with code 1', async () => {
652
+ const mockExec = vi.mocked(exec);
653
+ mockExec.mockImplementation((cmd, options, callback) => {
654
+ if (typeof options === 'function') {
655
+ callback = options;
656
+ options = undefined;
657
+ }
658
+ if (callback && typeof callback === 'function') {
659
+ callback(null, 'Container started', '');
660
+ }
661
+ return {};
662
+ });
663
+ // Setup preset with fallback
664
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
665
+ id: '1',
666
+ name: 'Main',
667
+ command: 'claude',
668
+ args: ['--bad-flag'],
669
+ fallbackArgs: ['--good-flag'],
670
+ });
671
+ // First spawn attempt - will exit with code 1
672
+ const firstMockPty = new MockPty();
673
+ // Second spawn attempt - succeeds
674
+ const secondMockPty = new MockPty();
675
+ vi.mocked(spawn)
676
+ .mockReturnValueOnce(firstMockPty)
677
+ .mockReturnValueOnce(secondMockPty);
678
+ const session = await sessionManager.createSessionWithDevcontainer('/test/worktree', {
679
+ upCommand: 'devcontainer up --workspace-folder .',
680
+ execCommand: 'devcontainer exec --workspace-folder .',
681
+ });
682
+ // Verify initial spawn
683
+ expect(spawn).toHaveBeenCalledTimes(1);
684
+ expect(spawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--bad-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
685
+ // Simulate exit with code 1 on first attempt
686
+ firstMockPty.emit('exit', { exitCode: 1 });
687
+ // Wait for fallback to occur
688
+ await new Promise(resolve => setTimeout(resolve, 50));
689
+ // Verify fallback spawn was called
690
+ expect(spawn).toHaveBeenCalledTimes(2);
691
+ expect(spawn).toHaveBeenNthCalledWith(2, 'devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude', '--good-flag'], expect.objectContaining({ cwd: '/test/worktree' }));
692
+ // Verify session process was replaced
693
+ expect(session.process).toBe(secondMockPty);
694
+ expect(session.isPrimaryCommand).toBe(false);
695
+ });
578
696
  });
579
697
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",