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
|
-
//
|
|
103
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
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
|
|
168
|
-
let callCount = 0;
|
|
167
|
+
// Mock spawn to fail
|
|
169
168
|
vi.mocked(spawn).mockImplementation(() => {
|
|
170
|
-
|
|
171
|
-
if (callCount === 1) {
|
|
172
|
-
throw new Error('Command failed');
|
|
173
|
-
}
|
|
174
|
-
return mockPty;
|
|
169
|
+
throw new Error('Command failed');
|
|
175
170
|
});
|
|
176
|
-
//
|
|
177
|
-
await sessionManager.createSessionWithPreset('/test/worktree');
|
|
178
|
-
// Verify
|
|
179
|
-
expect(spawn).toHaveBeenCalledTimes(
|
|
180
|
-
expect(spawn).
|
|
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
|
});
|