ccmanager 1.2.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.
- package/README.md +30 -0
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +31 -4
- package/dist/components/App.d.ts +5 -1
- package/dist/components/App.js +16 -5
- package/dist/components/Menu.d.ts +2 -0
- package/dist/components/Menu.js +13 -2
- package/dist/integration-tests/devcontainer.integration.test.d.ts +1 -0
- package/dist/integration-tests/devcontainer.integration.test.js +101 -0
- package/dist/services/sessionManager.d.ts +12 -2
- package/dist/services/sessionManager.js +90 -71
- package/dist/services/sessionManager.test.js +474 -140
- package/dist/types/index.d.ts +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
|
|
|
19
19
|
- Command presets with automatic fallback support
|
|
20
20
|
- Configurable state detection strategies for different CLI tools
|
|
21
21
|
- Status change hooks for automation and notifications
|
|
22
|
+
- Devcontainer integration
|
|
22
23
|
|
|
23
24
|
## Why CCManager over Claude Squad?
|
|
24
25
|
|
|
@@ -177,6 +178,35 @@ CCManager can automatically generate worktree directory paths based on branch na
|
|
|
177
178
|
|
|
178
179
|
For detailed configuration and examples, see [docs/worktree-auto-directory.md](docs/worktree-auto-directory.md).
|
|
179
180
|
|
|
181
|
+
## Devcontainer Integration
|
|
182
|
+
|
|
183
|
+
CCManager supports running AI assistant sessions inside devcontainers while keeping the manager itself on the host machine. This enables sandboxed development environments with restricted network access while maintaining host-level notifications and automation.
|
|
184
|
+
|
|
185
|
+
### Features
|
|
186
|
+
|
|
187
|
+
- **Host-based management**: CCManager runs on your host machine, managing sessions inside containers
|
|
188
|
+
- **Seamless integration**: All existing features (presets, status hooks, etc.) work with devcontainers
|
|
189
|
+
- **Security-focused**: Compatible with Anthropic's recommended devcontainer configurations
|
|
190
|
+
- **Persistent state**: Configuration and history persist across container recreations
|
|
191
|
+
|
|
192
|
+
### Usage
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# Start CCManager with devcontainer support
|
|
196
|
+
npx ccmanager --devc-up-command "<your devcontainer up command>" \
|
|
197
|
+
--devc-exec-command "<your devcontainer exec command>"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The devcontainer integration requires both commands:
|
|
201
|
+
- `--devc-up-command`: Any command to start the devcontainer
|
|
202
|
+
- `--devc-exec-command`: Any command to execute inside the container
|
|
203
|
+
|
|
204
|
+
### Benefits
|
|
205
|
+
|
|
206
|
+
- **Safe experimentation**: Run commands like `claude --dangerously-skip-permissions` without risk
|
|
207
|
+
|
|
208
|
+
For detailed setup and configuration, see [docs/devcontainer.md](docs/devcontainer.md).
|
|
209
|
+
|
|
180
210
|
## Git Worktree Configuration
|
|
181
211
|
|
|
182
212
|
CCManager can display enhanced git status information for each worktree when Git's worktree configuration extension is enabled.
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -4,19 +4,35 @@ import { render } from 'ink';
|
|
|
4
4
|
import meow from 'meow';
|
|
5
5
|
import App from './components/App.js';
|
|
6
6
|
import { worktreeConfigManager } from './services/worktreeConfigManager.js';
|
|
7
|
-
meow(`
|
|
7
|
+
const cli = meow(`
|
|
8
8
|
Usage
|
|
9
9
|
$ ccmanager
|
|
10
10
|
|
|
11
11
|
Options
|
|
12
|
-
--help
|
|
13
|
-
--version
|
|
12
|
+
--help Show help
|
|
13
|
+
--version Show version
|
|
14
|
+
--devc-up-command Command to start devcontainer
|
|
15
|
+
--devc-exec-command Command to execute in devcontainer
|
|
14
16
|
|
|
15
17
|
Examples
|
|
16
18
|
$ ccmanager
|
|
19
|
+
$ ccmanager --devc-up-command "devcontainer up --workspace-folder ." --devc-exec-command "devcontainer exec --workspace-folder ."
|
|
17
20
|
`, {
|
|
18
21
|
importMeta: import.meta,
|
|
22
|
+
flags: {
|
|
23
|
+
devcUpCommand: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
},
|
|
26
|
+
devcExecCommand: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
19
30
|
});
|
|
31
|
+
// Validate devcontainer arguments using XOR
|
|
32
|
+
if (!!cli.flags.devcUpCommand !== !!cli.flags.devcExecCommand) {
|
|
33
|
+
console.error('Error: Both --devc-up-command and --devc-exec-command must be provided together');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
20
36
|
// Check if we're in a TTY environment
|
|
21
37
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
22
38
|
console.error('Error: ccmanager must be run in an interactive terminal (TTY)');
|
|
@@ -24,4 +40,15 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
|
24
40
|
}
|
|
25
41
|
// Initialize worktree config manager
|
|
26
42
|
worktreeConfigManager.initialize();
|
|
27
|
-
|
|
43
|
+
// Prepare devcontainer config
|
|
44
|
+
const devcontainerConfig = cli.flags.devcUpCommand && cli.flags.devcExecCommand
|
|
45
|
+
? {
|
|
46
|
+
upCommand: cli.flags.devcUpCommand,
|
|
47
|
+
execCommand: cli.flags.devcExecCommand,
|
|
48
|
+
}
|
|
49
|
+
: undefined;
|
|
50
|
+
// Pass config to App
|
|
51
|
+
const appProps = devcontainerConfig ? { devcontainerConfig } : {};
|
|
52
|
+
render(React.createElement(App, { ...appProps }));
|
|
53
|
+
// Export for testing
|
|
54
|
+
export const parsedArgs = cli;
|
package/dist/components/App.d.ts
CHANGED
package/dist/components/App.js
CHANGED
|
@@ -11,7 +11,7 @@ import { SessionManager } from '../services/sessionManager.js';
|
|
|
11
11
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
12
12
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
13
13
|
import { configurationManager } from '../services/configurationManager.js';
|
|
14
|
-
const App = () => {
|
|
14
|
+
const App = ({ devcontainerConfig }) => {
|
|
15
15
|
const { exit } = useApp();
|
|
16
16
|
const [view, setView] = useState('menu');
|
|
17
17
|
const [sessionManager] = useState(() => new SessionManager());
|
|
@@ -87,7 +87,12 @@ const App = () => {
|
|
|
87
87
|
}
|
|
88
88
|
try {
|
|
89
89
|
// Use preset-based session creation with default preset
|
|
90
|
-
|
|
90
|
+
if (devcontainerConfig) {
|
|
91
|
+
session = await sessionManager.createSessionWithDevcontainer(worktree.path, devcontainerConfig);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
session = await sessionManager.createSessionWithPreset(worktree.path);
|
|
95
|
+
}
|
|
91
96
|
}
|
|
92
97
|
catch (error) {
|
|
93
98
|
setError(`Failed to create session: ${error}`);
|
|
@@ -102,7 +107,13 @@ const App = () => {
|
|
|
102
107
|
return;
|
|
103
108
|
try {
|
|
104
109
|
// Create session with selected preset
|
|
105
|
-
|
|
110
|
+
let session;
|
|
111
|
+
if (devcontainerConfig) {
|
|
112
|
+
session = await sessionManager.createSessionWithDevcontainer(selectedWorktree.path, devcontainerConfig, presetId);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
session = await sessionManager.createSessionWithPreset(selectedWorktree.path, presetId);
|
|
116
|
+
}
|
|
106
117
|
setActiveSession(session);
|
|
107
118
|
setView('session');
|
|
108
119
|
setSelectedWorktree(null);
|
|
@@ -120,7 +131,7 @@ const App = () => {
|
|
|
120
131
|
};
|
|
121
132
|
const handleReturnToMenu = () => {
|
|
122
133
|
setActiveSession(null);
|
|
123
|
-
|
|
134
|
+
// Don't clear error here - let user dismiss it manually
|
|
124
135
|
// Add a small delay to ensure Session cleanup completes
|
|
125
136
|
setTimeout(() => {
|
|
126
137
|
setView('menu');
|
|
@@ -210,7 +221,7 @@ const App = () => {
|
|
|
210
221
|
handleReturnToMenu();
|
|
211
222
|
};
|
|
212
223
|
if (view === 'menu') {
|
|
213
|
-
return (React.createElement(Menu, { key: menuKey, sessionManager: sessionManager, onSelectWorktree: handleSelectWorktree }));
|
|
224
|
+
return (React.createElement(Menu, { key: menuKey, sessionManager: sessionManager, onSelectWorktree: handleSelectWorktree, error: error, onDismissError: () => setError(null) }));
|
|
214
225
|
}
|
|
215
226
|
if (view === 'session' && activeSession) {
|
|
216
227
|
return (React.createElement(Box, { flexDirection: "column" },
|
|
@@ -4,6 +4,8 @@ import { SessionManager } from '../services/sessionManager.js';
|
|
|
4
4
|
interface MenuProps {
|
|
5
5
|
sessionManager: SessionManager;
|
|
6
6
|
onSelectWorktree: (worktree: Worktree) => void;
|
|
7
|
+
error?: string | null;
|
|
8
|
+
onDismissError?: () => void;
|
|
7
9
|
}
|
|
8
10
|
declare const Menu: React.FC<MenuProps>;
|
|
9
11
|
export default Menu;
|
package/dist/components/Menu.js
CHANGED
|
@@ -5,7 +5,7 @@ import { WorktreeService } from '../services/worktreeService.js';
|
|
|
5
5
|
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, } from '../constants/statusIcons.js';
|
|
6
6
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
7
7
|
import { prepareWorktreeItems, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
|
|
8
|
-
const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
8
|
+
const Menu = ({ sessionManager, onSelectWorktree, error, onDismissError, }) => {
|
|
9
9
|
const [baseWorktrees, setBaseWorktrees] = useState([]);
|
|
10
10
|
const [defaultBranch, setDefaultBranch] = useState(null);
|
|
11
11
|
const worktrees = useGitStatus(baseWorktrees, defaultBranch);
|
|
@@ -82,6 +82,11 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
82
82
|
}, [worktrees, sessions, defaultBranch]);
|
|
83
83
|
// Handle hotkeys
|
|
84
84
|
useInput((input, _key) => {
|
|
85
|
+
// Dismiss error on any key press when error is shown
|
|
86
|
+
if (error && onDismissError) {
|
|
87
|
+
onDismissError();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
85
90
|
const keyPressed = input.toLowerCase();
|
|
86
91
|
// Handle number keys 0-9 for worktree selection (first 10 only)
|
|
87
92
|
if (/^[0-9]$/.test(keyPressed)) {
|
|
@@ -198,7 +203,13 @@ const Menu = ({ sessionManager, onSelectWorktree }) => {
|
|
|
198
203
|
React.createElement(Text, { bold: true, color: "green" }, "CCManager - Claude Code Worktree Manager")),
|
|
199
204
|
React.createElement(Box, { marginBottom: 1 },
|
|
200
205
|
React.createElement(Text, { dimColor: true }, "Select a worktree to start or resume a Claude Code session:")),
|
|
201
|
-
React.createElement(SelectInput, { items: items, onSelect: handleSelect, isFocused:
|
|
206
|
+
React.createElement(SelectInput, { items: items, onSelect: handleSelect, isFocused: !error }),
|
|
207
|
+
error && (React.createElement(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red" },
|
|
208
|
+
React.createElement(Box, { flexDirection: "column" },
|
|
209
|
+
React.createElement(Text, { color: "red", bold: true },
|
|
210
|
+
"Error: ",
|
|
211
|
+
error),
|
|
212
|
+
React.createElement(Text, { color: "gray", dimColor: true }, "Press any key to dismiss")))),
|
|
202
213
|
React.createElement(Box, { marginTop: 1, flexDirection: "column" },
|
|
203
214
|
React.createElement(Text, { dimColor: true },
|
|
204
215
|
"Status: ",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { SessionManager } from '../services/sessionManager.js';
|
|
3
|
+
import { spawn } from 'node-pty';
|
|
4
|
+
import { exec } from 'child_process';
|
|
5
|
+
// Mock modules
|
|
6
|
+
vi.mock('node-pty');
|
|
7
|
+
vi.mock('child_process');
|
|
8
|
+
vi.mock('util', () => ({
|
|
9
|
+
promisify: vi.fn((fn) => {
|
|
10
|
+
return (cmd, options) => {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const callback = (err, stdout, stderr) => {
|
|
13
|
+
if (err) {
|
|
14
|
+
reject(err);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
resolve({ stdout, stderr });
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
// Call the original function with the promisified callback
|
|
21
|
+
fn(cmd, options, callback);
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
vi.mock('../services/configurationManager.js', () => ({
|
|
27
|
+
configurationManager: {
|
|
28
|
+
getDefaultPreset: vi.fn(() => ({
|
|
29
|
+
id: 'claude',
|
|
30
|
+
name: 'Claude',
|
|
31
|
+
command: 'claude',
|
|
32
|
+
args: [],
|
|
33
|
+
})),
|
|
34
|
+
getPresetById: vi.fn(),
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
vi.mock('../services/worktreeService.js', () => ({
|
|
38
|
+
WorktreeService: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
const mockSpawn = vi.mocked(spawn);
|
|
41
|
+
const mockExec = vi.mocked(exec);
|
|
42
|
+
describe('Devcontainer Integration', () => {
|
|
43
|
+
let sessionManager;
|
|
44
|
+
let mockPty;
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
sessionManager = new SessionManager();
|
|
48
|
+
// Mock PTY process
|
|
49
|
+
mockPty = {
|
|
50
|
+
onData: vi.fn(),
|
|
51
|
+
onExit: vi.fn(),
|
|
52
|
+
write: vi.fn(),
|
|
53
|
+
resize: vi.fn(),
|
|
54
|
+
kill: vi.fn(),
|
|
55
|
+
pid: 12345,
|
|
56
|
+
cols: 80,
|
|
57
|
+
rows: 24,
|
|
58
|
+
process: 'claude',
|
|
59
|
+
};
|
|
60
|
+
mockSpawn.mockReturnValue(mockPty);
|
|
61
|
+
});
|
|
62
|
+
it('should execute devcontainer up command before creating session', async () => {
|
|
63
|
+
const devcontainerConfig = {
|
|
64
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
65
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
66
|
+
};
|
|
67
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
68
|
+
if (typeof options === 'function') {
|
|
69
|
+
callback = options;
|
|
70
|
+
options = undefined;
|
|
71
|
+
}
|
|
72
|
+
if (callback && typeof callback === 'function') {
|
|
73
|
+
callback(null, 'Container started', '');
|
|
74
|
+
}
|
|
75
|
+
return {};
|
|
76
|
+
});
|
|
77
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
|
|
78
|
+
expect(mockExec).toHaveBeenCalledWith(devcontainerConfig.upCommand, { cwd: '/test/worktree' }, expect.any(Function));
|
|
79
|
+
});
|
|
80
|
+
it('should handle devcontainer command execution with presets', async () => {
|
|
81
|
+
const devcontainerConfig = {
|
|
82
|
+
upCommand: 'devcontainer up --workspace-folder .',
|
|
83
|
+
execCommand: 'devcontainer exec --workspace-folder .',
|
|
84
|
+
};
|
|
85
|
+
mockExec.mockImplementation((cmd, options, callback) => {
|
|
86
|
+
if (typeof options === 'function') {
|
|
87
|
+
callback = options;
|
|
88
|
+
options = undefined;
|
|
89
|
+
}
|
|
90
|
+
if (callback && typeof callback === 'function') {
|
|
91
|
+
callback(null, 'Container started', '');
|
|
92
|
+
}
|
|
93
|
+
return {};
|
|
94
|
+
});
|
|
95
|
+
await sessionManager.createSessionWithDevcontainer('/test/worktree', devcontainerConfig);
|
|
96
|
+
// Should spawn with devcontainer exec command
|
|
97
|
+
expect(mockSpawn).toHaveBeenCalledWith('devcontainer', ['exec', '--workspace-folder', '.', '--', 'claude'], expect.objectContaining({
|
|
98
|
+
cwd: '/test/worktree',
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Session, SessionManager as ISessionManager, SessionState } from '../types/index.js';
|
|
1
|
+
import { Session, SessionManager as ISessionManager, SessionState, DevcontainerConfig } from '../types/index.js';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
export declare class SessionManager extends EventEmitter implements ISessionManager {
|
|
4
4
|
sessions: Map<string, Session>;
|
|
@@ -7,9 +7,18 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
7
7
|
private spawn;
|
|
8
8
|
detectTerminalState(session: Session): SessionState;
|
|
9
9
|
constructor();
|
|
10
|
-
|
|
10
|
+
private createSessionId;
|
|
11
|
+
private createTerminal;
|
|
12
|
+
private createSessionInternal;
|
|
11
13
|
createSessionWithPreset(worktreePath: string, presetId?: string): Promise<Session>;
|
|
12
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
|
+
*/
|
|
13
22
|
private setupExitHandler;
|
|
14
23
|
private setupBackgroundHandler;
|
|
15
24
|
private cleanupSession;
|
|
@@ -18,5 +27,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
18
27
|
destroySession(worktreePath: string): void;
|
|
19
28
|
getAllSessions(): Session[];
|
|
20
29
|
private executeStatusHook;
|
|
30
|
+
createSessionWithDevcontainer(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Promise<Session>;
|
|
21
31
|
destroy(): void;
|
|
22
32
|
}
|
|
@@ -2,10 +2,12 @@ import { spawn } from 'node-pty';
|
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import pkg from '@xterm/headless';
|
|
4
4
|
import { exec } from 'child_process';
|
|
5
|
+
import { promisify } from 'util';
|
|
5
6
|
import { configurationManager } from './configurationManager.js';
|
|
6
7
|
import { WorktreeService } from './worktreeService.js';
|
|
7
8
|
import { createStateDetector } from './stateDetector.js';
|
|
8
9
|
const { Terminal } = pkg;
|
|
10
|
+
const execAsync = promisify(exec);
|
|
9
11
|
export class SessionManager extends EventEmitter {
|
|
10
12
|
async spawn(command, args, worktreePath) {
|
|
11
13
|
const spawnOptions = {
|
|
@@ -45,27 +47,19 @@ export class SessionManager extends EventEmitter {
|
|
|
45
47
|
});
|
|
46
48
|
this.sessions = new Map();
|
|
47
49
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
const id = `session-${Date.now()}-${Math.random()
|
|
55
|
-
.toString(36)
|
|
56
|
-
.substr(2, 9)}`;
|
|
57
|
-
// Get command configuration
|
|
58
|
-
const commandConfig = configurationManager.getCommandConfig();
|
|
59
|
-
const command = commandConfig.command || 'claude';
|
|
60
|
-
const args = commandConfig.args || [];
|
|
61
|
-
// Spawn the process with fallback support
|
|
62
|
-
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
63
|
-
// Create virtual terminal for state detection
|
|
64
|
-
const terminal = new Terminal({
|
|
50
|
+
createSessionId() {
|
|
51
|
+
return `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
52
|
+
}
|
|
53
|
+
createTerminal() {
|
|
54
|
+
return new Terminal({
|
|
65
55
|
cols: process.stdout.columns || 80,
|
|
66
56
|
rows: process.stdout.rows || 24,
|
|
67
57
|
allowProposedApi: true,
|
|
68
58
|
});
|
|
59
|
+
}
|
|
60
|
+
async createSessionInternal(worktreePath, ptyProcess, commandConfig, options = {}) {
|
|
61
|
+
const id = this.createSessionId();
|
|
62
|
+
const terminal = this.createTerminal();
|
|
69
63
|
const session = {
|
|
70
64
|
id,
|
|
71
65
|
worktreePath,
|
|
@@ -76,9 +70,10 @@ export class SessionManager extends EventEmitter {
|
|
|
76
70
|
lastActivity: new Date(),
|
|
77
71
|
isActive: false,
|
|
78
72
|
terminal,
|
|
79
|
-
isPrimaryCommand: true,
|
|
73
|
+
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
80
74
|
commandConfig,
|
|
81
|
-
detectionStrategy: 'claude',
|
|
75
|
+
detectionStrategy: options.detectionStrategy ?? 'claude',
|
|
76
|
+
devcontainerConfig: options.devcontainerConfig,
|
|
82
77
|
};
|
|
83
78
|
// Set up persistent background data handler for state detection
|
|
84
79
|
this.setupBackgroundHandler(session);
|
|
@@ -92,9 +87,6 @@ export class SessionManager extends EventEmitter {
|
|
|
92
87
|
if (existing) {
|
|
93
88
|
return existing;
|
|
94
89
|
}
|
|
95
|
-
const id = `session-${Date.now()}-${Math.random()
|
|
96
|
-
.toString(36)
|
|
97
|
-
.substr(2, 9)}`;
|
|
98
90
|
// Get preset configuration
|
|
99
91
|
let preset = presetId ? configurationManager.getPresetById(presetId) : null;
|
|
100
92
|
if (!preset) {
|
|
@@ -107,54 +99,12 @@ export class SessionManager extends EventEmitter {
|
|
|
107
99
|
args: preset.args,
|
|
108
100
|
fallbackArgs: preset.fallbackArgs,
|
|
109
101
|
};
|
|
110
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
// If primary command fails and we have fallback args, try them
|
|
118
|
-
if (preset.fallbackArgs) {
|
|
119
|
-
try {
|
|
120
|
-
ptyProcess = await this.spawn(command, preset.fallbackArgs, worktreePath);
|
|
121
|
-
isPrimaryCommand = false;
|
|
122
|
-
}
|
|
123
|
-
catch (_fallbackError) {
|
|
124
|
-
// Both attempts failed, throw the original error
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
// No fallback args, throw the error
|
|
130
|
-
throw error;
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
// Create virtual terminal for state detection
|
|
134
|
-
const terminal = new Terminal({
|
|
135
|
-
cols: process.stdout.columns || 80,
|
|
136
|
-
rows: process.stdout.rows || 24,
|
|
137
|
-
allowProposedApi: true,
|
|
102
|
+
// Spawn the process - fallback will be handled by setupExitHandler
|
|
103
|
+
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
104
|
+
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
105
|
+
isPrimaryCommand: true,
|
|
106
|
+
detectionStrategy: preset.detectionStrategy,
|
|
138
107
|
});
|
|
139
|
-
const session = {
|
|
140
|
-
id,
|
|
141
|
-
worktreePath,
|
|
142
|
-
process: ptyProcess,
|
|
143
|
-
state: 'busy', // Session starts as busy when created
|
|
144
|
-
output: [],
|
|
145
|
-
outputHistory: [],
|
|
146
|
-
lastActivity: new Date(),
|
|
147
|
-
isActive: false,
|
|
148
|
-
terminal,
|
|
149
|
-
isPrimaryCommand,
|
|
150
|
-
commandConfig,
|
|
151
|
-
detectionStrategy: preset.detectionStrategy || 'claude',
|
|
152
|
-
};
|
|
153
|
-
// Set up persistent background data handler for state detection
|
|
154
|
-
this.setupBackgroundHandler(session);
|
|
155
|
-
this.sessions.set(worktreePath, session);
|
|
156
|
-
this.emit('sessionCreated', session);
|
|
157
|
-
return session;
|
|
158
108
|
}
|
|
159
109
|
setupDataHandler(session) {
|
|
160
110
|
// This handler always runs for all data
|
|
@@ -180,13 +130,40 @@ export class SessionManager extends EventEmitter {
|
|
|
180
130
|
}
|
|
181
131
|
});
|
|
182
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
|
+
*/
|
|
183
140
|
setupExitHandler(session) {
|
|
184
141
|
session.process.onExit(async (e) => {
|
|
185
142
|
// Check if we should attempt fallback
|
|
186
143
|
if (e.exitCode === 1 && !e.signal && session.isPrimaryCommand) {
|
|
187
144
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
|
190
167
|
// Replace the process
|
|
191
168
|
session.process = fallbackProcess;
|
|
192
169
|
session.isPrimaryCommand = false;
|
|
@@ -304,6 +281,48 @@ export class SessionManager extends EventEmitter {
|
|
|
304
281
|
});
|
|
305
282
|
}
|
|
306
283
|
}
|
|
284
|
+
async createSessionWithDevcontainer(worktreePath, devcontainerConfig, presetId) {
|
|
285
|
+
// Check if session already exists
|
|
286
|
+
const existing = this.sessions.get(worktreePath);
|
|
287
|
+
if (existing) {
|
|
288
|
+
return existing;
|
|
289
|
+
}
|
|
290
|
+
// Execute devcontainer up command first
|
|
291
|
+
try {
|
|
292
|
+
await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
throw new Error(`Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`);
|
|
296
|
+
}
|
|
297
|
+
// Get preset configuration
|
|
298
|
+
let preset = presetId ? configurationManager.getPresetById(presetId) : null;
|
|
299
|
+
if (!preset) {
|
|
300
|
+
preset = configurationManager.getDefaultPreset();
|
|
301
|
+
}
|
|
302
|
+
// Parse the exec command to extract arguments
|
|
303
|
+
const execParts = devcontainerConfig.execCommand.split(/\s+/);
|
|
304
|
+
const devcontainerCmd = execParts[0] || 'devcontainer'; // Should be 'devcontainer'
|
|
305
|
+
const execArgs = execParts.slice(1); // Rest of the exec command args
|
|
306
|
+
// Build the full command: devcontainer exec [args] -- [preset command] [preset args]
|
|
307
|
+
const fullArgs = [
|
|
308
|
+
...execArgs,
|
|
309
|
+
'--',
|
|
310
|
+
preset.command,
|
|
311
|
+
...(preset.args || []),
|
|
312
|
+
];
|
|
313
|
+
// Spawn the process within devcontainer - fallback will be handled by setupExitHandler
|
|
314
|
+
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
|
|
315
|
+
const commandConfig = {
|
|
316
|
+
command: preset.command,
|
|
317
|
+
args: preset.args,
|
|
318
|
+
fallbackArgs: preset.fallbackArgs,
|
|
319
|
+
};
|
|
320
|
+
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
321
|
+
isPrimaryCommand: true,
|
|
322
|
+
detectionStrategy: preset.detectionStrategy,
|
|
323
|
+
devcontainerConfig,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
307
326
|
destroy() {
|
|
308
327
|
// Clean up all sessions
|
|
309
328
|
for (const worktreePath of this.sessions.keys()) {
|