ccmanager 4.1.10 → 4.1.12

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.
@@ -1,6 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useCallback } from 'react';
3
- import { useApp, Box, Text } from 'ink';
3
+ import { useApp, useInput, Box, Text } from 'ink';
4
4
  import { Effect } from 'effect';
5
5
  import Menu from './Menu.js';
6
6
  import Dashboard from './Dashboard.js';
@@ -31,6 +31,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
31
31
  const [worktreeService, setWorktreeService] = useState(() => new WorktreeService());
32
32
  const [activeSession, setActiveSession] = useState(null);
33
33
  const [error, setError] = useState(null);
34
+ const [worktreeHookError, setWorktreeHookError] = useState(null);
34
35
  const [menuKey, setMenuKey] = useState(0); // Force menu refresh
35
36
  const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
36
37
  const [renameTarget, setRenameTarget] = useState(null);
@@ -44,6 +45,26 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
44
45
  const [loadingContext, setLoadingContext] = useState({});
45
46
  // State for streaming devcontainer up logs
46
47
  const [devcontainerLogs, setDevcontainerLogs] = useState([]);
48
+ const [canReturnFromHookError, setCanReturnFromHookError] = useState(false);
49
+ useEffect(() => {
50
+ if (view !== 'worktree-hook-error') {
51
+ setCanReturnFromHookError(false);
52
+ return;
53
+ }
54
+ const timeout = setTimeout(() => {
55
+ setCanReturnFromHookError(true);
56
+ }, 100);
57
+ return () => {
58
+ clearTimeout(timeout);
59
+ };
60
+ }, [view]);
61
+ useInput(() => {
62
+ if (view !== 'worktree-hook-error' || !canReturnFromHookError) {
63
+ return;
64
+ }
65
+ setWorktreeHookError(null);
66
+ handleReturnToMenu();
67
+ });
47
68
  // Helper function to format error messages based on error type using _tag discrimination
48
69
  const formatErrorMessage = (error) => {
49
70
  switch (error._tag) {
@@ -59,6 +80,10 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
59
80
  return `Validation failed for ${error.field}: ${error.constraint}`;
60
81
  }
61
82
  };
83
+ const formatPostCreationHookWarning = (error) => `Post-creation hook failed: ${error.message}`;
84
+ const formatPreCreationHookError = (error) => error._tag === 'ProcessError'
85
+ ? `Pre-creation hook failed: ${error.message}`
86
+ : formatErrorMessage(error);
62
87
  // Helper function to create session with Effect-based error handling
