ccmanager 2.8.0 → 2.9.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.
Files changed (82) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/components/Session.js +11 -6
  26. package/dist/hooks/useGitStatus.d.ts +11 -0
  27. package/dist/hooks/useGitStatus.js +70 -12
  28. package/dist/hooks/useGitStatus.test.js +30 -23
  29. package/dist/services/configurationManager.d.ts +75 -0
  30. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  31. package/dist/services/configurationManager.effect.test.js +407 -0
  32. package/dist/services/configurationManager.js +246 -0
  33. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  34. package/dist/services/projectManager.d.ts +98 -2
  35. package/dist/services/projectManager.js +228 -59
  36. package/dist/services/projectManager.test.js +242 -2
  37. package/dist/services/sessionManager.d.ts +44 -2
  38. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  39. package/dist/services/sessionManager.effect.test.js +321 -0
  40. package/dist/services/sessionManager.js +216 -65
  41. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  42. package/dist/services/sessionManager.test.js +40 -36
  43. package/dist/services/shortcutManager.d.ts +2 -0
  44. package/dist/services/shortcutManager.js +53 -0
  45. package/dist/services/shortcutManager.test.d.ts +1 -0
  46. package/dist/services/shortcutManager.test.js +30 -0
  47. package/dist/services/worktreeService.d.ts +356 -26
  48. package/dist/services/worktreeService.js +793 -353
  49. package/dist/services/worktreeService.test.js +294 -313
  50. package/dist/types/errors.d.ts +74 -0
  51. package/dist/types/errors.js +31 -0
  52. package/dist/types/errors.test.d.ts +1 -0
  53. package/dist/types/errors.test.js +201 -0
  54. package/dist/types/index.d.ts +5 -17
  55. package/dist/utils/claudeDir.d.ts +58 -6
  56. package/dist/utils/claudeDir.js +103 -8
  57. package/dist/utils/claudeDir.test.d.ts +1 -0
  58. package/dist/utils/claudeDir.test.js +108 -0
  59. package/dist/utils/concurrencyLimit.d.ts +5 -0
  60. package/dist/utils/concurrencyLimit.js +11 -0
  61. package/dist/utils/concurrencyLimit.test.js +40 -1
  62. package/dist/utils/gitStatus.d.ts +36 -8
  63. package/dist/utils/gitStatus.js +170 -88
  64. package/dist/utils/gitStatus.test.js +12 -9
  65. package/dist/utils/hookExecutor.d.ts +41 -6
  66. package/dist/utils/hookExecutor.js +75 -32
  67. package/dist/utils/hookExecutor.test.js +73 -20
  68. package/dist/utils/terminalCapabilities.d.ts +18 -0
  69. package/dist/utils/terminalCapabilities.js +81 -0
  70. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  71. package/dist/utils/terminalCapabilities.test.js +104 -0
  72. package/dist/utils/testHelpers.d.ts +106 -0
  73. package/dist/utils/testHelpers.js +153 -0
  74. package/dist/utils/testHelpers.test.d.ts +1 -0
  75. package/dist/utils/testHelpers.test.js +114 -0
  76. package/dist/utils/worktreeConfig.d.ts +77 -2
  77. package/dist/utils/worktreeConfig.js +156 -16
  78. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  79. package/dist/utils/worktreeConfig.test.js +39 -0
  80. package/package.json +4 -4
  81. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  82. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -0,0 +1,321 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { Effect, Either } from 'effect';
