ccmanager 1.1.1 → 1.3.0
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 +18 -7
- package/dist/components/Menu.d.ts +2 -0
- package/dist/components/Menu.js +13 -2
- package/dist/components/NewWorktree.d.ts +1 -1
- package/dist/components/NewWorktree.js +34 -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 +5 -2
- package/dist/services/sessionManager.js +59 -46
- package/dist/services/sessionManager.test.js +358 -142
- package/dist/services/worktreeService.d.ts +3 -1
- package/dist/services/worktreeService.js +61 -2
- package/dist/services/worktreeService.test.js +165 -0
- 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');
|
|
@@ -139,11 +150,11 @@ const App = () => {
|
|
|
139
150
|
}
|
|
140
151
|
}, 50); // Small delay to ensure proper cleanup
|
|
141
152
|
};
|
|
142
|
-
const handleCreateWorktree = async (path, branch, baseBranch) => {
|
|
153
|
+
const handleCreateWorktree = async (path, branch, baseBranch, copyClaudeDirectory) => {
|
|
143
154
|
setView('creating-worktree');
|
|
144
155
|
setError(null);
|
|
145
156
|
// Create the worktree
|
|
146
|
-
const result = worktreeService.createWorktree(path, branch, baseBranch);
|
|
157
|
+
const result = worktreeService.createWorktree(path, branch, baseBranch, copyClaudeDirectory);
|
|
147
158
|
if (result.success) {
|
|
148
159
|
// Success - return to menu
|
|
149
160
|
handleReturnToMenu();
|
|
@@ -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: ",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
interface NewWorktreeProps {
|
|
3
|
-
onComplete: (path: string, branch: string, baseBranch: string) => void;
|
|
3
|
+
onComplete: (path: string, branch: string, baseBranch: string, copyClaudeDirectory: boolean) => void;
|
|
4
4
|
onCancel: () => void;
|
|
5
5
|
}
|
|
6
6
|
declare const NewWorktree: React.FC<NewWorktreeProps>;
|
|
@@ -13,6 +13,7 @@ const NewWorktree = ({ onComplete, onCancel }) => {
|
|
|
13
13
|
const [step, setStep] = useState(isAutoDirectory ? 'branch' : 'path');
|
|
14
14
|
const [path, setPath] = useState('');
|
|
15
15
|
const [branch, setBranch] = useState('');
|
|
16
|
+
const [baseBranch, setBaseBranch] = useState('');
|
|
16
17
|
// Initialize worktree service and load branches (memoized to avoid re-initialization)
|
|
17
18
|
const { branches, defaultBranch } = useMemo(() => {
|
|
18
19
|
const service = new WorktreeService();
|
|
@@ -48,13 +49,31 @@ const NewWorktree = ({ onComplete, onCancel }) => {
|
|
|
48
49
|
}
|
|
49
50
|
};
|
|
50
51
|
const handleBaseBranchSelect = (item) => {
|
|
52
|
+
setBaseBranch(item.value);
|
|
53
|
+
// Check if .claude directory exists in the base branch
|
|
54
|
+
const service = new WorktreeService();
|
|
55
|
+
if (service.hasClaudeDirectoryInBranch(item.value)) {
|
|
56
|
+
setStep('copy-settings');
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// Skip copy-settings step and complete with copySettings = false
|
|
60
|
+
if (isAutoDirectory) {
|
|
61
|
+
const autoPath = generateWorktreeDirectory(branch, worktreeConfig.autoDirectoryPattern);
|
|
62
|
+
onComplete(autoPath, branch, item.value, false);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
onComplete(path, branch, item.value, false);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const handleCopySettingsSelect = (item) => {
|
|
51
70
|
if (isAutoDirectory) {
|
|
52
71
|
// Generate path from branch name
|
|
53
72
|
const autoPath = generateWorktreeDirectory(branch, worktreeConfig.autoDirectoryPattern);
|
|
54
|
-
onComplete(autoPath, branch, item.value);
|
|
73
|
+
onComplete(autoPath, branch, baseBranch, item.value);
|
|
55
74
|
}
|
|
56
75
|
else {
|
|
57
|
-
onComplete(path, branch, item.value);
|
|
76
|
+
onComplete(path, branch, baseBranch, item.value);
|
|
58
77
|
}
|
|
59
78
|
};
|
|
60
79
|
// Calculate generated path for preview (memoized to avoid expensive recalculations)
|
|
@@ -97,6 +116,19 @@ const NewWorktree = ({ onComplete, onCancel }) => {
|
|
|
97
116
|
React.createElement(Text, { color: "cyan" }, branch),
|
|
98
117
|
":")),
|
|
99
118
|
React.createElement(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: 0, limit: 10 }))),
|
|
119
|
+
step === 'copy-settings' && (React.createElement(Box, { flexDirection: "column" },
|
|
120
|
+
React.createElement(Box, { marginBottom: 1 },
|
|
121
|
+
React.createElement(Text, null,
|
|
122
|
+
"Copy .claude directory from base branch (",
|
|
123
|
+
React.createElement(Text, { color: "cyan" }, baseBranch),
|
|
124
|
+
")?")),
|
|
125
|
+
React.createElement(SelectInput, { items: [
|
|
126
|
+
{
|
|
127
|
+
label: 'Yes - Copy .claude directory from base branch',
|
|
128
|
+
value: true,
|
|
129
|
+
},
|
|
130
|
+
{ label: 'No - Start without .claude directory', value: false },
|
|
131
|
+
], onSelect: handleCopySettingsSelect, initialIndex: 0 }))),
|
|
100
132
|
React.createElement(Box, { marginTop: 1 },
|
|
101
133
|
React.createElement(Text, { dimColor: true },
|
|
102
134
|
"Press ",
|
|
@@ -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,7 +7,9 @@ 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;
|
|
13
15
|
private setupExitHandler;
|
|
@@ -18,5 +20,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
18
20
|
destroySession(worktreePath: string): void;
|
|
19
21
|
getAllSessions(): Session[];
|
|
20
22
|
private executeStatusHook;
|
|
23
|
+
createSessionWithDevcontainer(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Promise<Session>;
|
|
21
24
|
destroy(): void;
|
|
22
25
|
}
|
|
@@ -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) {
|
|
@@ -130,31 +122,10 @@ export class SessionManager extends EventEmitter {
|
|
|
130
122
|
throw error;
|
|
131
123
|
}
|
|
132
124
|
}
|
|
133
|
-
|
|
134
|
-
const terminal = new Terminal({
|
|
135
|
-
cols: process.stdout.columns || 80,
|
|
136
|
-
rows: process.stdout.rows || 24,
|
|
137
|
-
allowProposedApi: true,
|
|
138
|
-
});
|
|
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,
|
|
125
|
+
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
149
126
|
isPrimaryCommand,
|
|
150
|
-
|
|
151
|
-
|
|
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;
|
|
127
|
+
detectionStrategy: preset.detectionStrategy,
|
|
128
|
+
});
|
|
158
129
|
}
|
|
159
130
|
setupDataHandler(session) {
|
|
160
131
|
// This handler always runs for all data
|
|
@@ -304,6 +275,48 @@ export class SessionManager extends EventEmitter {
|
|
|
304
275
|
});
|
|
305
276
|
}
|
|
306
277
|
}
|
|
278
|
+
async createSessionWithDevcontainer(worktreePath, devcontainerConfig, presetId) {
|
|
279
|
+
// Check if session already exists
|
|
280
|
+
const existing = this.sessions.get(worktreePath);
|
|
281
|
+
if (existing) {
|
|
282
|
+
return existing;
|
|
283
|
+
}
|
|
284
|
+
// Execute devcontainer up command first
|
|
285
|
+
try {
|
|
286
|
+
await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
throw new Error(`Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`);
|
|
290
|
+
}
|
|
291
|
+
// Get preset configuration
|
|
292
|
+
let preset = presetId ? configurationManager.getPresetById(presetId) : null;
|
|
293
|
+
if (!preset) {
|
|
294
|
+
preset = configurationManager.getDefaultPreset();
|
|
295
|
+
}
|
|
296
|
+
// Parse the exec command to extract arguments
|
|
297
|
+
const execParts = devcontainerConfig.execCommand.split(/\s+/);
|
|
298
|
+
const devcontainerCmd = execParts[0] || 'devcontainer'; // Should be 'devcontainer'
|
|
299
|
+
const execArgs = execParts.slice(1); // Rest of the exec command args
|
|
300
|
+
// Build the full command: devcontainer exec [args] -- [preset command] [preset args]
|
|
301
|
+
const fullArgs = [
|
|
302
|
+
...execArgs,
|
|
303
|
+
'--',
|
|
304
|
+
preset.command,
|
|
305
|
+
...(preset.args || []),
|
|
306
|
+
];
|
|
307
|
+
// Spawn the process within devcontainer
|
|
308
|
+
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
|
|
309
|
+
const commandConfig = {
|
|
310
|
+
command: preset.command,
|
|
311
|
+
args: preset.args,
|
|
312
|
+
fallbackArgs: preset.fallbackArgs,
|
|
313
|
+
};
|
|
314
|
+
return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
|
|
315
|
+
isPrimaryCommand: true,
|
|
316
|
+
detectionStrategy: preset.detectionStrategy,
|
|
317
|
+
devcontainerConfig,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
307
320
|
destroy() {
|
|
308
321
|
// Clean up all sessions
|
|
309
322
|
for (const worktreePath of this.sessions.keys()) {
|