63
88
  const createSessionWithEffect = useCallback(async (worktreePath, presetId, initialPrompt) => {
64
89
  setDevcontainerLogs([]);
@@ -218,6 +243,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
218
243
  // Helper function to handle worktree creation results
219
244
  const handleWorktreeCreationResult = (result, creationData) => {
220
245
  if (result.success) {
246
+ if (result.warning) {
247
+ setError(null);
248
+ setWorktreeHookError(result.warning);
249
+ setView('worktree-hook-error');
250
+ return;
251
+ }
221
252
  if (creationData.presetId && creationData.initialPrompt) {
222
253
  setPendingMenuSessionLaunch({
223
254
  worktree: {
@@ -381,7 +412,13 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
381
412
  // Transform Effect result to legacy format for handleWorktreeCreationResult
382
413
  if (result._tag === 'Left') {
383
414
  // Handle error using pattern matching on _tag
384
- const errorMessage = formatErrorMessage(result.left);
415
+ const errorMessage = formatPreCreationHookError(result.left);
416
+ if (result.left._tag === 'ProcessError') {
417
+ setError(null);
418
+ setWorktreeHookError(errorMessage);
419
+ setView('worktree-hook-error');
420
+ return;
421
+ }
385
422
  handleWorktreeCreationResult({ success: false, error: errorMessage }, {
386
423
  path: targetPath,
387
424
  branch,
@@ -396,8 +433,13 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
396
433
  }
397
434
  else {
398
435
  // Success case
399
- const createdWorktree = result.right;
400
- handleWorktreeCreationResult({ success: true }, {
436
+ const { worktree: createdWorktree, postCreationHookError } = result.right;
437
+ handleWorktreeCreationResult({
438
+ success: true,
439
+ warning: postCreationHookError
440
+ ? formatPostCreationHookWarning(postCreationHookError)
441
+ : undefined,
442
+ }, {
401
443
  path: createdWorktree.path,
402
444
  branch: createdWorktree.branch || branch,
403
445
  baseBranch: request.baseBranch,
@@ -432,14 +474,26 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
432
474
  creationData.copySessionData, creationData.copyClaudeDirectory)));
433
475
  if (result._tag === 'Left') {
434
476
  // Handle error using pattern matching on _tag
435
- const errorMessage = formatErrorMessage(result.left);
477
+ const errorMessage = formatPreCreationHookError(result.left);
478
+ if (result.left._tag === 'ProcessError') {
479
+ setError(null);
480
+ setWorktreeHookError(errorMessage);
481
+ setView('worktree-hook-error');
482
+ return;
483
+ }
436
484
  setError(errorMessage);
437
485
  setView('new-worktree');
438
486
  }
439
487
  else {
440
- handleWorktreeCreationResult({ success: true }, {
441
- path: creationData.path,
442
- branch: creationData.branch,
488
+ const { worktree: createdWorktree, postCreationHookError } = result.right;
489
+ handleWorktreeCreationResult({
490
+ success: true,
491
+ warning: postCreationHookError
492
+ ? formatPostCreationHookWarning(postCreationHookError)
493
+ : undefined,
494
+ }, {
495
+ path: createdWorktree.path,
496
+ branch: createdWorktree.branch || creationData.branch,
443
497
  baseBranch: selectedRemoteRef,
444
498
  copySessionData: creationData.copySessionData,
445
499
  copyClaudeDirectory: creationData.copyClaudeDirectory,
@@ -555,6 +609,9 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
555
609
  : 'Creating worktree...';
556
610
  return (_jsx(Box, { flexDirection: "column", children: _jsx(LoadingSpinner, { message: message, color: "cyan" }) }));
557
611
  }
612
+ if (view === 'worktree-hook-error') {
613
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: "Worktree hook error" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: worktreeHookError }) }), _jsx(Text, { dimColor: true, children: "Press any key to return to the menu" })] }));
614
+ }
558
615
  if (view === 'delete-worktree') {
559
616
  return (_jsxs(Box, { flexDirection: "column", children: [error && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", error] }) })), _jsx(DeleteWorktree, { projectPath: selectedProject?.path, onComplete: handleDeleteWorktrees, onCancel: handleCancelDeleteWorktree })] }));
560
617
  }
@@ -3,6 +3,7 @@ import { render } from 'ink-testing-library';
3
3
  import { beforeAll, beforeEach, afterEach, describe, expect, it, vi, } from 'vitest';
4
4
  import { Effect } from 'effect';
5
5
  import { ENV_VARS } from '../constants/env.js';
6
+ import { ProcessError } from '../types/errors.js';
6
7
  let App;
7
8
  let menuProps;
8
9
  let newWorktreeProps;
@@ -129,10 +130,12 @@ beforeEach(() => {
129
130
  createWorktreeEffectMock.mockReset();
130
131
  deleteWorktreeEffectMock.mockReset();
131
132
  createWorktreeEffectMock.mockImplementation((path, branch) => Effect.succeed({
132
- path,
133
- branch,
134
- isMainWorktree: false,
135
- hasSession: false,
133
+ worktree: {
134
+ path,
135
+ branch,
136
+ isMainWorktree: false,
137
+ hasSession: false,
138
+ },
136
139
  }));
137
140
  deleteWorktreeEffectMock.mockImplementation(() => Effect.succeed(undefined));
138
141
  sessionManagers.length = 0;
@@ -171,10 +174,12 @@ describe('App component loading state machine', () => {
171
174
  createWorktreeEffectMock.mockImplementation(() => Effect.tryPromise({
172
175
  try: () => new Promise(resolve => {
173
176
  resolveWorktree = () => resolve({
174
- path: '/tmp/test',
175
- branch: 'feature',
176
- isMainWorktree: false,
177
- hasSession: false,
177
+ worktree: {
178
+ path: '/tmp/test',
179
+ branch: 'feature',
180
+ isMainWorktree: false,
181
+ hasSession: false,
182
+ },
178
183
  });
179
184
  }),
180
185
  catch: (error) => error,
@@ -228,12 +233,147 @@ describe('App component loading state machine', () => {
228
233
  expect(sessionProps?.session).toEqual(mockSession);
229
234
  unmount();
230
235
  });
236
+ it('shows a hook error screen before returning to menu when post-creation hook fails after manual worktree creation', async () => {
237
+ createWorktreeEffectMock.mockImplementation((path, branch) => Effect.succeed({
238
+ worktree: {
239
+ path,
240
+ branch,
241
+ isMainWorktree: false,
242
+ hasSession: false,
243
+ },
244
+ postCreationHookError: new ProcessError({
245
+ command: 'exit 1',
246
+ exitCode: 1,
247
+ message: 'Hook exited with code 1',
248
+ }),
249
+ }));
250
+ const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
251
+ await waitForCondition(() => Boolean(menuProps));
252
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
253
+ await waitForCondition(() => Boolean(newWorktreeProps));
254
+ await Promise.resolve(newWorktreeProps.onComplete({
255
+ creationMode: 'manual',
256
+ path: '/tmp/test',
257
+ branch: 'feature',
258
+ baseBranch: 'main',
259
+ copySessionData: false,
260
+ copyClaudeDirectory: false,
261
+ }));
262
+ await waitForCondition(() => lastFrame()?.includes('Worktree hook error') ?? false);
263
+ expect(lastFrame()).toContain('Post-creation hook failed');
264
+ expect(lastFrame()).toContain('Hook exited with code 1');
265
+ expect(lastFrame()).toContain('Press any key to return to the menu');
266
+ await flush(120);
267
+ stdin.write('x');
268
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
269
+ unmount();
270
+ });
271
+ it('shows a hook error screen before returning to menu when pre-creation hook fails', async () => {
272
+ createWorktreeEffectMock.mockImplementation(() => Effect.fail(new ProcessError({
273
+ command: 'exit 1',
274
+ exitCode: 1,
275
+ message: 'Hook exited with code 1',
276
+ })));
277
+ const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
278
+ await waitForCondition(() => Boolean(menuProps));
279
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
280
+ await waitForCondition(() => Boolean(newWorktreeProps));
281
+ await Promise.resolve(newWorktreeProps.onComplete({
282
+ creationMode: 'manual',
283
+ path: '/tmp/test',
284
+ branch: 'feature',
285
+ baseBranch: 'main',
286
+ copySessionData: false,
287
+ copyClaudeDirectory: false,
288
+ }));
289
+ await waitForCondition(() => lastFrame()?.includes('Worktree hook error') ?? false);
290
+ expect(lastFrame()).toContain('Pre-creation hook failed');
291
+ expect(lastFrame()).toContain('Hook exited with code 1');
292
+ expect(lastFrame()).toContain('Press any key to return to the menu');
293
+ await flush(120);
294
+ stdin.write('x');
295
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
296
+ unmount();
297
+ });
298
+ it('ignores immediate input on the hook error screen so the error remains visible', async () => {
299
+ createWorktreeEffectMock.mockImplementation((path, branch) => Effect.succeed({
300
+ worktree: {
301
+ path,
302
+ branch,
303
+ isMainWorktree: false,
304
+ hasSession: false,
305
+ },
306
+ postCreationHookError: new ProcessError({
307
+ command: 'exit 1',
308
+ exitCode: 1,
309
+ message: 'Hook exited with code 1',
310
+ }),
311
+ }));
312
+ const { lastFrame, stdin, unmount } = render(_jsx(App, { version: "test" }));
313
+ await waitForCondition(() => Boolean(menuProps));
314
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
315
+ await waitForCondition(() => Boolean(newWorktreeProps));
316
+ await Promise.resolve(newWorktreeProps.onComplete({
317
+ creationMode: 'manual',
318
+ path: '/tmp/test',
319
+ branch: 'feature',
320
+ baseBranch: 'main',
321
+ copySessionData: false,
322
+ copyClaudeDirectory: false,
323
+ }));
324
+ await waitForCondition(() => lastFrame()?.includes('Worktree hook error') ?? false);
325
+ stdin.write('x');
326
+ await flush(40);
327
+ expect(lastFrame()).toContain('Worktree hook error');
328
+ expect(lastFrame()).toContain('Hook exited with code 1');
329
+ await flush(120);
330
+ stdin.write('x');
331
+ await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
332
+ unmount();
333
+ });
334
+ it('does not auto-start a prompt-first session when post-creation hook fails', async () => {
335
+ createWorktreeEffectMock.mockImplementation((path, branch) => Effect.succeed({
336
+ worktree: {
337
+ path,
338
+ branch,
339
+ isMainWorktree: false,
340
+ hasSession: false,
341
+ },
342
+ postCreationHookError: new ProcessError({
343
+ command: 'exit 1',
344
+ exitCode: 1,
345
+ message: 'Hook exited with code 1',
346
+ }),
347
+ }));
348
+ const { lastFrame, unmount } = render(_jsx(App, { version: "test" }));
349
+ await waitForCondition(() => Boolean(menuProps));
350
+ const sessionManager = sessionManagers[0];
351
+ await Promise.resolve(menuProps.onMenuAction({ type: 'newWorktree' }));
352
+ await waitForCondition(() => Boolean(newWorktreeProps));
353
+ await Promise.resolve(newWorktreeProps.onComplete({
354
+ creationMode: 'prompt',
355
+ path: '/tmp/project',
356
+ projectPath: '/tmp/project',
357
+ autoDirectoryPattern: '../{branch}',
358
+ baseBranch: 'main',
359
+ presetId: 'claude',
360
+ initialPrompt: 'trim worktree name output',
361
+ copySessionData: false,
362
+ copyClaudeDirectory: false,
363
+ }));
364
+ await waitForCondition(() => lastFrame()?.includes('Worktree hook error') ?? false);
365
+ expect(sessionManager.createSessionWithPresetEffect).not.toHaveBeenCalled();
366
+ expect(lastFrame()).toContain('Post-creation hook failed');
367
+ unmount();
368
+ });
231
369
  it('uses the created worktree path when auto-starting a prompt-first session', async () => {
232
370
  createWorktreeEffectMock.mockImplementation((_path, branch) => Effect.succeed({
233
- path: '/tmp/resolved-worktree',
234
- branch,
235
- isMainWorktree: false,
236
- hasSession: false,
371
+ worktree: {
372
+ path: '/tmp/resolved-worktree',
373
+ branch,
374
+ isMainWorktree: false,
375
+ hasSession: false,
376
+ },
237
377
  }));
238
378
  const { unmount } = render(_jsx(App, { version: "test" }));
239
379
  await waitForCondition(() => Boolean(menuProps));
@@ -18,8 +18,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
18
18
  private autoApprovalDisabledWorktrees;
19
19
  private restoringSessions;
20
20
  private bufferedRestoreData;
21
- private restoreRefreshTimers;
22
- private restoreRefreshDeadlines;
23
21
  private spawn;
24
22
  private resolvePreset;
25
23
  detectTerminalState(session: Session): SessionState;
@@ -41,10 +39,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
41
39
  private createTerminal;
42
40
  private shouldResetRestoreScrollback;
43
41
  private getRestoreSnapshot;
44
- private scheduleRestoreRefresh;
45
- private armRestoreRefreshTimer;
46
- private cancelRestoreRefresh;
47
- private fireRestoreRefresh;
48
42
  private createSessionInternal;
49
43
  /**
50
44
  * Create session with command preset using Effect-based error handling
@@ -21,15 +21,6 @@ const { Terminal } = pkg;
21
21
  const TERMINAL_CONTENT_MAX_LINES = 300;
22
22
  const TERMINAL_SCROLLBACK_LINES = 5000;
23
23
  const TERMINAL_RESTORE_SCROLLBACK_LINES = 200;
24
- // Claude Code's Ink-based renderer sometimes splits a single UI redraw across
25
- // multiple PTY writes with short time gaps. If we snapshot between chunks, the
26
- // resulting viewport can miss rows (e.g. empty middle area while the top/bottom
27
- // chrome already rendered). Re-emit the snapshot after the PTY output has been
28
- // quiet for this long so late chunks are accounted for.
29
- const RESTORE_REFRESH_QUIET_MS = 120;
30
- // Cap on how long we wait for quiet before forcing the refresh, so continuous
31
- // streaming output (e.g. a long busy turn) still produces an updated snapshot.
32
- const RESTORE_REFRESH_MAX_WAIT_MS = 400;
33
24
  export class SessionManager extends EventEmitter {
34
25
  sessions;
35
26
  waitingWithBottomBorder = new Map();
@@ -37,8 +28,6 @@ export class SessionManager extends EventEmitter {
37
28
  autoApprovalDisabledWorktrees = new Set();
38
29
  restoringSessions = new Set();
39
30
  bufferedRestoreData = new Map();
40
- restoreRefreshTimers = new Map();
41
- restoreRefreshDeadlines = new Map();
42
31
  async spawn(command, args, worktreePath, options = {}) {
43
32
  const spawnOptions = {
44
33
  name: 'xterm-256color',
@@ -216,7 +205,7 @@ export class SessionManager extends EventEmitter {
216
205
  data.includes('\x1b[3J') ||
217
206
  data.includes('\x1bc'));
218
207
  }
219
- getRestoreSnapshot(session, options = {}) {
208
+ getRestoreSnapshot(session) {
220
209
  const activeBuffer = session.terminal.buffer.active;
221
210
  if (activeBuffer.type !== 'normal') {
222
211
  return session.serializer.serialize({
@@ -228,22 +217,6 @@ export class SessionManager extends EventEmitter {
228
217
  if (bufferLength === 0) {
229
218
  return '';
230
219
  }
231
- // While the session is busy, cursor-addressed status-box redraws can push
232
- // stale frames into scrollback (e.g. Claude's spinner + token stats line).
233
- // Those ghost rows render as duplicated status bars when replayed, so
234
- // restore only the viewport during busy state. Refresh re-emits also
235
- // bypass scrollback to avoid duplicating history into real-terminal
236
- // scrollback on top of the initial emit.
237
- const isBusy = session.stateMutex.getSnapshot().state === 'busy';
238
- if (options.viewportOnly || isBusy) {
239
- const snapshot = session.serializer.serialize({
240
- scrollback: 0,
241
- excludeAltBuffer: true,
242
- });
243
- const cursorRow = normalBuffer.cursorY + 1;
244
- const cursorCol = normalBuffer.cursorX + 1;
245
- return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
246
- }
247
220
  const scrollbackStart = Math.max(0, normalBuffer.baseY - TERMINAL_RESTORE_SCROLLBACK_LINES);
248
221
  const rangeStart = Math.max(session.restoreScrollbackBaseLine, scrollbackStart);
249
222
  const rangeEnd = bufferLength - 1;
@@ -258,56 +231,6 @@ export class SessionManager extends EventEmitter {
258
231
  const cursorCol = normalBuffer.cursorX + 1;
259
232
  return `${snapshot}\x1b[${cursorRow};${cursorCol}H`;
260
233
  }
261
- scheduleRestoreRefresh(session) {
262
- this.restoreRefreshDeadlines.set(session.id, Date.now() + RESTORE_REFRESH_MAX_WAIT_MS);
263
- this.armRestoreRefreshTimer(session);
264
- }
265
- armRestoreRefreshTimer(session) {
266
- const deadline = this.restoreRefreshDeadlines.get(session.id);
267
- if (deadline === undefined) {
268
- return;
269
- }
270
- const existing = this.restoreRefreshTimers.get(session.id);
271
- if (existing !== undefined) {
272
- clearTimeout(existing);
273
- this.restoreRefreshTimers.delete(session.id);
274
- }
275
- const remaining = deadline - Date.now();
276
- if (remaining <= 0) {
277
- this.fireRestoreRefresh(session);
278
- return;
279
- }
280
- const delay = Math.min(RESTORE_REFRESH_QUIET_MS, remaining);
281
- const timer = setTimeout(() => this.fireRestoreRefresh(session), delay);
282
- this.restoreRefreshTimers.set(session.id, timer);
283
- }
284
- cancelRestoreRefresh(session) {
285
- const existing = this.restoreRefreshTimers.get(session.id);
286
- if (existing !== undefined) {
287
- clearTimeout(existing);
288
- this.restoreRefreshTimers.delete(session.id);
289
- }
290
- this.restoreRefreshDeadlines.delete(session.id);
291
- }
292
- fireRestoreRefresh(session) {
293
- this.restoreRefreshTimers.delete(session.id);
294
- this.restoreRefreshDeadlines.delete(session.id);
295
- if (!session.isActive) {
296
- return;
297
- }
298
- const snapshot = this.getRestoreSnapshot(session, { viewportOnly: true });
299
- if (snapshot.length > 0) {
300
- // \x1b[2J: clear the viewport before repainting — without this, a
301
- // refresh snapshot shorter than the already-displayed content leaves
302
- // a "ghost tail" of pre-refresh rows at the bottom.
303
- // \x1b[?7h / \x1b[?7l: Session.tsx disables auto-wrap (DECAWM) after
304
- // the initial restore, but SerializeAddon omits row separators for
305
- // wrapped lines and relies on DECAWM to advance to the next row
306
- // (see PR #276). Re-enable auto-wrap just for the snapshot write so
307
- // wrapped viewport rows don't overlap, then restore the TUI default.
308
- this.emit('sessionRestore', session, `\x1b[?7h\x1b[2J\x1b[H${snapshot}\x1b[?7l`);
309
- }
310
- }
311
234
  async createSessionInternal(worktreePath, ptyProcess, options = {}) {
312
235
  const existingSessions = this.getSessionsForWorktree(worktreePath);
313
236
  const maxNumber = existingSessions.reduce((max, s) => Math.max(max, s.sessionNumber), 0);
@@ -411,12 +334,6 @@ export class SessionManager extends EventEmitter {
411
334
  session.terminal.buffer.normal.baseY;
412
335
  }
413
336
  session.lastActivity = new Date();
414
- // If a restore-refresh is pending, each incoming chunk resets the
415
- // quiet timer so the follow-up snapshot only fires after Claude's
416
- // multi-chunk redraw has settled.
417
- if (this.restoreRefreshDeadlines.has(session.id)) {
418
- this.armRestoreRefreshTimer(session);
419
- }
420
337
  // Only emit data events when session is active
421
338
  if (session.isActive) {
422
339
  if (this.restoringSessions.has(session.id)) {
@@ -576,12 +493,10 @@ export class SessionManager extends EventEmitter {
576
493
  }
577
494
  }
578
495
  }
579
- this.scheduleRestoreRefresh(session);
580
496
  }
581
497
  else {
582
498
  this.restoringSessions.delete(session.id);
583
499
  this.bufferedRestoreData.delete(session.id);
584
- this.cancelRestoreRefresh(session);
585
500
  }
586
501
  }
587
502
  }
@@ -656,7 +571,6 @@ export class SessionManager extends EventEmitter {
656
571
  this.waitingWithBottomBorder.delete(sessionId);
657
572
  this.restoringSessions.delete(sessionId);
658
573
  this.bufferedRestoreData.delete(sessionId);
659
- this.cancelRestoreRefresh(session);
660
574
  this.emit('sessionDestroyed', session);
661
575
  }
662
576
  }
@@ -710,7 +624,6 @@ export class SessionManager extends EventEmitter {
710
624
  }
711
625
  this.sessions.delete(sessionId);
712
626
  this.waitingWithBottomBorder.delete(sessionId);
713
- this.cancelRestoreRefresh(session);
714
627
  this.emit('sessionDestroyed', session);
715
628
  },
716
629
  catch: (error) => {
@@ -782,7 +782,6 @@ describe('SessionManager', () => {
782
782
  });
783
783
  vi.mocked(spawn).mockReturnValue(mockPty);
784
784
  const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
785
- await session.stateMutex.update(data => ({ ...data, state: 'idle' }));
786
785
  const normalBuffer = session.terminal.buffer.normal;
787
786
  normalBuffer.baseY = 260;
788
787
  normalBuffer.length = 300;
@@ -804,32 +803,6 @@ describe('SessionManager', () => {
804
803
  });
805
804
  expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mrestored\u001b[0m\u001b[8;12H');
806
805
  });
807
- it('should emit a viewport-only restore snapshot while session is busy', async () => {
808
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
809
- id: '1',
810
- name: 'Main',
811
- command: 'claude',
812
- });
813
- vi.mocked(spawn).mockReturnValue(mockPty);
814
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
815
- const normalBuffer = session.terminal.buffer.normal;
816
- normalBuffer.baseY = 260;
817
- normalBuffer.length = 300;
818
- normalBuffer.cursorY = 7;
819
- normalBuffer.cursorX = 11;
820
- session.restoreScrollbackBaseLine = 120;
821
- const serializeMock = vi
822
- .spyOn(session.serializer, 'serialize')
823
- .mockReturnValue('\u001b[31mbusy-viewport\u001b[0m');
824
- const restoreHandler = vi.fn();
825
- sessionManager.on('sessionRestore', restoreHandler);
826
- sessionManager.setSessionActive(session.id, true);
827
- expect(serializeMock).toHaveBeenCalledWith({
828
- scrollback: 0,
829
- excludeAltBuffer: true,
830
- });
831
- expect(restoreHandler).toHaveBeenCalledWith(session, '\u001b[31mbusy-viewport\u001b[0m\u001b[8;12H');
832
- });
833
806
  it('should keep viewport-only restore behavior for alternate screen sessions', async () => {
834
807
  vi.mocked(configReader.getDefaultPreset).mockReturnValue({
835
808
  id: '1',
@@ -899,90 +872,6 @@ describe('SessionManager', () => {
899
872
  sessionManager.setSessionActive(session.id, true);
900
873
  expect(eventOrder).toEqual(['restore', 'data']);
901
874
  });
902
- it('should re-emit a viewport-only snapshot after the PTY quiet period', async () => {
903
- vi.useFakeTimers();
904
- try {
905
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
906
- id: '1',
907
- name: 'Main',
908
- command: 'claude',
909
- });
910
- vi.mocked(spawn).mockReturnValue(mockPty);
911
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
912
- session.terminal.buffer.normal.length = 1;
913
- const serializeMock = vi
914
- .spyOn(session.serializer, 'serialize')
915
- .mockReturnValue('snap');
916
- const restoreHandler = vi.fn();
917
- sessionManager.on('sessionRestore', restoreHandler);
918
- sessionManager.setSessionActive(session.id, true);
919
- expect(restoreHandler).toHaveBeenCalledTimes(1);
920
- await vi.advanceTimersByTimeAsync(50);
921
- mockPty.emit('data', 'late-chunk');
922
- await vi.advanceTimersByTimeAsync(50);
923
- expect(restoreHandler).toHaveBeenCalledTimes(1);
924
- await vi.advanceTimersByTimeAsync(120);
925
- expect(restoreHandler).toHaveBeenCalledTimes(2);
926
- expect(restoreHandler.mock.calls[1]?.[1]).toMatch(/^\u001b\[\?7h\u001b\[2J\u001b\[Hsnap.*\u001b\[\?7l$/);
927
- expect(serializeMock).toHaveBeenLastCalledWith({
928
- scrollback: 0,
929
- excludeAltBuffer: true,
930
- });
931
- }
932
- finally {
933
- vi.useRealTimers();
934
- }
935
- });
936
- it('should force a refresh at the max-wait deadline even while data keeps streaming', async () => {
937
- vi.useFakeTimers();
938
- try {
939
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
940
- id: '1',
941
- name: 'Main',
942
- command: 'claude',
943
- });
944
- vi.mocked(spawn).mockReturnValue(mockPty);
945
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
946
- session.terminal.buffer.normal.length = 1;
947
- vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
948
- const restoreHandler = vi.fn();
949
- sessionManager.on('sessionRestore', restoreHandler);
950
- sessionManager.setSessionActive(session.id, true);
951
- expect(restoreHandler).toHaveBeenCalledTimes(1);
952
- for (let elapsed = 0; elapsed < 400; elapsed += 50) {
953
- await vi.advanceTimersByTimeAsync(50);
954
- mockPty.emit('data', 'chunk');
955
- }
956
- expect(restoreHandler).toHaveBeenCalledTimes(2);
957
- }
958
- finally {
959
- vi.useRealTimers();
960
- }
961
- });
962
- it('should cancel a scheduled refresh when the session is deactivated', async () => {
963
- vi.useFakeTimers();
964
- try {
965
- vi.mocked(configReader.getDefaultPreset).mockReturnValue({
966
- id: '1',
967
- name: 'Main',
968
- command: 'claude',
969
- });
970
- vi.mocked(spawn).mockReturnValue(mockPty);
971
- const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree'));
972
- session.terminal.buffer.normal.length = 1;
973
- vi.spyOn(session.serializer, 'serialize').mockReturnValue('snap');
974
- const restoreHandler = vi.fn();
975
- sessionManager.on('sessionRestore', restoreHandler);
976
- sessionManager.setSessionActive(session.id, true);
977
- expect(restoreHandler).toHaveBeenCalledTimes(1);
978
- sessionManager.setSessionActive(session.id, false);
979
- await vi.advanceTimersByTimeAsync(500);
980
- expect(restoreHandler).toHaveBeenCalledTimes(1);
981
- }
982
- finally {
983
- vi.useRealTimers();
984
- }
985
- });
986
875
  });
987
876
  describe('static methods', () => {
988
877
  describe('getSessionCounts', () => {
@@ -15,7 +15,8 @@ export class CodexStateDetector extends BaseStateDetector {
15
15
  // Check for waiting prompts
16
16
  if (lowerContent.includes('allow command?') ||
17
17
  lowerContent.includes('[y/n]') ||
18
- lowerContent.includes('yes (y)')) {
18
+ lowerContent.includes('yes (y)') ||
19
+ lowerContent.includes('enter to submit')) {
19
20
  return 'waiting_input';
20
21
  }
21
22
  if (/(do you want|would you like)[\s\S]*?\n+[\s\S]*?\byes\b/.test(lowerContent)) {
@@ -171,4 +171,24 @@ describe('CodexStateDetector', () => {
171
171
  // Assert
172
172
  expect(state).toBe('waiting_input');
173
173
  });
174
+ it('should detect waiting_input for MCP tool permission prompt with "enter to submit"', () => {
175
+ // Arrange
176
+ terminal = createMockTerminal([
177
+ 'Field 1/1',
178
+ 'Allow the chrome-devtools MCP server to run tool "new_page"?',
179
+ '',
180
+ 'timeout: 10000',
181
+ 'url: http://localhost:4000/scenarios',
182
+ '',
183
+ '› 1. Allow Run the tool and continue.',
184
+ '2. Allow for this session Run the tool and remember this choice for this session.',
185
+ '3. Always allow Run the tool and remember this choice for future tool calls.',
186
+ '4. Cancel Cancel this tool call',
187
+ 'enter to submit | esc to cancel',
188
+ ]);
189
+ // Act
190
+ const state = detector.detectState(terminal, 'idle');
191
+ // Assert
192
+ expect(state).toBe('waiting_input');
193
+ });
174
194
  });
@@ -1,5 +1,5 @@
1
1
  import { Effect } from 'effect';
2
- import { Worktree, MergeConfig } from '../types/index.js';
2
+ import { Worktree, CreateWorktreeResult, MergeConfig } from '../types/index.js';
3
3
  import { GitError, FileSystemError, ProcessError } from '../types/errors.js';
4
4
  /**
5
5
  * WorktreeService - Git worktree management with Effect-based error handling
@@ -323,7 +323,7 @@ export declare class WorktreeService {
323
323
  * @throws {GitError} When git worktree add command fails
324
324
  * @throws {FileSystemError} When session data copy fails
325
325
  */
326
- createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<Worktree, GitError | FileSystemError | ProcessError, never>;
326
+ createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<CreateWorktreeResult, GitError | FileSystemError | ProcessError, never>;
327
327
  /**
328
328
  * Effect-based deleteWorktree operation
329
329
  * May fail with GitError
@@ -8,6 +8,7 @@ import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
8
8
  import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
9
9
  import { executeWorktreePostCreationHook, executeWorktreePreCreationHook, } from '../utils/hookExecutor.js';
10
10
  import { configReader } from './config/configReader.js';
11
+ import { logger } from '../utils/logger.js';
11
12
  const CLAUDE_DIR = '.claude';
12
13
  /**
13
14
  * WorktreeService - Git worktree management with Effect-based error handling
@@ -764,6 +765,16 @@ export class WorktreeService {
764
765
  const resolvedPath = path.isAbsolute(worktreePath)
765
766
  ? worktreePath
766
767
  : path.join(absoluteGitRoot, worktreePath);
768
+ logger.info('Worktree creation requested', {
769
+ inputPath: worktreePath,
770
+ resolvedPath,
771
+ branch,
772
+ baseBranch,
773
+ rootPath: self.rootPath,
774
+ absoluteGitRoot,
775
+ copySessionData,
776
+ copyClaudeDirectory,
777
+ });
767
778
  // Check if branch exists
768
779
  const branchExists = yield* Effect.catchAll(Effect.try({
769
780
  try: () => {
@@ -777,6 +788,12 @@ export class WorktreeService {
777
788
  }), () => Effect.succeed(false));
778
789
  // Execute pre-creation hook if configured (BEFORE git worktree add)
779
790
  const worktreeHooksConfig = configReader.getWorktreeHooks();
791
+ logger.info('Worktree hook config before creation', {
792
+ preCreationEnabled: Boolean(worktreeHooksConfig.pre_creation?.enabled),
793
+ preCreationCommand: worktreeHooksConfig.pre_creation?.command,
794
+ postCreationEnabled: Boolean(worktreeHooksConfig.post_creation?.enabled),
795
+ postCreationCommand: worktreeHooksConfig.post_creation?.command,
796
+ });
780
797
  if (worktreeHooksConfig.pre_creation?.enabled &&
781
798
  worktreeHooksConfig.pre_creation?.command) {
782
799
  yield* executeWorktreePreCreationHook(worktreeHooksConfig.pre_creation.command, resolvedPath, branch, absoluteGitRoot, baseBranch);
@@ -794,10 +811,18 @@ export class WorktreeService {
794
811
  // Execute the worktree creation command
795
812
  yield* Effect.try({
796
813
  try: () => {
814
+ logger.info('Executing git worktree add command', {
815
+ command,
816
+ cwd: absoluteGitRoot,
817
+ });
797
818
  execSync(command, {
798
819
  cwd: absoluteGitRoot,
799
820
  encoding: 'utf8',
800
821
  });
822
+ logger.info('Git worktree add command succeeded', {
823
+ command,
824
+ cwd: absoluteGitRoot,
825
+ });
801
826
  },
802
827
  catch: (error) => {
803
828
  const execError = error;
@@ -835,22 +860,31 @@ export class WorktreeService {
835
860
  }
836
861
  // Execute post-creation hook if configured
837
862
  const worktreeHooks = configReader.getWorktreeHooks();
838
- if (worktreeHooks.post_creation?.enabled &&
839
- worktreeHooks.post_creation?.command) {
840
- const newWorktree = {
841
- path: resolvedPath,
842
- branch: branch,
843
- isMainWorktree: false,
844
- hasSession: false,
845
- };
846
- yield* executeWorktreePostCreationHook(worktreeHooks.post_creation.command, newWorktree, absoluteGitRoot, baseBranch);
847
- }
848
- return {
863
+ logger.info('Worktree hook config after creation', {
864
+ postCreationEnabled: Boolean(worktreeHooks.post_creation?.enabled),
865
+ postCreationCommand: worktreeHooks.post_creation?.command,
866
+ resolvedPath,
867
+ branch,
868
+ });
869
+ let postCreationHookError;
870
+ const createdWorktree = {
849
871
  path: resolvedPath,
850
872
  branch,
851
873
  isMainWorktree: false,
852
874
  hasSession: false,
853
875
  };
876
+ if (worktreeHooks.post_creation?.enabled &&
877
+ worktreeHooks.post_creation?.command) {
878
+ const postCreationHookResult = yield* Effect.either(executeWorktreePostCreationHook(worktreeHooks.post_creation.command, createdWorktree, absoluteGitRoot, baseBranch));
879
+ if (postCreationHookResult._tag === 'Left') {
880
+ postCreationHookError = postCreationHookResult.left;
881
+ logger.error(`Failed to execute post-creation hook: ${postCreationHookError.message}`);
882
+ }
883
+ }
884
+ return {
885
+ worktree: createdWorktree,
886
+ postCreationHookError,
887
+ };
854
888
  });
855
889
  }
856
890
  /**
@@ -4,7 +4,7 @@ import { execSync } from 'child_process';
4
4
  import { existsSync, statSync } from 'fs';
5
5
  import { configReader } from './config/configReader.js';
6
6
  import { Effect } from 'effect';
7
- import { GitError } from '../types/errors.js';
7
+ import { GitError, ProcessError } from '../types/errors.js';
8
8
  // Mock child_process module
9
9
  vi.mock('child_process');
10
10
  // Mock fs module
@@ -25,6 +25,7 @@ vi.mock('./config/configReader.js', () => ({
25
25
  }));
26
26
  // Mock HookExecutor
27
27
  vi.mock('../utils/hookExecutor.js', () => ({
28
+ executeWorktreePreCreationHook: vi.fn(),
28
29
  executeWorktreePostCreationHook: vi.fn(),
29
30
  }));
30
31
  // Get the mocked function with proper typing
@@ -641,7 +642,7 @@ branch refs/heads/feature
641
642
  });
642
643
  const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main');
643
644
  const result = await Effect.runPromise(effect);
644
- expect(result).toMatchObject({
645
+ expect(result.worktree).toMatchObject({
645
646
  path: '/path/to/worktree',
646
647
  branch: 'new-feature',
647
648
  isMainWorktree: false,
@@ -676,6 +677,77 @@ branch refs/heads/feature
676
677
  expect.fail('Should have returned Left with GitError');
677
678
  }
678
679
  });
680
+ it('should fail with ProcessError and skip git worktree add when pre-creation hook fails', async () => {
681
+ const { executeWorktreePreCreationHook } = await import('../utils/hookExecutor.js');
682
+ const mockedPreHook = vi.mocked(executeWorktreePreCreationHook);
683
+ mockedGetWorktreeHooks.mockReturnValue({
684
+ pre_creation: {
685
+ command: 'exit 1',
686
+ enabled: true,
687
+ },
688
+ });
689
+ mockedPreHook.mockReturnValue(Effect.fail(new ProcessError({
690
+ command: 'exit 1',
691
+ exitCode: 1,
692
+ message: 'Hook exited with code 1',
693
+ })));
694
+ mockedExecSync.mockImplementation((cmd, _options) => {
695
+ if (typeof cmd === 'string') {
696
+ if (cmd === 'git rev-parse --git-common-dir') {
697
+ return '/fake/path/.git\n';
698
+ }
699
+ if (cmd.includes('rev-parse --verify')) {
700
+ throw new Error('Branch not found');
701
+ }
702
+ if (cmd.includes('git worktree add')) {
703
+ throw new Error('git worktree add should not be called');
704
+ }
705
+ }
706
+ return '';
707
+ });
708
+ const result = await Effect.runPromise(Effect.either(service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main')));
709
+ expect(result._tag).toBe('Left');
710
+ if (result._tag === 'Left') {
711
+ expect(result.left).toBeInstanceOf(ProcessError);
712
+ }
713
+ expect(mockedExecSync).not.toHaveBeenCalledWith(expect.stringContaining('git worktree add'), expect.anything());
714
+ });
715
+ it('should return Worktree with postCreationHookError when post-creation hook fails', async () => {
716
+ const { executeWorktreePostCreationHook } = await import('../utils/hookExecutor.js');
717
+ const mockedPostHook = vi.mocked(executeWorktreePostCreationHook);
718
+ const hookError = new ProcessError({
719
+ command: 'exit 1',
720
+ exitCode: 1,
721
+ message: 'Hook exited with code 1',
722
+ });
723
+ mockedGetWorktreeHooks.mockReturnValue({
724
+ post_creation: {
725
+ command: 'exit 1',
726
+ enabled: true,
727
+ },
728
+ });
729
+ mockedPostHook.mockReturnValue(Effect.fail(hookError));
730
+ mockedExecSync.mockImplementation((cmd, _options) => {
731
+ if (typeof cmd === 'string') {
732
+ if (cmd === 'git rev-parse --git-common-dir') {
733
+ return '/fake/path/.git\n';
734
+ }
735
+ if (cmd.includes('rev-parse --verify')) {
736
+ throw new Error('Branch not found');
737
+ }
738
+ if (cmd.includes('git worktree add')) {
739
+ return '';
740
+ }
741
+ }
742
+ return '';
743
+ });
744
+ const result = await Effect.runPromise(service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main'));
745
+ expect(result.worktree).toMatchObject({
746
+ path: '/path/to/worktree',
747
+ branch: 'new-feature',
748
+ });
749
+ expect(result.postCreationHookError).toBe(hookError);
750
+ });
679
751
  });
680
752
  describe('Effect-based deleteWorktree', () => {
681
753
  it('should return Effect with void on success', async () => {
@@ -51,6 +51,8 @@ export declare class ProcessError extends ProcessError_base<{
51
51
  readonly signal?: string;
52
52
  readonly exitCode?: number;
53
53
  readonly message: string;
54
+ readonly stdout?: string;
55
+ readonly stderr?: string;
54
56
  }> {
55
57
  }
56
58
  declare const ValidationError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => import("effect/Cause").YieldableError & {
@@ -4,6 +4,7 @@ import type { SerializeAddon } from '@xterm/addon-serialize';
4
4
  import { GitStatus } from '../utils/gitStatus.js';
5
5
  import { Mutex, SessionStateData } from '../utils/mutex.js';
6
6
  import type { StateDetector } from '../services/stateDetector/types.js';
7
+ import type { ProcessError } from './errors.js';
7
8
  export type Terminal = InstanceType<typeof pkg.Terminal>;
8
9
  export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'pending_auto_approval';
9
10
  export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor' | 'github-copilot' | 'cline' | 'opencode' | 'kimi';
@@ -16,6 +17,10 @@ export interface Worktree {
16
17
  gitStatusError?: string;
17
18
  lastCommitDate?: Date;
18
19
  }
20
+ export interface CreateWorktreeResult {
21
+ worktree: Worktree;
22
+ postCreationHookError?: ProcessError;
23
+ }
19
24
  export interface Session {
20
25
  id: string;
21
26
  worktreePath: string;
@@ -251,7 +256,7 @@ export declare class AmbiguousBranchError extends Error {
251
256
  export interface IWorktreeService {
252
257
  getWorktreesEffect(): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
253
258
  getGitRootPath(): string;
254
- createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<Worktree, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError | import('../types/errors.js').ProcessError, never>;
259
+ createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<CreateWorktreeResult, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError | import('../types/errors.js').ProcessError, never>;
255
260
  deleteWorktreeEffect(worktreePath: string, options?: {
256
261
  deleteBranch?: boolean;
257
262
  }): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
@@ -59,7 +59,7 @@ export declare function executeWorktreePreCreationHook(command: string, worktree
59
59
  * Execute a worktree post-creation hook using Effect
60
60
  * Errors are caught and logged but do not break the main flow
61
61
  */
62
- export declare function executeWorktreePostCreationHook(command: string, worktree: Worktree, gitRoot: string, baseBranch?: string): Effect.Effect<void, never>;
62
+ export declare function executeWorktreePostCreationHook(command: string, worktree: Worktree, gitRoot: string, baseBranch?: string): Effect.Effect<void, ProcessError>;
63
63
  /**
64
64
  * Execute a session status change hook using Effect
65
65
  * Errors are caught and logged but do not break the main flow
@@ -4,6 +4,7 @@ import { Effect } from 'effect';
4
4
  import { ProcessError } from '../types/errors.js';
5
5
  import { WorktreeService } from '../services/worktreeService.js';
6
6
  import { configReader } from '../services/config/configReader.js';
7
+ import { logger } from './logger.js';
7
8
  /**
8
9
  * Execute a hook command with the provided environment variables using Effect
9
10
  *
@@ -40,6 +41,11 @@ import { configReader } from '../services/config/configReader.js';
40
41
  */
41
42
  export function executeHook(command, cwd, environment) {
42
43
  return Effect.async(resume => {
44
+ logger.info('Hook execution starting', {
45
+ command,
46
+ cwd,
47
+ environment,
48
+ });
43
49
  // Use spawn with shell to execute the command and wait for all child processes
44
50
  const child = spawn(command, [], {
45
51
  cwd,
@@ -50,24 +56,44 @@ export function executeHook(command, cwd, environment) {
50
56
  shell: true,
51
57
  stdio: ['ignore', 'pipe', 'pipe'],
52
58
  });
59
+ let stdout = '';
53
60
  let stderr = '';
61
+ child.stdout?.on('data', data => {
62
+ stdout += data.toString();
63
+ });
54
64
  // Collect stderr for logging
55
65
  child.stderr?.on('data', data => {
56
66
  stderr += data.toString();
57
67
  });
58
68
  // Wait for the process and all its children to exit
59
69
  child.on('exit', (code, signal) => {
70
+ logger.info('Hook execution finished', {
71
+ command,
72
+ cwd,
73
+ exitCode: code,
74
+ signal,
75
+ stdout,
76
+ stderr,
77
+ });
60
78
  if (code !== 0 || signal) {
61
79
  const errorMessage = signal
62
80
  ? `Hook terminated by signal ${signal}`
63
81
  : `Hook exited with code ${code}`;
82
+ const outputDetails = [
83
+ stderr ? `Stderr: ${stderr}` : undefined,
84
+ stdout ? `Stdout: ${stdout}` : undefined,
85
+ ]
86
+ .filter(Boolean)
87
+ .join('\n');
64
88
  resume(Effect.fail(new ProcessError({
65
89
  command,
66
90
  exitCode: code ?? undefined,
67
91
  signal: signal ?? undefined,
68
- message: stderr
69
- ? `${errorMessage}\nStderr: ${stderr}`
92
+ message: outputDetails
93
+ ? `${errorMessage}\n${outputDetails}`
70
94
  : errorMessage,
95
+ stdout,
96
+ stderr,
71
97
  })));
72
98
  return;
73
99
  }
@@ -76,6 +102,11 @@ export function executeHook(command, cwd, environment) {
76
102
  });
77
103
  // Handle errors in spawning the process
78
104
  child.on('error', error => {
105
+ logger.error('Hook spawn failed', {
106
+ command,
107
+ cwd,
108
+ error: error.message,
109
+ });
79
110
  resume(Effect.fail(new ProcessError({
80
111
  command,
81
112
  message: error.message,
@@ -103,6 +134,13 @@ export function executeWorktreePreCreationHook(command, worktreePath, branch, gi
103
134
  if (baseBranch) {
104
135
  environment.CCMANAGER_BASE_BRANCH = baseBranch;
105
136
  }
137
+ logger.info('Worktree pre-creation hook configured', {
138
+ command,
139
+ worktreePath,
140
+ branch,
141
+ gitRoot,
142
+ baseBranch,
143
+ });
106
144
  // Execute in git root (worktree doesn't exist yet)
107
145
  // NO Effect.catchAll - errors must propagate to abort creation
108
146
  return executeHook(command, gitRoot, environment);
@@ -120,11 +158,14 @@ export function executeWorktreePostCreationHook(command, worktree, gitRoot, base
120
158
  if (baseBranch) {
121
159
  environment.CCMANAGER_BASE_BRANCH = baseBranch;
122
160
  }
123
- return Effect.catchAll(executeHook(command, worktree.path, environment), error => {
124
- // Log error but don't throw - hooks should not break the main flow
125
- console.error(`Failed to execute post-creation hook: ${error.message}`);
126
- return Effect.void;
161
+ logger.info('Worktree post-creation hook configured', {
162
+ command,
163
+ worktreePath: worktree.path,
164
+ branch: worktree.branch || 'unknown',
165
+ gitRoot,
166
+ baseBranch,
127
167
  });
168
+ return executeHook(command, worktree.path, environment);
128
169
  }
129
170
  /**
130
171
  * Execute a session status change hook using Effect
@@ -156,7 +156,7 @@ describe('hookExecutor Integration Tests', () => {
156
156
  });
157
157
  });
158
158
  describe('executeWorktreePostCreationHook (real execution)', () => {
159
- it('should not throw even when command fails', async () => {
159
+ it('should fail with ProcessError when command fails', async () => {
160
160
  // Arrange
161
161
  const tmpDir = await mkdtemp(join(tmpdir(), 'hook-test-'));
162
162
  const worktree = {
@@ -166,8 +166,13 @@ describe('hookExecutor Integration Tests', () => {
166
166
  hasSession: false,
167
167
  };
168
168
  try {
169
- // Act & Assert - should not throw even with failing command
170
- await expect(Effect.runPromise(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main'))).resolves.toBeUndefined();
169
+ // Act & Assert - post-creation hook failures are surfaced to callers
170
+ const result = await Effect.runPromise(Effect.either(executeWorktreePostCreationHook('exit 1', worktree, tmpDir, 'main')));
171
+ expect(result._tag).toBe('Left');
172
+ if (result._tag === 'Left') {
173
+ expect(result.left._tag).toBe('ProcessError');
174
+ expect(result.left.exitCode).toBe(1);
175
+ }
171
176
  }
172
177
  finally {
173
178
  // Cleanup
@@ -1,7 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { format } from 'util';
4
- import os from 'os';
4
+ import * as os from 'os';
5
5
  /**
6
6
  * Log level constants for structured logging
7
7
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.10",
3
+ "version": "4.1.12",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.10",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.10",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.10",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.10",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.10"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.12",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.12",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.12",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.12",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.12"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",