3
+ import { spawn } from 'node-pty';
4
+ import { EventEmitter } from 'events';
5
+ // Mock node-pty
6
+ vi.mock('node-pty', () => ({
7
+ spawn: vi.fn(),
8
+ }));
9
+ // Mock child_process
10
+ vi.mock('child_process', () => ({
11
+ exec: vi.fn(),
12
+ execFile: vi.fn(),
13
+ }));
14
+ // Mock configuration manager
15
+ vi.mock('./configurationManager.js', () => ({
16
+ configurationManager: {
17
+ getDefaultPreset: vi.fn(),
18
+ getPresetById: vi.fn(),
19
+ },
20
+ }));
21
+ // Mock Terminal
22
+ vi.mock('@xterm/headless', () => ({
23
+ default: {
24
+ Terminal: vi.fn().mockImplementation(() => ({
25
+ buffer: {
26
+ active: {
27
+ length: 0,
28
+ getLine: vi.fn(),
29
+ },
30
+ },
31
+ write: vi.fn(),
32
+ })),
33
+ },
34
+ }));
35
+ // Create a mock IPty class
36
+ class MockPty extends EventEmitter {
37
+ constructor() {
38
+ super(...arguments);
39
+ Object.defineProperty(this, "kill", {
40
+ enumerable: true,
41
+ configurable: true,
42
+ writable: true,
43
+ value: vi.fn()
44
+ });
45
+ Object.defineProperty(this, "resize", {
46
+ enumerable: true,
47
+ configurable: true,
48
+ writable: true,
49
+ value: vi.fn()
50
+ });
51
+ Object.defineProperty(this, "write", {
52
+ enumerable: true,
53
+ configurable: true,
54
+ writable: true,
55
+ value: vi.fn()
56
+ });
57
+ Object.defineProperty(this, "onData", {
58
+ enumerable: true,
59
+ configurable: true,
60
+ writable: true,
61
+ value: vi.fn((callback) => {
62
+ this.on('data', callback);
63
+ })
64
+ });
65
+ Object.defineProperty(this, "onExit", {
66
+ enumerable: true,
67
+ configurable: true,
68
+ writable: true,
69
+ value: vi.fn((callback) => {
70
+ this.on('exit', callback);
71
+ })
72
+ });
73
+ }
74
+ }
75
+ describe('SessionManager Effect-based Operations', () => {
76
+ let sessionManager;
77
+ let mockPty;
78
+ let SessionManager;
79
+ let configurationManager;
80
+ beforeEach(async () => {
81
+ vi.clearAllMocks();
82
+ // Dynamically import after mocks are set up
83
+ const sessionManagerModule = await import('./sessionManager.js');
84
+ const configManagerModule = await import('./configurationManager.js');
85
+ SessionManager = sessionManagerModule.SessionManager;
86
+ configurationManager = configManagerModule.configurationManager;
87
+ sessionManager = new SessionManager();
88
+ mockPty = new MockPty();
89
+ });
90
+ afterEach(() => {
91
+ sessionManager.destroy();
92
+ });
93
+ describe('createSessionWithPreset returning Effect', () => {
94
+ it('should return Effect that succeeds with Session', async () => {
95
+ // Setup mock preset
96
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
97
+ id: '1',
98
+ name: 'Main',
99
+ command: 'claude',
100
+ args: ['--preset-arg'],
101
+ });
102
+ // Setup spawn mock
103
+ vi.mocked(spawn).mockReturnValue(mockPty);
104
+ // Create session with preset - should return Effect
105
+ const effect = sessionManager.createSessionWithPresetEffect('/test/worktree');
106
+ // Execute the Effect and verify it succeeds with a Session
107
+ const session = await Effect.runPromise(effect);
108
+ expect(session).toBeDefined();
109
+ expect(session.worktreePath).toBe('/test/worktree');
110
+ expect(session.state).toBe('busy');
111
+ });
112
+ it('should return Effect that fails with ConfigError when preset not found', async () => {
113
+ // Setup mocks - both return null/undefined
114
+ vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
115
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue(undefined);
116
+ // Create session with non-existent preset - should return Effect
117
+ const effect = sessionManager.createSessionWithPresetEffect('/test/worktree', 'invalid-preset');
118
+ // Execute the Effect and expect it to fail with ConfigError
119
+ const result = await Effect.runPromise(Effect.either(effect));
120
+ expect(Either.isLeft(result)).toBe(true);
121
+ if (Either.isLeft(result)) {
122
+ expect(result.left._tag).toBe('ConfigError');
123
+ if (result.left._tag === 'ConfigError') {
124
+ expect(result.left.reason).toBe('validation');
125
+ expect(result.left.details).toContain('preset');
126
+ }
127
+ }
128
+ });
129
+ it('should return Effect that fails with ProcessError when spawn fails', async () => {
130
+ // Setup mock preset
131
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
132
+ id: '1',
133
+ name: 'Main',
134
+ command: 'invalid-command',
135
+ args: ['--arg'],
136
+ });
137
+ // Mock spawn to throw error
138
+ vi.mocked(spawn).mockImplementation(() => {
139
+ throw new Error('spawn ENOENT: command not found');
140
+ });
141
+ // Create session - should return Effect
142
+ const effect = sessionManager.createSessionWithPresetEffect('/test/worktree');
143
+ // Execute the Effect and expect it to fail with ProcessError
144
+ const result = await Effect.runPromise(Effect.either(effect));
145
+ expect(Either.isLeft(result)).toBe(true);
146
+ if (Either.isLeft(result)) {
147
+ expect(result.left._tag).toBe('ProcessError');
148
+ if (result.left._tag === 'ProcessError') {
149
+ expect(result.left.command).toContain('createSessionWithPreset');
150
+ expect(result.left.message).toContain('spawn');
151
+ }
152
+ }
153
+ });
154
+ it('should return existing session without creating new Effect', async () => {
155
+ // Setup mock preset
156
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
157
+ id: '1',
158
+ name: 'Main',
159
+ command: 'claude',
160
+ });
161
+ // Setup spawn mock
162
+ vi.mocked(spawn).mockReturnValue(mockPty);
163
+ // Create session twice
164
+ const effect1 = sessionManager.createSessionWithPresetEffect('/test/worktree');
165
+ const session1 = await Effect.runPromise(effect1);
166
+ const effect2 = sessionManager.createSessionWithPresetEffect('/test/worktree');
167
+ const session2 = await Effect.runPromise(effect2);
168
+ // Should return the same session
169
+ expect(session1).toBe(session2);
170
+ // Spawn should only be called once
171
+ expect(spawn).toHaveBeenCalledTimes(1);
172
+ });
173
+ });
174
+ describe('createSessionWithDevcontainer returning Effect', () => {
175
+ it('should return Effect that succeeds with Session', async () => {
176
+ // Setup mock preset
177
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
178
+ id: '1',
179
+ name: 'Main',
180
+ command: 'claude',
181
+ args: ['--resume'],
182
+ });
183
+ // Setup spawn mock
184
+ vi.mocked(spawn).mockReturnValue(mockPty);
185
+ // Mock exec to succeed
186
+ const { exec } = await import('child_process');
187
+ const mockExec = vi.mocked(exec);
188
+ mockExec.mockImplementation((cmd, options, callback) => {
189
+ if (typeof options === 'function') {
190
+ callback = options;
191
+ }
192
+ if (callback && typeof callback === 'function') {
193
+ callback(null, 'Container started', '');
194
+ }
195
+ return {};
196
+ });
197
+ const devcontainerConfig = {
198
+ upCommand: 'devcontainer up --workspace-folder .',
199
+ execCommand: 'devcontainer exec --workspace-folder .',
200
+ };
201
+ // Create session with devcontainer - should return Effect
202
+ const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig);
203
+ // Execute the Effect and verify it succeeds with a Session
204
+ const session = await Effect.runPromise(effect);
205
+ expect(session).toBeDefined();
206
+ expect(session.worktreePath).toBe('/test/worktree');
207
+ expect(session.devcontainerConfig).toEqual(devcontainerConfig);
208
+ });
209
+ it('should return Effect that fails with ProcessError when devcontainer up fails', async () => {
210
+ // Mock exec to fail
211
+ const { exec } = await import('child_process');
212
+ const mockExec = vi.mocked(exec);
213
+ mockExec.mockImplementation((cmd, options, callback) => {
214
+ if (typeof options === 'function') {
215
+ callback = options;
216
+ }
217
+ if (callback && typeof callback === 'function') {
218
+ callback(new Error('Container failed to start'), '', '');
219
+ }
220
+ return {};
221
+ });
222
+ const devcontainerConfig = {
223
+ upCommand: 'devcontainer up --workspace-folder .',
224
+ execCommand: 'devcontainer exec --workspace-folder .',
225
+ };
226
+ // Create session with devcontainer - should return Effect
227
+ const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig);
228
+ // Execute the Effect and expect it to fail with ProcessError
229
+ const result = await Effect.runPromise(Effect.either(effect));
230
+ expect(Either.isLeft(result)).toBe(true);
231
+ if (Either.isLeft(result)) {
232
+ expect(result.left._tag).toBe('ProcessError');
233
+ if (result.left._tag === 'ProcessError') {
234
+ expect(result.left.command).toContain('devcontainer up');
235
+ expect(result.left.message).toContain('Container failed');
236
+ }
237
+ }
238
+ });
239
+ it('should return Effect that fails with ConfigError when preset not found', async () => {
240
+ // Setup mocks - both return null/undefined
241
+ vi.mocked(configurationManager.getPresetById).mockReturnValue(undefined);
242
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue(undefined);
243
+ // Mock exec to succeed (devcontainer up)
244
+ const { exec } = await import('child_process');
245
+ const mockExec = vi.mocked(exec);
246
+ mockExec.mockImplementation((cmd, options, callback) => {
247
+ if (typeof options === 'function') {
248
+ callback = options;
249
+ }
250
+ if (callback && typeof callback === 'function') {
251
+ callback(null, 'Container started', '');
252
+ }
253
+ return {};
254
+ });
255
+ const devcontainerConfig = {
256
+ upCommand: 'devcontainer up',
257
+ execCommand: 'devcontainer exec',
258
+ };
259
+ // Create session with invalid preset
260
+ const effect = sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig, 'invalid-preset');
261
+ // Execute the Effect and expect it to fail with ConfigError
262
+ const result = await Effect.runPromise(Effect.either(effect));
263
+ expect(Either.isLeft(result)).toBe(true);
264
+ if (Either.isLeft(result)) {
265
+ expect(result.left._tag).toBe('ConfigError');
266
+ }
267
+ });
268
+ });
269
+ describe('terminateSession returning Effect', () => {
270
+ it('should return Effect that succeeds when session exists', async () => {
271
+ // Setup mock preset and create a session first
272
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
273
+ id: '1',
274
+ name: 'Main',
275
+ command: 'claude',
276
+ });
277
+ vi.mocked(spawn).mockReturnValue(mockPty);
278
+ // Create session
279
+ await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
280
+ // Terminate session - should return Effect
281
+ const effect = sessionManager.terminateSessionEffect('/test/worktree');
282
+ // Execute the Effect and verify it succeeds
283
+ await Effect.runPromise(effect);
284
+ // Verify session was destroyed
285
+ expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
286
+ expect(mockPty.kill).toHaveBeenCalled();
287
+ });
288
+ it('should return Effect that fails with ProcessError when session does not exist', async () => {
289
+ // Terminate non-existent session - should return Effect
290
+ const effect = sessionManager.terminateSessionEffect('/nonexistent/worktree');
291
+ // Execute the Effect and expect it to fail with ProcessError
292
+ const result = await Effect.runPromise(Effect.either(effect));
293
+ expect(Either.isLeft(result)).toBe(true);
294
+ if (Either.isLeft(result)) {
295
+ expect(result.left._tag).toBe('ProcessError');
296
+ expect(result.left.message).toContain('Session not found');
297
+ }
298
+ });
299
+ it('should return Effect that succeeds even when process kill fails', async () => {
300
+ // Setup mock preset and create a session
301
+ vi.mocked(configurationManager.getDefaultPreset).mockReturnValue({
302
+ id: '1',
303
+ name: 'Main',
304
+ command: 'claude',
305
+ });
306
+ vi.mocked(spawn).mockReturnValue(mockPty);
307
+ // Create session
308
+ await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
309
+ // Mock kill to throw error
310
+ mockPty.kill.mockImplementation(() => {
311
+ throw new Error('Process already terminated');
312
+ });
313
+ // Terminate session - should still succeed
314
+ const effect = sessionManager.terminateSessionEffect('/test/worktree');
315
+ // Should not throw, gracefully handle kill failure
316
+ await Effect.runPromise(effect);
317
+ // Session should still be removed from map
318
+ expect(sessionManager.getSession('/test/worktree')).toBeUndefined();
319
+ });
320
+ });
321
+ });
@@ -7,6 +7,8 @@ import { configurationManager } from './configurationManager.js';
7
7
  import { executeStatusHook } from '../utils/hookExecutor.js';
