ccmanager 4.0.1 → 4.0.3
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/dist/components/App.js +17 -3
- package/dist/components/ConfigureStatusHooks.js +1 -1
- package/dist/components/Session.js +46 -63
- package/dist/services/bunTerminal.d.ts +1 -0
- package/dist/services/bunTerminal.js +4 -3
- package/dist/services/sessionManager.d.ts +1 -1
- package/dist/services/sessionManager.effect.test.js +16 -37
- package/dist/services/sessionManager.js +42 -9
- package/dist/services/sessionManager.test.js +23 -97
- package/dist/services/stateDetector/cursor.js +8 -0
- package/dist/services/stateDetector/cursor.test.js +12 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/hookExecutor.js +3 -2
- package/dist/utils/hookExecutor.test.js +4 -0
- package/dist/utils/worktreeUtils.test.js +1 -0
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -42,6 +42,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
42
42
|
const [pendingWorktreeCreation, setPendingWorktreeCreation] = useState(null);
|
|
43
43
|
// State for loading context - track flags for message composition
|
|
44
44
|
const [loadingContext, setLoadingContext] = useState({});
|
|
45
|
+
// State for streaming devcontainer up logs
|
|
46
|
+
const [devcontainerLogs, setDevcontainerLogs] = useState([]);
|
|
45
47
|
// Helper function to format error messages based on error type using _tag discrimination
|
|
46
48
|
const formatErrorMessage = (error) => {
|
|
47
49
|
switch (error._tag) {
|
|
@@ -59,8 +61,15 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
59
61
|
};
|
|
60
62
|
// Helper function to create session with Effect-based error handling
|
|
61
63
|
const createSessionWithEffect = useCallback(async (worktreePath, presetId, initialPrompt) => {
|
|
64
|
+
setDevcontainerLogs([]);
|
|
62
65
|
const sessionEffect = devcontainerConfig
|
|
63
|
-
? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt)
|
|
66
|
+
? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt, (line) => {
|
|
67
|
+
setDevcontainerLogs(prev => {
|
|
68
|
+
const next = [...prev, line];
|
|
69
|
+
// Keep only the last 10 lines to avoid unbounded growth
|
|
70
|
+
return next.length > 10 ? next.slice(-10) : next;
|
|
71
|
+
});
|
|
72
|
+
})
|
|
64
73
|
: sessionManager.createSessionWithPresetEffect(worktreePath, presetId, initialPrompt);
|
|
65
74
|
const result = await Effect.runPromise(Effect.either(sessionEffect));
|
|
66
75
|
if (result._tag === 'Left') {
|
|
@@ -449,6 +458,11 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
449
458
|
setLoadingContext({ deleteBranch });
|
|
450
459
|
setView('deleting-worktree');
|
|
451
460
|
setError(null);
|
|
461
|
+
// Yield to the event loop so Ink can paint `deleting-worktree` before git work runs.
|
|
462
|
+
// Otherwise the confirmation UI stays visible until deletion finishes (no spinner).
|
|
463
|
+
await new Promise(resolve => {
|
|
464
|
+
setTimeout(resolve, 0);
|
|
465
|
+
});
|
|
452
466
|
// Delete the worktrees sequentially using Effect
|
|
453
467
|
let hasError = false;
|
|
454
468
|
for (const path of worktreePaths) {
|
|
@@ -619,7 +633,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
619
633
|
// Use yellow color for devcontainer operations (longer duration),
|
|
620
634
|
// cyan for standard session creation
|
|
621
635
|
const color = devcontainerConfig ? 'yellow' : 'cyan';
|
|
622
|
-
return (
|
|
636
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LoadingSpinner, { message: message, color: color }), devcontainerLogs.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: devcontainerLogs.map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) }))] }));
|
|
623
637
|
}
|
|
624
638
|
if (view === 'creating-session-preset') {
|
|
625
639
|
// Always display preset-specific message
|
|
@@ -631,7 +645,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
631
645
|
: 'Creating session with preset...';
|
|
632
646
|
// Use yellow color for devcontainer, cyan for standard
|
|
633
647
|
const color = devcontainerConfig ? 'yellow' : 'cyan';
|
|
634
|
-
return (
|
|
648
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LoadingSpinner, { message: message, color: color }), devcontainerLogs.length > 0 && (_jsx(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: devcontainerLogs.map((line, i) => (_jsx(Text, { dimColor: true, children: line }, i))) }))] }));
|
|
635
649
|
}
|
|
636
650
|
if (view === 'clearing') {
|
|
637
651
|
// Render nothing during the clearing phase to ensure clean transition
|
|
@@ -105,7 +105,7 @@ const ConfigureStatusHooks = ({ onComplete, }) => {
|
|
|
105
105
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "green", children: "\u2713 Configuration saved successfully!" }) }));
|
|
106
106
|
}
|
|
107
107
|
if (view === 'edit') {
|
|
108
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", STATUS_LABELS[selectedStatus], " Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute when status changes to", ' ', STATUS_LABELS[selectedStatus], ":"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_DIR, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
|
|
108
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure ", STATUS_LABELS[selectedStatus], " Hook"] }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Command to execute when status changes to", ' ', STATUS_LABELS[selectedStatus], ":"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(TextInputWrapper, { value: currentCommand, onChange: setCurrentCommand, onSubmit: handleCommandSubmit, placeholder: "Enter command (e.g., notify-send 'Claude is idle')" }) }), _jsx(Box, { marginBottom: 1, children: _jsxs(Text, { children: ["Enabled: ", currentEnabled ? '✓' : '✗', " (Press Tab to toggle)"] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Environment variables available: CCMANAGER_OLD_STATE, CCMANAGER_NEW_STATE," }) }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: `CCMANAGER_WORKTREE_PATH, CCMANAGER_WORKTREE_DIR, CCMANAGER_WORKTREE_BRANCH, CCMANAGER_SESSION_ID, CCMANAGER_PRESET_NAME` }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Enter to save, Tab to toggle enabled, Esc to cancel" }) })] }));
|
|
109
109
|
}
|
|
110
110
|
const scopeLabel = scope === 'project' ? 'Project' : 'Global';
|
|
111
111
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure Status Hooks (", scopeLabel, ")"] }) }), isInheriting && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { backgroundColor: "cyan", color: "black", children: [' ', "\uD83D\uDCCB Inheriting from global configuration", ' '] }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Set commands to run when session status changes:" }) }), _jsx(SelectInput, { items: getMenuItems(), onSelect: handleMenuSelect, isFocused: true, limit: 10 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press Esc to go back" }) })] }));
|
|
@@ -4,11 +4,6 @@ import { shortcutManager } from '../services/shortcutManager.js';
|
|
|
4
4
|
const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
5
5
|
const { stdout } = useStdout();
|
|
6
6
|
const isExitingRef = useRef(false);
|
|
7
|
-
const stripOscColorSequences = (input) => {
|
|
8
|
-
// Remove default foreground/background color OSC sequences that Codex emits
|
|
9
|
-
// These sequences leak as literal text when replaying buffered output
|
|
10
|
-
return input.replace(/\x1B\](?:10|11);[^\x07\x1B]*(?:\x07|\x1B\\)/g, '');
|
|
11
|
-
};
|
|
12
7
|
const normalizeLineEndings = (input) => {
|
|
13
8
|
// Ensure LF moves to column 0 to prevent cursor drift when ONLCR is disabled.
|
|
14
9
|
let normalized = '';
|
|
@@ -32,22 +27,40 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
32
27
|
// protocol / modifyOtherKeys / focus tracking) so they don't leak into other
|
|
33
28
|
// sessions after we detach.
|
|
34
29
|
stdout.write('\x1b[>0u'); // Disable kitty keyboard protocol (CSI u sequences)
|
|
35
|
-
stdout.write('\x1b[>
|
|
30
|
+
stdout.write('\x1b[>4;0m'); // Disable xterm modifyOtherKeys extensions
|
|
36
31
|
stdout.write('\x1b[?1004l'); // Disable focus reporting
|
|
37
32
|
stdout.write('\x1b[?2004l'); // Disable bracketed paste (can interfere with shortcuts)
|
|
38
33
|
stdout.write('\x1b[?7h'); // Re-enable auto-wrap
|
|
39
34
|
};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
35
|
+
// Set up raw input handling
|
|
36
|
+
const stdin = process.stdin;
|
|
37
|
+
// Configure stdin for PTY passthrough
|
|
38
|
+
if (stdin.isTTY) {
|
|
39
|
+
stdin.setRawMode(true);
|
|
40
|
+
stdin.resume();
|
|
41
|
+
}
|
|
42
|
+
stdin.setEncoding('utf8');
|
|
43
|
+
const handleStdinData = (data) => {
|
|
44
|
+
if (isExitingRef.current)
|
|
45
|
+
return;
|
|
46
|
+
// Check for return to menu shortcut
|
|
47
|
+
if (shortcutManager.matchesRawInput('returnToMenu', data)) {
|
|
48
|
+
// Disable any extended input modes that might have been enabled by the PTY
|
|
49
|
+
if (stdout) {
|
|
50
|
+
resetTerminalInputModes();
|
|
51
|
+
}
|
|
52
|
+
// Remove our listener — Ink will reconfigure stdin when Menu mounts
|
|
53
|
+
stdin.removeListener('data', handleStdinData);
|
|
54
|
+
onReturnToMenu();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (session.stateMutex.getSnapshot().state === 'pending_auto_approval') {
|
|
58
|
+
sessionManager.cancelAutoApproval(session.id, 'User input received during auto-approval');
|
|
59
|
+
}
|
|
60
|
+
// Pass all other input directly to the PTY
|
|
61
|
+
session.process.write(data);
|
|
48
62
|
};
|
|
49
|
-
|
|
50
|
-
resetTerminalInputModes();
|
|
63
|
+
stdin.on('data', handleStdinData);
|
|
51
64
|
// Prevent line wrapping from drifting redraws in TUIs that rely on cursor-up clears.
|
|
52
65
|
stdout.write('\x1b[?7l');
|
|
53
66
|
// Clear screen when entering session
|
|
@@ -61,9 +74,8 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
61
74
|
// Concatenate all history buffers and write at once for better performance
|
|
62
75
|
const allHistory = Buffer.concat(restoredSession.outputHistory);
|
|
63
76
|
const historyStr = allHistory.toString('utf8');
|
|
64
|
-
//
|
|
65
|
-
const
|
|
66
|
-
const normalized = normalizeLineEndings(sanitized);
|
|
77
|
+
// Normalize the output
|
|
78
|
+
const normalized = normalizeLineEndings(historyStr);
|
|
67
79
|
// Remove leading clear screen sequences to avoid double-clear
|
|
68
80
|
const cleaned = normalized
|
|
69
81
|
.replace(/^\x1B\[2J/g, '')
|
|
@@ -75,6 +87,21 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
75
87
|
};
|
|
76
88
|
// Listen for restore event first
|
|
77
89
|
sessionManager.on('sessionRestore', handleSessionRestore);
|
|
90
|
+
// Listen for session data events
|
|
91
|
+
const handleSessionData = (activeSession, data) => {
|
|
92
|
+
// Only handle data for our session
|
|
93
|
+
if (activeSession.id === session.id && !isExitingRef.current) {
|
|
94
|
+
stdout.write(normalizeLineEndings(data));
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
const handleSessionExit = (exitedSession) => {
|
|
98
|
+
if (exitedSession.id === session.id) {
|
|
99
|
+
isExitingRef.current = true;
|
|
100
|
+
// Don't call onReturnToMenu here - App component handles it
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
sessionManager.on('sessionData', handleSessionData);
|
|
104
|
+
sessionManager.on('sessionExit', handleSessionExit);
|
|
78
105
|
// Mark session as active (this will trigger the restore event)
|
|
79
106
|
sessionManager.setSessionActive(session.id, true);
|
|
80
107
|
// Immediately resize the PTY and terminal to current dimensions
|
|
@@ -93,21 +120,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
93
120
|
catch {
|
|
94
121
|
/* empty */
|
|
95
122
|
}
|
|
96
|
-
// Listen for session data events
|
|
97
|
-
const handleSessionData = (activeSession, data) => {
|
|
98
|
-
// Only handle data for our session
|
|
99
|
-
if (activeSession.id === session.id && !isExitingRef.current) {
|
|
100
|
-
stdout.write(normalizeLineEndings(data));
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
const handleSessionExit = (exitedSession) => {
|
|
104
|
-
if (exitedSession.id === session.id) {
|
|
105
|
-
isExitingRef.current = true;
|
|
106
|
-
// Don't call onReturnToMenu here - App component handles it
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
sessionManager.on('sessionData', handleSessionData);
|
|
110
|
-
sessionManager.on('sessionExit', handleSessionExit);
|
|
111
123
|
// Handle terminal resize
|
|
112
124
|
const handleResize = () => {
|
|
113
125
|
const cols = process.stdout.columns || 80;
|
|
@@ -119,35 +131,6 @@ const Session = ({ session, sessionManager, onReturnToMenu, }) => {
|
|
|
119
131
|
}
|
|
120
132
|
};
|
|
121
133
|
stdout.on('resize', handleResize);
|
|
122
|
-
// Set up raw input handling
|
|
123
|
-
const stdin = process.stdin;
|
|
124
|
-
// Configure stdin for PTY passthrough
|
|
125
|
-
if (stdin.isTTY) {
|
|
126
|
-
stdin.setRawMode(true);
|
|
127
|
-
stdin.resume();
|
|
128
|
-
}
|
|
129
|
-
stdin.setEncoding('utf8');
|
|
130
|
-
const handleStdinData = (data) => {
|
|
131
|
-
if (isExitingRef.current)
|
|
132
|
-
return;
|
|
133
|
-
// Check for return to menu shortcut
|
|
134
|
-
if (shortcutManager.matchesRawInput('returnToMenu', data)) {
|
|
135
|
-
// Disable any extended input modes that might have been enabled by the PTY
|
|
136
|
-
if (stdout) {
|
|
137
|
-
resetTerminalInputModes();
|
|
138
|
-
}
|
|
139
|
-
// Remove our listener — Ink will reconfigure stdin when Menu mounts
|
|
140
|
-
stdin.removeListener('data', handleStdinData);
|
|
141
|
-
onReturnToMenu();
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
if (session.stateMutex.getSnapshot().state === 'pending_auto_approval') {
|
|
145
|
-
sessionManager.cancelAutoApproval(session.id, 'User input received during auto-approval');
|
|
146
|
-
}
|
|
147
|
-
// Pass all other input directly to the PTY
|
|
148
|
-
session.process.write(data);
|
|
149
|
-
};
|
|
150
|
-
stdin.on('data', handleStdinData);
|
|
151
134
|
return () => {
|
|
152
135
|
// Remove our stdin listener
|
|
153
136
|
stdin.removeListener('data', handleStdinData);
|
|
@@ -57,9 +57,10 @@ class BunTerminal {
|
|
|
57
57
|
this._processBuffer();
|
|
58
58
|
},
|
|
59
59
|
});
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
60
|
+
// Most interactive CLIs work best when the PTY starts in raw mode, but
|
|
61
|
+
// terminal proxy commands such as `devcontainer exec` manage termios
|
|
62
|
+
// themselves and break if the outer PTY is forced raw first.
|
|
63
|
+
this._terminal.setRawMode(options.rawMode ?? true);
|
|
63
64
|
// Disable ONLCR in the PTY output flags to avoid double CRLF translation
|
|
64
65
|
// when forwarding PTY output to the real stdout TTY.
|
|
65
66
|
const ONLCR_FLAG = 0x0002;
|
|
@@ -96,7 +96,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
|
|
|
96
96
|
* Create session with devcontainer integration using Effect-based error handling
|
|
97
97
|
* @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
|
|
98
98
|
*/
|
|
99
|
-
createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string, initialPrompt?: string): Effect.Effect<Session, ProcessError | ConfigError, never>;
|
|
99
|
+
createSessionWithDevcontainerEffect(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string, initialPrompt?: string, onLog?: (line: string) => void): Effect.Effect<Session, ProcessError | ConfigError, never>;
|
|
100
100
|
destroy(): void;
|
|
101
101
|
static getSessionCounts(sessions: Session[]): SessionCounts;
|
|
102
102
|
static formatSessionCounts(counts: SessionCounts): string;
|
|
@@ -9,8 +9,17 @@ vi.mock('./bunTerminal.js', () => ({
|
|
|
9
9
|
return null;
|
|
10
10
|
}),
|
|
11
11
|
}));
|
|
12
|
+
// Helper to create a mock child process for child_process.spawn
|
|
13
|
+
function createMockChildProcess(exitCode = 0) {
|
|
14
|
+
const stdout = new EventEmitter();
|
|
15
|
+
const stderr = new EventEmitter();
|
|
16
|
+
const proc = Object.assign(new EventEmitter(), { stdout, stderr });
|
|
17
|
+
process.nextTick(() => proc.emit('close', exitCode));
|
|
18
|
+
return proc;
|
|
19
|
+
}
|
|
12
20
|
// Mock child_process
|
|
13
21
|
vi.mock('child_process', () => ({
|
|
22
|
+
spawn: vi.fn(() => createMockChildProcess(0)),
|
|
14
23
|
exec: vi.fn(),
|
|
15
24
|
execFile: vi.fn(),
|
|
16
25
|
}));
|
|
@@ -167,18 +176,6 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
167
176
|
});
|
|
168
177
|
// Setup spawn mock
|
|
169
178
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
170
|
-
// Mock exec to succeed
|
|
171
|
-
const { exec } = await import('child_process');
|
|
172
|
-
const mockExec = vi.mocked(exec);
|
|
173
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
174
|
-
if (typeof options === 'function') {
|
|
175
|
-
callback = options;
|
|
176
|
-
}
|
|
177
|
-
if (callback && typeof callback === 'function') {
|
|
178
|
-
callback(null, 'Container started', '');
|
|
179
|
-
}
|
|
180
|
-
return {};
|
|
181
|
-
});
|
|
182
179
|
const devcontainerConfig = {
|
|
183
180
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
184
181
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
@@ -192,18 +189,9 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
192
189
|
expect(session.devcontainerConfig).toEqual(devcontainerConfig);
|
|
193
190
|
});
|
|
194
191
|
it('should return Effect that fails with ProcessError when devcontainer up fails', async () => {
|
|
195
|
-
// Mock
|
|
196
|
-
const {
|
|
197
|
-
|
|
198
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
199
|
-
if (typeof options === 'function') {
|
|
200
|
-
callback = options;
|
|
201
|
-
}
|
|
202
|
-
if (callback && typeof callback === 'function') {
|
|
203
|
-
callback(new Error('Container failed to start'), '', '');
|
|
204
|
-
}
|
|
205
|
-
return {};
|
|
206
|
-
});
|
|
192
|
+
// Mock spawn to return a process that exits with code 1
|
|
193
|
+
const { spawn: childSpawn } = await import('child_process');
|
|
194
|
+
vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(1));
|
|
207
195
|
const devcontainerConfig = {
|
|
208
196
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
209
197
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
@@ -217,11 +205,14 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
217
205
|
expect(result.left._tag).toBe('ProcessError');
|
|
218
206
|
if (result.left._tag === 'ProcessError') {
|
|
219
207
|
expect(result.left.command).toContain('devcontainer up');
|
|
220
|
-
expect(result.left.message).toContain('
|
|
208
|
+
expect(result.left.message).toContain('Command exited with code 1');
|
|
221
209
|
}
|
|
222
210
|
}
|
|
223
211
|
});
|
|
224
212
|
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
213
|
+
// Reset childSpawn mock to succeed (devcontainer up should pass)
|
|
214
|
+
const { spawn: childSpawn } = await import('child_process');
|
|
215
|
+
vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(0));
|
|
225
216
|
// Setup mocks - getPresetByIdEffect returns Left, getDefaultPreset returns undefined
|
|
226
217
|
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
|
|
227
218
|
field: 'presetId',
|
|
@@ -229,18 +220,6 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
229
220
|
receivedValue: 'invalid-preset',
|
|
230
221
|
})));
|
|
231
222
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue(undefined);
|
|
232
|
-
// Mock exec to succeed (devcontainer up)
|
|
233
|
-
const { exec } = await import('child_process');
|
|
234
|
-
const mockExec = vi.mocked(exec);
|
|
235
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
236
|
-
if (typeof options === 'function') {
|
|
237
|
-
callback = options;
|
|
238
|
-
}
|
|
239
|
-
if (callback && typeof callback === 'function') {
|
|
240
|
-
callback(null, 'Container started', '');
|
|
241
|
-
}
|
|
242
|
-
return {};
|
|
243
|
-
});
|
|
244
223
|
const devcontainerConfig = {
|
|
245
224
|
upCommand: 'devcontainer up',
|
|
246
225
|
execCommand: 'devcontainer exec',
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { spawn } from './bunTerminal.js';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
3
|
import pkg from '@xterm/headless';
|
|
4
|
-
import {
|
|
5
|
-
import { promisify } from 'util';
|
|
4
|
+
import { spawn as childSpawn } from 'child_process';
|
|
6
5
|
import { configReader } from './config/configReader.js';
|
|
7
6
|
import { executeStatusHook } from '../utils/hookExecutor.js';
|
|
8
7
|
import { createStateDetector } from './stateDetector/index.js';
|
|
@@ -17,20 +16,20 @@ import { getTerminalScreenContent } from '../utils/screenCapture.js';
|
|
|
17
16
|
import { injectTeammateMode } from '../utils/commandArgs.js';
|
|
18
17
|
import { preparePresetLaunch } from '../utils/presetPrompt.js';
|
|
19
18
|
const { Terminal } = pkg;
|
|
20
|
-
const execAsync = promisify(exec);
|
|
21
19
|
const TERMINAL_CONTENT_MAX_LINES = 300;
|
|
22
20
|
export class SessionManager extends EventEmitter {
|
|
23
21
|
sessions;
|
|
24
22
|
waitingWithBottomBorder = new Map();
|
|
25
23
|
busyTimers = new Map();
|
|
26
24
|
autoApprovalDisabledWorktrees = new Set();
|
|
27
|
-
async spawn(command, args, worktreePath) {
|
|
25
|
+
async spawn(command, args, worktreePath, options = {}) {
|
|
28
26
|
const spawnOptions = {
|
|
29
27
|
name: 'xterm-256color',
|
|
30
28
|
cols: process.stdout.columns || 80,
|
|
31
29
|
rows: process.stdout.rows || 24,
|
|
32
30
|
cwd: worktreePath,
|
|
33
31
|
env: process.env,
|
|
32
|
+
...(options.rawMode === undefined ? {} : { rawMode: options.rawMode }),
|
|
34
33
|
};
|
|
35
34
|
return spawn(command, args, spawnOptions);
|
|
36
35
|
}
|
|
@@ -217,6 +216,7 @@ export class SessionManager extends EventEmitter {
|
|
|
217
216
|
terminal,
|
|
218
217
|
stateCheckInterval: undefined, // Will be set in setupBackgroundHandler
|
|
219
218
|
isPrimaryCommand: options.isPrimaryCommand ?? true,
|
|
219
|
+
presetName: options.presetName,
|
|
220
220
|
detectionStrategy,
|
|
221
221
|
devcontainerConfig: options.devcontainerConfig ?? undefined,
|
|
222
222
|
stateMutex: new Mutex(createInitialSessionStateData()),
|
|
@@ -257,6 +257,7 @@ export class SessionManager extends EventEmitter {
|
|
|
257
257
|
const ptyProcess = await this.spawn(command, args, worktreePath);
|
|
258
258
|
const session = await this.createSessionInternal(worktreePath, ptyProcess, {
|
|
259
259
|
isPrimaryCommand: true,
|
|
260
|
+
presetName: preset.name,
|
|
260
261
|
detectionStrategy: preset.detectionStrategy,
|
|
261
262
|
});
|
|
262
263
|
if (launch.stdinPayload) {
|
|
@@ -338,7 +339,7 @@ export class SessionManager extends EventEmitter {
|
|
|
338
339
|
'claude',
|
|
339
340
|
...fallbackClaudeArgs,
|
|
340
341
|
];
|
|
341
|
-
fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath);
|
|
342
|
+
fallbackProcess = await this.spawn(devcontainerCmd, fallbackFullArgs, session.worktreePath, { rawMode: false });
|
|
342
343
|
}
|
|
343
344
|
else {
|
|
344
345
|
// Regular fallback without devcontainer
|
|
@@ -633,12 +634,43 @@ export class SessionManager extends EventEmitter {
|
|
|
633
634
|
* Create session with devcontainer integration using Effect-based error handling
|
|
634
635
|
* @returns Effect that may fail with ProcessError (container/spawn failure) or ConfigError (invalid preset)
|
|
635
636
|
*/
|
|
636
|
-
createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt) {
|
|
637
|
+
createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt, onLog) {
|
|
637
638
|
return Effect.tryPromise({
|
|
638
639
|
try: async () => {
|
|
639
|
-
// Execute devcontainer up command
|
|
640
|
+
// Execute devcontainer up command, streaming output in real-time
|
|
640
641
|
try {
|
|
641
|
-
await
|
|
642
|
+
await new Promise((resolve, reject) => {
|
|
643
|
+
const parts = devcontainerConfig.upCommand.split(/\s+/);
|
|
644
|
+
const cmd = parts[0];
|
|
645
|
+
const args = parts.slice(1);
|
|
646
|
+
const proc = childSpawn(cmd, args, {
|
|
647
|
+
cwd: worktreePath,
|
|
648
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
649
|
+
shell: false,
|
|
650
|
+
});
|
|
651
|
+
const handleData = (data) => {
|
|
652
|
+
const text = data.toString();
|
|
653
|
+
for (const line of text.split('\n')) {
|
|
654
|
+
const trimmed = line.trimEnd();
|
|
655
|
+
if (trimmed) {
|
|
656
|
+
onLog?.(trimmed);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
proc.stdout?.on('data', handleData);
|
|
661
|
+
proc.stderr?.on('data', handleData);
|
|
662
|
+
proc.on('error', err => {
|
|
663
|
+
reject(err);
|
|
664
|
+
});
|
|
665
|
+
proc.on('close', code => {
|
|
666
|
+
if (code === 0) {
|
|
667
|
+
resolve();
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
reject(new Error(`Command exited with code ${code}`));
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
});
|
|
642
674
|
}
|
|
643
675
|
catch (error) {
|
|
644
676
|
throw new ProcessError({
|
|
@@ -656,9 +688,10 @@ export class SessionManager extends EventEmitter {
|
|
|
656
688
|
const presetArgs = launch.args;
|
|
657
689
|
const fullArgs = [...execArgs, '--', preset.command, ...presetArgs];
|
|
658
690
|
// Spawn the process within devcontainer
|
|
659
|
-
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath);
|
|
691
|
+
const ptyProcess = await this.spawn(devcontainerCmd, fullArgs, worktreePath, { rawMode: false });
|
|
660
692
|
const session = await this.createSessionInternal(worktreePath, ptyProcess, {
|
|
661
693
|
isPrimaryCommand: true,
|
|
694
|
+
presetName: preset.name,
|
|
662
695
|
detectionStrategy: preset.detectionStrategy,
|
|
663
696
|
devcontainerConfig,
|
|
664
697
|
});
|
|
@@ -3,7 +3,16 @@ import { Effect, Either } from 'effect';
|
|
|
3
3
|
import { ValidationError } from '../types/errors.js';
|
|
4
4
|
import { spawn } from './bunTerminal.js';
|
|
5
5
|
import { EventEmitter } from 'events';
|
|
6
|
-
import {
|
|
6
|
+
import { spawn as childSpawn } from 'child_process';
|
|
7
|
+
// Helper to create a mock child process for child_process.spawn
|
|
8
|
+
function createMockChildProcess(exitCode = 0) {
|
|
9
|
+
const stdout = new EventEmitter();
|
|
10
|
+
const stderr = new EventEmitter();
|
|
11
|
+
const proc = Object.assign(new EventEmitter(), { stdout, stderr });
|
|
12
|
+
// Emit 'close' asynchronously so listeners can be attached
|
|
13
|
+
process.nextTick(() => proc.emit('close', exitCode));
|
|
14
|
+
return proc;
|
|
15
|
+
}
|
|
7
16
|
// Mock bunTerminal
|
|
8
17
|
vi.mock('./bunTerminal.js', () => ({
|
|
9
18
|
spawn: vi.fn(function () {
|
|
@@ -12,12 +21,11 @@ vi.mock('./bunTerminal.js', () => ({
|
|
|
12
21
|
}));
|
|
13
22
|
// Mock child_process
|
|
14
23
|
vi.mock('child_process', () => ({
|
|
15
|
-
|
|
16
|
-
return
|
|
17
|
-
}),
|
|
18
|
-
execFile: vi.fn(function () {
|
|
19
|
-
return null;
|
|
24
|
+
spawn: vi.fn(function () {
|
|
25
|
+
return createMockChildProcess(0);
|
|
20
26
|
}),
|
|
27
|
+
exec: vi.fn(),
|
|
28
|
+
execFile: vi.fn(),
|
|
21
29
|
}));
|
|
22
30
|
// Mock configuration manager
|
|
23
31
|
vi.mock('./config/configReader.js', () => ({
|
|
@@ -388,24 +396,8 @@ describe('SessionManager', () => {
|
|
|
388
396
|
});
|
|
389
397
|
describe('createSessionWithDevcontainerEffect', () => {
|
|
390
398
|
beforeEach(() => {
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
mockExec.shouldFail = false;
|
|
394
|
-
// Setup exec mock to work with promisify
|
|
395
|
-
mockExec.mockImplementation(((...args) => {
|
|
396
|
-
const [command, , callback] = args;
|
|
397
|
-
if (callback) {
|
|
398
|
-
// Handle callback style
|
|
399
|
-
if (command.includes('devcontainer up')) {
|
|
400
|
-
if (mockExec.shouldFail) {
|
|
401
|
-
callback(new Error('Container startup failed'));
|
|
402
|
-
}
|
|
403
|
-
else {
|
|
404
|
-
callback(null, '', '');
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}));
|
|
399
|
+
// Setup childSpawn mock to return a successful mock child process
|
|
400
|
+
vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(0));
|
|
409
401
|
});
|
|
410
402
|
it('should execute devcontainer up command before creating session', async () => {
|
|
411
403
|
// Setup mock preset
|
|
@@ -434,7 +426,7 @@ describe('SessionManager', () => {
|
|
|
434
426
|
'--resume',
|
|
435
427
|
'--teammate-mode',
|
|
436
428
|
'in-process',
|
|
437
|
-
], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
429
|
+
], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
|
|
438
430
|
});
|
|
439
431
|
it('should use specific preset when ID provided', async () => {
|
|
440
432
|
// Setup mock preset
|
|
@@ -465,15 +457,14 @@ describe('SessionManager', () => {
|
|
|
465
457
|
], expect.any(Object));
|
|
466
458
|
});
|
|
467
459
|
it('should throw error when devcontainer up fails', async () => {
|
|
468
|
-
// Setup
|
|
469
|
-
|
|
470
|
-
mockExec.shouldFail = true;
|
|
460
|
+
// Setup childSpawn to return a process that exits with code 1
|
|
461
|
+
vi.mocked(childSpawn).mockImplementation(() => createMockChildProcess(1));
|
|
471
462
|
// Create session with devcontainer
|
|
472
463
|
const devcontainerConfig = {
|
|
473
464
|
upCommand: 'devcontainer up',
|
|
474
465
|
execCommand: 'devcontainer exec',
|
|
475
466
|
};
|
|
476
|
-
await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer:
|
|
467
|
+
await expect(Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', devcontainerConfig))).rejects.toThrow('Failed to start devcontainer: Command exited with code 1');
|
|
477
468
|
});
|
|
478
469
|
it('should create a new session each time for multi-session support', async () => {
|
|
479
470
|
// Setup mock preset
|
|
@@ -540,17 +531,6 @@ describe('SessionManager', () => {
|
|
|
540
531
|
});
|
|
541
532
|
// Setup spawn mock
|
|
542
533
|
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
543
|
-
const mockExec = vi.mocked(exec);
|
|
544
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
545
|
-
if (typeof options === 'function') {
|
|
546
|
-
callback = options;
|
|
547
|
-
options = undefined;
|
|
548
|
-
}
|
|
549
|
-
if (callback && typeof callback === 'function') {
|
|
550
|
-
callback(null, 'Container started', '');
|
|
551
|
-
}
|
|
552
|
-
return {};
|
|
553
|
-
});
|
|
554
534
|
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree2', {
|
|
555
535
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
556
536
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
@@ -566,20 +546,10 @@ describe('SessionManager', () => {
|
|
|
566
546
|
'in-process',
|
|
567
547
|
], expect.objectContaining({
|
|
568
548
|
cwd: '/test/worktree2',
|
|
549
|
+
rawMode: false,
|
|
569
550
|
}));
|
|
570
551
|
});
|
|
571
552
|
it('should use preset with devcontainer', async () => {
|
|
572
|
-
const mockExec = vi.mocked(exec);
|
|
573
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
574
|
-
if (typeof options === 'function') {
|
|
575
|
-
callback = options;
|
|
576
|
-
options = undefined;
|
|
577
|
-
}
|
|
578
|
-
if (callback && typeof callback === 'function') {
|
|
579
|
-
callback(null, 'Container started', '');
|
|
580
|
-
}
|
|
581
|
-
return {};
|
|
582
|
-
});
|
|
583
553
|
await Effect.runPromise(sessionManager.createSessionWithDevcontainerEffect('/test/worktree', {
|
|
584
554
|
upCommand: 'devcontainer up --workspace-folder .',
|
|
585
555
|
execCommand: 'devcontainer exec --workspace-folder .',
|
|
@@ -594,17 +564,6 @@ describe('SessionManager', () => {
|
|
|
594
564
|
});
|
|
595
565
|
});
|
|
596
566
|
it('should parse exec command and append preset command', async () => {
|
|
597
|
-
const mockExec = vi.mocked(exec);
|
|
598
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
599
|
-
if (typeof options === 'function') {
|
|
600
|
-
callback = options;
|
|
601
|
-
options = undefined;
|
|
602
|
-
}
|
|
603
|
-
if (callback && typeof callback === 'function') {
|
|
604
|
-
callback(null, 'Container started', '');
|
|
605
|
-
}
|
|
606
|
-
return {};
|
|
607
|
-
});
|
|
608
567
|
const config = {
|
|
609
568
|
upCommand: 'devcontainer up --workspace-folder /path/to/project',
|
|
610
569
|
execCommand: 'devcontainer exec --workspace-folder /path/to/project --user vscode',
|
|
@@ -623,17 +582,6 @@ describe('SessionManager', () => {
|
|
|
623
582
|
], expect.any(Object));
|
|
624
583
|
});
|
|
625
584
|
it('should handle preset with args in devcontainer', async () => {
|
|
626
|
-
const mockExec = vi.mocked(exec);
|
|
627
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
628
|
-
if (typeof options === 'function') {
|
|
629
|
-
callback = options;
|
|
630
|
-
options = undefined;
|
|
631
|
-
}
|
|
632
|
-
if (callback && typeof callback === 'function') {
|
|
633
|
-
callback(null, 'Container started', '');
|
|
634
|
-
}
|
|
635
|
-
return {};
|
|
636
|
-
});
|
|
637
585
|
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.right({
|
|
638
586
|
id: 'claude-with-args',
|
|
639
587
|
name: 'Claude with Args',
|
|
@@ -657,17 +605,6 @@ describe('SessionManager', () => {
|
|
|
657
605
|
], expect.any(Object));
|
|
658
606
|
});
|
|
659
607
|
it('should use empty args as fallback in devcontainer when no fallback args specified', async () => {
|
|
660
|
-
const mockExec = vi.mocked(exec);
|
|
661
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
662
|
-
if (typeof options === 'function') {
|
|
663
|
-
callback = options;
|
|
664
|
-
options = undefined;
|
|
665
|
-
}
|
|
666
|
-
if (callback && typeof callback === 'function') {
|
|
667
|
-
callback(null, 'Container started', '');
|
|
668
|
-
}
|
|
669
|
-
return {};
|
|
670
|
-
});
|
|
671
608
|
// Setup preset without fallback args
|
|
672
609
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
673
610
|
id: '1',
|
|
@@ -713,23 +650,12 @@ describe('SessionManager', () => {
|
|
|
713
650
|
'claude',
|
|
714
651
|
'--teammate-mode',
|
|
715
652
|
'in-process',
|
|
716
|
-
], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
653
|
+
], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
|
|
717
654
|
// Verify session process was replaced
|
|
718
655
|
expect(session.process).toBe(secondMockPty);
|
|
719
656
|
expect(session.isPrimaryCommand).toBe(false);
|
|
720
657
|
});
|
|
721
658
|
it('should fallback to default command in devcontainer when primary command exits with code 1', async () => {
|
|
722
|
-
const mockExec = vi.mocked(exec);
|
|
723
|
-
mockExec.mockImplementation((cmd, options, callback) => {
|
|
724
|
-
if (typeof options === 'function') {
|
|
725
|
-
callback = options;
|
|
726
|
-
options = undefined;
|
|
727
|
-
}
|
|
728
|
-
if (callback && typeof callback === 'function') {
|
|
729
|
-
callback(null, 'Container started', '');
|
|
730
|
-
}
|
|
731
|
-
return {};
|
|
732
|
-
});
|
|
733
659
|
// Setup preset with args
|
|
734
660
|
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
735
661
|
id: '1',
|
|
@@ -774,7 +700,7 @@ describe('SessionManager', () => {
|
|
|
774
700
|
'claude',
|
|
775
701
|
'--teammate-mode',
|
|
776
702
|
'in-process',
|
|
777
|
-
], expect.objectContaining({ cwd: '/test/worktree' }));
|
|
703
|
+
], expect.objectContaining({ cwd: '/test/worktree', rawMode: false }));
|
|
778
704
|
// Verify session process was replaced
|
|
779
705
|
expect(session.process).toBe(secondMockPty);
|
|
780
706
|
expect(session.isPrimaryCommand).toBe(false);
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { BaseStateDetector } from './base.js';
|
|
2
|
+
// Spinner symbols used by Cursor during active processing
|
|
3
|
+
const CURSOR_SPINNER_CHARS = '⬡⬢';
|
|
4
|
+
// Like Claude's spinner activity: "<symbol> <word>ing…"; Cursor often uses ASCII dots (.. or …)
|
|
5
|
+
const SPINNER_ACTIVITY_PATTERN = new RegExp(`^\\s*[${CURSOR_SPINNER_CHARS}] \\S+ing(?:.*\u2026|.*\\.{2,})`, 'm');
|
|
2
6
|
export class CursorStateDetector extends BaseStateDetector {
|
|
3
7
|
detectState(terminal, _currentState) {
|
|
4
8
|
const content = this.getTerminalContent(terminal, 30);
|
|
@@ -16,6 +20,10 @@ export class CursorStateDetector extends BaseStateDetector {
|
|
|
16
20
|
if (lowerContent.includes('ctrl+c to stop')) {
|
|
17
21
|
return 'busy';
|
|
18
22
|
}
|
|
23
|
+
// Spinner activity (e.g. "⬡ Grepping..", "⬢ Reading…") — case-sensitive on original buffer
|
|
24
|
+
if (SPINNER_ACTIVITY_PATTERN.test(content)) {
|
|
25
|
+
return 'busy';
|
|
26
|
+
}
|
|
19
27
|
// Otherwise idle - Priority 3
|
|
20
28
|
return 'idle';
|
|
21
29
|
}
|
|
@@ -138,6 +138,18 @@ describe('CursorStateDetector', () => {
|
|
|
138
138
|
// Assert
|
|
139
139
|
expect(state).toBe('busy');
|
|
140
140
|
});
|
|
141
|
+
it('should detect busy state for spinner activity (⬡ …ing..)', () => {
|
|
142
|
+
terminal = createMockTerminal([' ⬡ Grepping..', 'Some footer']);
|
|
143
|
+
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
144
|
+
});
|
|
145
|
+
it('should detect busy state for spinner activity (⬢ …ing...)', () => {
|
|
146
|
+
terminal = createMockTerminal([' ⬢ Reading...']);
|
|
147
|
+
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
148
|
+
});
|
|
149
|
+
it('should detect busy state for spinner activity with Unicode ellipsis', () => {
|
|
150
|
+
terminal = createMockTerminal(['⬡ Searching\u2026']);
|
|
151
|
+
expect(detector.detectState(terminal, 'idle')).toBe('busy');
|
|
152
|
+
});
|
|
141
153
|
it('should detect idle state when no patterns match', () => {
|
|
142
154
|
// Arrange
|
|
143
155
|
terminal = createMockTerminal(['Normal output', 'Some message', 'Ready']);
|
package/dist/types/index.d.ts
CHANGED
|
@@ -29,6 +29,7 @@ export interface Session {
|
|
|
29
29
|
terminal: Terminal;
|
|
30
30
|
stateCheckInterval: NodeJS.Timeout | undefined;
|
|
31
31
|
isPrimaryCommand: boolean;
|
|
32
|
+
presetName: string | undefined;
|
|
32
33
|
detectionStrategy: StateDetectionStrategy | undefined;
|
|
33
34
|
devcontainerConfig: DevcontainerConfig | undefined;
|
|
34
35
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { basename } from 'path';
|
|
3
3
|
import { Effect } from 'effect';
|
|
4
4
|
import { ProcessError } from '../types/errors.js';
|
|
5
5
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
@@ -149,12 +149,13 @@ export function executeStatusHook(oldState, newState, session) {
|
|
|
149
149
|
// Build environment for status hook
|
|
150
150
|
const environment = {
|
|
151
151
|
CCMANAGER_WORKTREE_PATH: session.worktreePath,
|
|
152
|
-
CCMANAGER_WORKTREE_DIR:
|
|
152
|
+
CCMANAGER_WORKTREE_DIR: basename(session.worktreePath),
|
|
153
153
|
CCMANAGER_WORKTREE_BRANCH: branch,
|
|
154
154
|
CCMANAGER_GIT_ROOT: session.worktreePath, // For status hooks, we use worktree path as cwd
|
|
155
155
|
CCMANAGER_OLD_STATE: oldState,
|
|
156
156
|
CCMANAGER_NEW_STATE: newState,
|
|
157
157
|
CCMANAGER_SESSION_ID: session.id,
|
|
158
|
+
CCMANAGER_PRESET_NAME: session.presetName || '',
|
|
158
159
|
};
|
|
159
160
|
yield* Effect.catchAll(executeHook(hook.command, session.worktreePath, environment), error => {
|
|
160
161
|
// Log error but don't throw - hooks should not break the main flow
|
|
@@ -384,6 +384,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
384
384
|
outputHistory: [],
|
|
385
385
|
stateCheckInterval: undefined,
|
|
386
386
|
isPrimaryCommand: true,
|
|
387
|
+
presetName: undefined,
|
|
387
388
|
detectionStrategy: 'claude',
|
|
388
389
|
devcontainerConfig: undefined,
|
|
389
390
|
lastActivity: new Date(),
|
|
@@ -440,6 +441,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
440
441
|
outputHistory: [],
|
|
441
442
|
stateCheckInterval: undefined,
|
|
442
443
|
isPrimaryCommand: true,
|
|
444
|
+
presetName: undefined,
|
|
443
445
|
detectionStrategy: 'claude',
|
|
444
446
|
devcontainerConfig: undefined,
|
|
445
447
|
lastActivity: new Date(),
|
|
@@ -494,6 +496,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
494
496
|
outputHistory: [],
|
|
495
497
|
stateCheckInterval: undefined,
|
|
496
498
|
isPrimaryCommand: true,
|
|
499
|
+
presetName: undefined,
|
|
497
500
|
detectionStrategy: 'claude',
|
|
498
501
|
devcontainerConfig: undefined,
|
|
499
502
|
lastActivity: new Date(),
|
|
@@ -550,6 +553,7 @@ describe('hookExecutor Integration Tests', () => {
|
|
|
550
553
|
outputHistory: [],
|
|
551
554
|
stateCheckInterval: undefined,
|
|
552
555
|
isPrimaryCommand: true,
|
|
556
|
+
presetName: undefined,
|
|
553
557
|
detectionStrategy: 'claude',
|
|
554
558
|
devcontainerConfig: undefined,
|
|
555
559
|
lastActivity: new Date(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.3",
|
|
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.0.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.0.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.0.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.0.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.0.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "4.0.3",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "4.0.3",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "4.0.3",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "4.0.3",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "4.0.3"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|