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.
- package/dist/services/sessionManager.d.ts +2 -3
- package/dist/services/sessionManager.js +12 -10
- package/dist/services/sessionManager.test.js +39 -5
- package/dist/services/stateDetector/cursor.js +5 -1
- package/dist/services/stateDetector/cursor.test.js +15 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/utils/hookExecutor.test.js +8 -0
- package/dist/utils/worktreeUtils.test.js +2 -0
- package/package.json +6 -6
|
@@ -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
|
|
63
|
-
* If fallbackArgs are configured,
|
|
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
|
|
319
|
-
* If fallbackArgs are configured,
|
|
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
|
-
|
|
340
|
-
...
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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([
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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: {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.0.
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.0.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.0.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.0.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.0.
|
|
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",
|