8
8
  import { createStateDetector } from './stateDetector.js';
9
9
  import { STATE_PERSISTENCE_DURATION_MS, STATE_CHECK_INTERVAL_MS, } from '../constants/statePersistence.js';
10
+ import { Effect } from 'effect';
11
+ import { ProcessError, ConfigError } from '../types/errors.js';
10
12
  const { Terminal } = pkg;
11
13
  const execAsync = promisify(exec);
12
14
  export class SessionManager extends EventEmitter {
@@ -86,29 +88,78 @@ export class SessionManager extends EventEmitter {
86
88
  this.emit('sessionCreated', session);
87
89
  return session;
88
90
  }
89
- async createSessionWithPreset(worktreePath, presetId) {
90
- // Check if session already exists
91
- const existing = this.sessions.get(worktreePath);
92
- if (existing) {
93
- return existing;
94
- }
95
- // Get preset configuration
96
- let preset = presetId ? configurationManager.getPresetById(presetId) : null;
97
- if (!preset) {
98
- preset = configurationManager.getDefaultPreset();
99
- }
100
- const command = preset.command;
101
- const args = preset.args || [];
102
- const commandConfig = {
103
- command: preset.command,
104
- args: preset.args,
105
- fallbackArgs: preset.fallbackArgs,
106
- };
107
- // Spawn the process - fallback will be handled by setupExitHandler
108
- const ptyProcess = await this.spawn(command, args, worktreePath);
109
- return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
110
- isPrimaryCommand: true,
111
- detectionStrategy: preset.detectionStrategy,
91
+ /**
92
+ * Create session with command preset using Effect-based error handling
93
+ *
94
+ * @param {string} worktreePath - Path to the worktree
95
+ * @param {string} [presetId] - Optional preset ID, uses default if not provided
96
+ * @returns {Effect.Effect<Session, ProcessError | ConfigError, never>} Effect that may fail with ProcessError (spawn failure) or ConfigError (invalid preset)
97
+ *
98
+ * @example
99
+ * ```typescript
100
+ * // Use Effect.match for type-safe error handling
101
+ * const result = await Effect.runPromise(
102
+ * Effect.match(effect, {
103
+ * onFailure: (error) => ({ type: 'error', message: error.message }),
104
+ * onSuccess: (session) => ({ type: 'success', data: session })
105
+ * })
106
+ * );
107
+ * ```
108
+ */
109
+ createSessionWithPresetEffect(worktreePath, presetId) {
110
+ return Effect.tryPromise({
111
+ try: async () => {
112
+ // Check if session already exists
113
+ const existing = this.sessions.get(worktreePath);
114
+ if (existing) {
115
+ return existing;
116
+ }
117
+ // Get preset configuration
118
+ let preset = presetId
119
+ ? configurationManager.getPresetById(presetId)
120
+ : null;
121
+ if (!preset) {
122
+ preset = configurationManager.getDefaultPreset();
123
+ }
124
+ // Validate preset exists
125
+ if (!preset) {
126
+ throw new ConfigError({
127
+ configPath: 'configuration',
128
+ reason: 'validation',
129
+ details: presetId
130
+ ? `Preset with ID '${presetId}' not found and no default preset available`
131
+ : 'No default preset available',
132
+ });
133
+ }
134
+ const command = preset.command;
135
+ const args = preset.args || [];
136
+ const commandConfig = {
137
+ command: preset.command,
138
+ args: preset.args,
139
+ fallbackArgs: preset.fallbackArgs,
140
+ };
141
+ // Spawn the process - fallback will be handled by setupExitHandler
142
+ const ptyProcess = await this.spawn(command, args, worktreePath);
143
+ return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
144
+ isPrimaryCommand: true,
145
+ detectionStrategy: preset.detectionStrategy,
146
+ });
147
+ },
148
+ catch: (error) => {
149
+ // If it's already a ConfigError, return it
150
+ if (error instanceof ConfigError) {
151
+ return error;
152
+ }
153
+ // Otherwise, wrap in ProcessError
154
+ return new ProcessError({
155
+ command: presetId
156
+ ? `createSessionWithPreset (preset: ${presetId})`
157
+ : 'createSessionWithPreset (default preset)',
158
+ message: error instanceof Error
159
+ ? error.message
160
+ : 'Failed to create session with preset',
161
+ });
162
+ },
112
163
  });
113
164
  }
