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 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
@@ -1,2 +1,9 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ export declare const parsedArgs: import("meow").Result<{
3
+ devcUpCommand: {
4
+ type: "string";
5
+ };
6
+ devcExecCommand: {
7
+ type: "string";
8
+ };
9
+ }>;
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 Show help
13
- --version Show 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
- render(React.createElement(App, null));
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;
@@ -1,3 +1,7 @@
1
1
  import React from 'react';
2
- declare const App: React.FC;
2
+ import { DevcontainerConfig } from '../types/index.js';
3
+ interface AppProps {
4
+ devcontainerConfig?: DevcontainerConfig;
5
+ }
6
+ declare const App: React.FC<AppProps>;
3
7
  export default App;
@@ -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
- session = await sessionManager.createSessionWithPreset(worktree.path);
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
- const session = await sessionManager.createSessionWithPreset(selectedWorktree.path, presetId);
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
- setError(null);
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;
@@ -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: true }),
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,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
- createSession(worktreePath: string): Promise<Session>;
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
- async createSession(worktreePath) {
49
- // Check if session already exists
50
- const existing = this.sessions.get(worktreePath);
51
- if (existing) {
52
- return existing;
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', // Default to claude for legacy method
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
- // Try to spawn the process
111
- let ptyProcess;
112
- let isPrimaryCommand = true;
113
- try {
114
- ptyProcess = await this.spawn(command, args, worktreePath);
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
- // Spawn fallback process
189
- 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
+ }
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()) {