114
165
  setupDataHandler(session) {
@@ -213,8 +264,8 @@ export class SessionManager extends EventEmitter {
213
264
  session.state = detectedState;
214
265
  session.pendingState = undefined;
215
266
  session.pendingStateStart = undefined;
216
- // Execute status hook asynchronously (non-blocking)
217
- void executeStatusHook(oldState, detectedState, session);
267
+ // Execute status hook asynchronously (non-blocking) using Effect
268
+ void Effect.runPromise(executeStatusHook(oldState, detectedState, session));
218
269
  this.emit('sessionStateChanged', session);
219
270
  }
220
271
  }
@@ -280,49 +331,149 @@ export class SessionManager extends EventEmitter {
280
331
  this.emit('sessionDestroyed', session);
281
332
  }
282
333
  }
334
+ /**
335
+ * Terminate session and cleanup resources using Effect-based error handling
336
+ *
337
+ * @param {string} worktreePath - Path to the worktree
338
+ * @returns {Effect.Effect<void, ProcessError, never>} Effect that may fail with ProcessError if session does not exist or cleanup fails
339
+ *
340
+ * @example
341
+ * ```typescript
342
+ * // Terminate session with error handling
343
+ * const result = await Effect.runPromise(
344
+ * Effect.match(effect, {
345
+ * onFailure: (error) => ({ type: 'error', message: error.message }),
346
+ * onSuccess: () => ({ type: 'success' })
347
+ * })
348
+ * );
349
+ * ```
350
+ */
351
+ terminateSessionEffect(worktreePath) {
352
+ return Effect.try({
353
+ try: () => {
354
+ const session = this.sessions.get(worktreePath);
355
+ if (!session) {
356
+ throw new ProcessError({
357
+ command: 'terminateSession',
358
+ message: `Session not found for worktree: ${worktreePath}`,
359
+ });
360
+ }
361
+ // Clear the state check interval
362
+ if (session.stateCheckInterval) {
363
+ clearInterval(session.stateCheckInterval);
364
+ }
365
+ // Try to kill the process - don't fail if process is already dead
366
+ try {
367
+ session.process.kill();
368
+ }
369
+ catch (_error) {
370
+ // Process might already be dead, this is acceptable
371
+ }
372
+ // Clean up any pending timer
373
+ const timer = this.busyTimers.get(worktreePath);
374
+ if (timer) {
375
+ clearTimeout(timer);
376
+ this.busyTimers.delete(worktreePath);
377
+ }
378
+ // Remove from sessions map and cleanup
379
+ this.sessions.delete(worktreePath);
380
+ this.waitingWithBottomBorder.delete(session.id);
381
+ this.emit('sessionDestroyed', session);
382
+ },
383
+ catch: (error) => {
384
+ // If it's already a ProcessError, return it
385
+ if (error instanceof ProcessError) {
386
+ return error;
387
+ }
388
+ // Otherwise, wrap in ProcessError
389
+ return new ProcessError({
390
+ command: 'terminateSession',
391
+ message: error instanceof Error
392
+ ? error.message
393
+ : `Failed to terminate session for ${worktreePath}`,
394
+ });
395
+ },
396
+ });
397
+ }
283
398
  getAllSessions() {
284
399
  return Array.from(this.sessions.values());
285
400
  }
286
- async createSessionWithDevcontainer(worktreePath, devcontainerConfig, presetId) {
287
- // Check if session already exists
288
- const existing = this.sessions.get(worktreePath);
289
- if (existing) {
290
- return existing;
291
- }
292
- // Execute devcontainer up command first
293
- try {
294
- await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
295
- }
296
- catch (error) {
297
- throw new Error(`Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`);
298
- }
299
- // Get preset configuration
300
- let preset = presetId ? configurationManager.getPresetById(presetId) : null;
301
- if (!preset) {
302
- preset = configurationManager.getDefaultPreset();
303
- }
304
- // Parse the exec command to extract arguments
305
- const execParts = devcontainerConfig.execCommand.split(/\s+/);
306
- const devcontainerCmd = execParts[0] || 'devcontainer'; // Should be 'devcontainer'
307
- const execArgs = execParts.slice(1); // Rest of the exec command args
308
- // Build the full command: devcontainer exec [args] -- [preset command] [preset args]
309
- const fullArgs = [
310
- ...execArgs,
311
- '--',
312
- preset.command,
313
- ...(preset.args || []),
314
- ];
315
- // Spawn the process within devcontainer - fallback will be handled by setupExitHandler
316
- const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
317
- const commandConfig = {
318
- command: preset.command,
319
- args: preset.args,
320
- fallbackArgs: preset.fallbackArgs,
321
- };
322
- return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
323
- isPrimaryCommand: true,
324
- detectionStrategy: preset.detectionStrategy,
325
- devcontainerConfig,
401
+ /**
402
+ * Create session with devcontainer integration using Effect-based error handling
403
+ * @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
404
+ */
405
+ createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId) {
406
+ return Effect.tryPromise({
407
+ try: async () => {
408
+ // Check if session already exists
409
+ const existing = this.sessions.get(worktreePath);
410
+ if (existing) {
411
+ return existing;
412
+ }
413
+ // Execute devcontainer up command first
414
+ try {
415
+ await execAsync(devcontainerConfig.upCommand, { cwd: worktreePath });
416
+ }
417
+ catch (error) {
418
+ throw new ProcessError({
419
+ command: devcontainerConfig.upCommand,
420
+ message: `Failed to start devcontainer: ${error instanceof Error ? error.message : String(error)}`,
421
+ });
422
+ }
423
+ // Get preset configuration
424
+ let preset = presetId
425
+ ? configurationManager.getPresetById(presetId)
426
+ : null;
427
+ if (!preset) {
428
+ preset = configurationManager.getDefaultPreset();
429
+ }
430
+ // Validate preset exists
431
+ if (!preset) {
432
+ throw new ConfigError({
433
+ configPath: 'configuration',
434
+ reason: 'validation',
435
+ details: presetId
436
+ ? `Preset with ID '${presetId}' not found and no default preset available`
437
+ : 'No default preset available',
438
+ });
439
+ }
440
+ // Parse the exec command to extract arguments
441
+ const execParts = devcontainerConfig.execCommand.split(/\s+/);
442
+ const devcontainerCmd = execParts[0] || 'devcontainer';
443
+ const execArgs = execParts.slice(1);
444
+ // Build the full command: devcontainer exec [args] -- [preset command] [preset args]
445
+ const fullArgs = [
446
+ ...execArgs,
447
+ '--',
448
+ preset.command,
449
+ ...(preset.args || []),
450
+ ];
451
+ // Spawn the process within devcontainer
452
+ const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
453
+ const commandConfig = {
454
+ command: preset.command,
455
+ args: preset.args,
456
+ fallbackArgs: preset.fallbackArgs,
457
+ };
458
+ return this.createSessionInternal(worktreePath, ptyProcess, commandConfig, {
459
+ isPrimaryCommand: true,
460
+ detectionStrategy: preset.detectionStrategy,
461
+ devcontainerConfig,
462
+ });
463
+ },
464
+ catch: (error) => {
465
+ // If it's already a ConfigError or ProcessError, return it
466
+ if (error instanceof ConfigError || error instanceof ProcessError) {
467
+ return error;
468
+ }
469
+ // Otherwise, wrap in ProcessError
470
+ return new ProcessError({
471
+ command: `createSessionWithDevcontainer (${devcontainerConfig.execCommand})`,
472
+ message: error instanceof Error
473
+ ? error.message
474
+ : 'Failed to create session with devcontainer',
475
+ });
476
+ },
326
477
  });
327
478
  }
328
479
  destroy() {