ccmanager 3.1.4 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +85 -0
- package/dist/cli.test.js +9 -10
- package/dist/components/App.test.js +6 -4
- package/dist/components/DeleteWorktree.test.js +22 -10
- package/dist/components/Menu.recent-projects.test.js +22 -0
- package/dist/components/Menu.test.js +5 -3
- package/dist/components/MergeWorktree.test.js +12 -4
- package/dist/components/NewWorktree.test.js +57 -39
- package/dist/components/ProjectList.recent-projects.test.js +5 -3
- package/dist/components/ProjectList.test.js +25 -3
- package/dist/components/Session.js +6 -5
- package/dist/services/bunTerminal.d.ts +53 -0
- package/dist/services/bunTerminal.js +175 -0
- package/dist/services/sessionManager.autoApproval.test.js +73 -41
- package/dist/services/sessionManager.effect.test.js +17 -13
- package/dist/services/sessionManager.js +122 -76
- package/dist/services/sessionManager.statePersistence.test.js +64 -62
- package/dist/services/sessionManager.test.js +44 -23
- package/dist/types/index.d.ts +8 -7
- package/dist/utils/hookExecutor.test.js +55 -54
- package/dist/utils/mutex.d.ts +54 -0
- package/dist/utils/mutex.js +104 -0
- package/dist/utils/worktreeUtils.js +3 -1
- package/dist/utils/worktreeUtils.test.js +5 -4
- package/package.json +39 -29
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BunTerminal - A wrapper around Bun's built-in Terminal API
|
|
3
|
+
* that provides an interface compatible with the IPty interface.
|
|
4
|
+
*
|
|
5
|
+
* This replaces @skitee3000/bun-pty to avoid native library issues
|
|
6
|
+
* when running compiled Bun binaries.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* BunTerminal class that wraps Bun's built-in Terminal API.
|
|
10
|
+
*/
|
|
11
|
+
class BunTerminal {
|
|
12
|
+
constructor(file, args, options) {
|
|
13
|
+
Object.defineProperty(this, "_pid", {
|
|
14
|
+
enumerable: true,
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
value: -1
|
|
18
|
+
});
|
|
19
|
+
Object.defineProperty(this, "_cols", {
|
|
20
|
+
enumerable: true,
|
|
21
|
+
configurable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
value: void 0
|
|
24
|
+
});
|
|
25
|
+
Object.defineProperty(this, "_rows", {
|
|
26
|
+
enumerable: true,
|
|
27
|
+
configurable: true,
|
|
28
|
+
writable: true,
|
|
29
|
+
value: void 0
|
|
30
|
+
});
|
|
31
|
+
Object.defineProperty(this, "_process", {
|
|
32
|
+
enumerable: true,
|
|
33
|
+
configurable: true,
|
|
34
|
+
writable: true,
|
|
35
|
+
value: void 0
|
|
36
|
+
});
|
|
37
|
+
Object.defineProperty(this, "_closed", {
|
|
38
|
+
enumerable: true,
|
|
39
|
+
configurable: true,
|
|
40
|
+
writable: true,
|
|
41
|
+
value: false
|
|
42
|
+
});
|
|
43
|
+
Object.defineProperty(this, "_dataListeners", {
|
|
44
|
+
enumerable: true,
|
|
45
|
+
configurable: true,
|
|
46
|
+
writable: true,
|
|
47
|
+
value: []
|
|
48
|
+
});
|
|
49
|
+
Object.defineProperty(this, "_exitListeners", {
|
|
50
|
+
enumerable: true,
|
|
51
|
+
configurable: true,
|
|
52
|
+
writable: true,
|
|
53
|
+
value: []
|
|
54
|
+
});
|
|
55
|
+
Object.defineProperty(this, "_subprocess", {
|
|
56
|
+
enumerable: true,
|
|
57
|
+
configurable: true,
|
|
58
|
+
writable: true,
|
|
59
|
+
value: null
|
|
60
|
+
});
|
|
61
|
+
Object.defineProperty(this, "onData", {
|
|
62
|
+
enumerable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
writable: true,
|
|
65
|
+
value: (listener) => {
|
|
66
|
+
this._dataListeners.push(listener);
|
|
67
|
+
return {
|
|
68
|
+
dispose: () => {
|
|
69
|
+
const index = this._dataListeners.indexOf(listener);
|
|
70
|
+
if (index !== -1) {
|
|
71
|
+
this._dataListeners.splice(index, 1);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
Object.defineProperty(this, "onExit", {
|
|
78
|
+
enumerable: true,
|
|
79
|
+
configurable: true,
|
|
80
|
+
writable: true,
|
|
81
|
+
value: (listener) => {
|
|
82
|
+
this._exitListeners.push(listener);
|
|
83
|
+
return {
|
|
84
|
+
dispose: () => {
|
|
85
|
+
const index = this._exitListeners.indexOf(listener);
|
|
86
|
+
if (index !== -1) {
|
|
87
|
+
this._exitListeners.splice(index, 1);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
this._cols = options.cols ?? 80;
|
|
94
|
+
this._rows = options.rows ?? 24;
|
|
95
|
+
this._process = file;
|
|
96
|
+
// Spawn the process with Bun's built-in terminal support
|
|
97
|
+
this._subprocess = Bun.spawn([file, ...args], {
|
|
98
|
+
cwd: options.cwd ?? process.cwd(),
|
|
99
|
+
env: options.env,
|
|
100
|
+
terminal: {
|
|
101
|
+
cols: this._cols,
|
|
102
|
+
rows: this._rows,
|
|
103
|
+
data: (_terminal, data) => {
|
|
104
|
+
if (!this._closed) {
|
|
105
|
+
const str = typeof data === 'string'
|
|
106
|
+
? data
|
|
107
|
+
: Buffer.from(data).toString('utf8');
|
|
108
|
+
for (const listener of this._dataListeners) {
|
|
109
|
+
listener(str);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
this._pid = this._subprocess.pid;
|
|
116
|
+
// Handle process exit
|
|
117
|
+
this._subprocess.exited.then(exitCode => {
|
|
118
|
+
if (!this._closed) {
|
|
119
|
+
this._closed = true;
|
|
120
|
+
for (const listener of this._exitListeners) {
|
|
121
|
+
listener({ exitCode });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
get pid() {
|
|
127
|
+
return this._pid;
|
|
128
|
+
}
|
|
129
|
+
get cols() {
|
|
130
|
+
return this._cols;
|
|
131
|
+
}
|
|
132
|
+
get rows() {
|
|
133
|
+
return this._rows;
|
|
134
|
+
}
|
|
135
|
+
get process() {
|
|
136
|
+
return this._process;
|
|
137
|
+
}
|
|
138
|
+
write(data) {
|
|
139
|
+
if (this._closed || !this._subprocess?.terminal) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this._subprocess.terminal.write(data);
|
|
143
|
+
}
|
|
144
|
+
resize(columns, rows) {
|
|
145
|
+
if (this._closed || !this._subprocess?.terminal) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
this._cols = columns;
|
|
149
|
+
this._rows = rows;
|
|
150
|
+
this._subprocess.terminal.resize(columns, rows);
|
|
151
|
+
}
|
|
152
|
+
kill(_signal) {
|
|
153
|
+
if (this._closed) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this._closed = true;
|
|
157
|
+
if (this._subprocess?.terminal) {
|
|
158
|
+
this._subprocess.terminal.close();
|
|
159
|
+
}
|
|
160
|
+
if (this._subprocess) {
|
|
161
|
+
this._subprocess.kill();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Spawn a new PTY process using Bun's built-in Terminal API.
|
|
167
|
+
*
|
|
168
|
+
* @param file - The command to execute
|
|
169
|
+
* @param args - Arguments to pass to the command
|
|
170
|
+
* @param options - PTY fork options
|
|
171
|
+
* @returns An IPty instance
|
|
172
|
+
*/
|
|
173
|
+
export function spawn(file, args, options) {
|
|
174
|
+
return new BunTerminal(file, args, options);
|
|
175
|
+
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { EventEmitter } from 'events';
|
|
3
|
-
import { spawn } from '
|
|
3
|
+
import { spawn } from './bunTerminal.js';
|
|
4
4
|
import { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
|
|
5
5
|
import { Effect } from 'effect';
|
|
6
6
|
const detectStateMock = vi.fn();
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// Create a deferred promise pattern for controllable mock
|
|
8
|
+
let verifyResolve = null;
|
|
9
|
+
const verifyNeedsPermissionMock = vi.fn(() => Effect.promise(() => new Promise(resolve => {
|
|
10
|
+
verifyResolve = resolve;
|
|
11
|
+
})));
|
|
12
|
+
vi.mock('./bunTerminal.js', () => ({
|
|
13
|
+
spawn: vi.fn(function () {
|
|
14
|
+
return null;
|
|
15
|
+
}),
|
|
10
16
|
}));
|
|
11
17
|
vi.mock('./stateDetector.js', () => ({
|
|
12
18
|
createStateDetector: () => ({ detectState: detectStateMock }),
|
|
@@ -47,15 +53,17 @@ vi.mock('./configurationManager.js', () => ({
|
|
|
47
53
|
}));
|
|
48
54
|
vi.mock('@xterm/headless', () => ({
|
|
49
55
|
default: {
|
|
50
|
-
Terminal: vi.fn().mockImplementation(()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
Terminal: vi.fn().mockImplementation(function () {
|
|
57
|
+
return {
|
|
58
|
+
buffer: {
|
|
59
|
+
active: {
|
|
60
|
+
length: 0,
|
|
61
|
+
getLine: vi.fn(),
|
|
62
|
+
},
|
|
55
63
|
},
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
})
|
|
64
|
+
write: vi.fn(),
|
|
65
|
+
};
|
|
66
|
+
}),
|
|
59
67
|
},
|
|
60
68
|
}));
|
|
61
69
|
vi.mock('./autoApprovalVerifier.js', () => ({
|
|
@@ -70,6 +78,7 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
70
78
|
vi.useFakeTimers();
|
|
71
79
|
detectStateMock.mockReset();
|
|
72
80
|
verifyNeedsPermissionMock.mockClear();
|
|
81
|
+
verifyResolve = null;
|
|
73
82
|
mockPtyInstances = new Map();
|
|
74
83
|
eventEmitters = new Map();
|
|
75
84
|
spawn.mockImplementation((_command, _args, options) => {
|
|
@@ -124,36 +133,52 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
124
133
|
it('re-enables auto approval after leaving waiting_input', async () => {
|
|
125
134
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
126
135
|
// Simulate a prior auto-approval failure
|
|
127
|
-
session.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
expect(session.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
expect(session.
|
|
139
|
-
|
|
136
|
+
await session.stateMutex.update(data => ({
|
|
137
|
+
...data,
|
|
138
|
+
autoApprovalFailed: true,
|
|
139
|
+
}));
|
|
140
|
+
// First waiting_input cycle (auto-approval suppressed) (use async to process mutex updates)
|
|
141
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
|
|
142
|
+
expect(session.stateMutex.getSnapshot().state).toBe('waiting_input');
|
|
143
|
+
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(true);
|
|
144
|
+
// Transition back to busy should reset the failure flag (use async to process mutex updates)
|
|
145
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3);
|
|
146
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
147
|
+
expect(session.stateMutex.getSnapshot().autoApprovalFailed).toBe(false);
|
|
148
|
+
// Next waiting_input should trigger pending_auto_approval (use async to process mutex updates)
|
|
149
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
|
|
150
|
+
// State should now be pending_auto_approval (waiting for verification)
|
|
151
|
+
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
140
152
|
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
153
|
+
// Resolve the verification (needsPermission: false means auto-approve)
|
|
154
|
+
expect(verifyResolve).not.toBeNull();
|
|
155
|
+
verifyResolve({ needsPermission: false });
|
|
156
|
+
await Promise.resolve(); // allow handleAutoApproval promise to resolve
|
|
141
157
|
});
|
|
142
158
|
it('cancels auto approval when user input is detected', async () => {
|
|
143
159
|
const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
|
|
144
160
|
const abortController = new AbortController();
|
|
145
|
-
session.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
await session.stateMutex.update(data => ({
|
|
162
|
+
...data,
|
|
163
|
+
state: 'pending_auto_approval',
|
|
164
|
+
autoApprovalAbortController: abortController,
|
|
165
|
+
pendingState: 'pending_auto_approval',
|
|
166
|
+
pendingStateStart: Date.now(),
|
|
167
|
+
}));
|
|
149
168
|
const handler = vi.fn();
|
|
150
169
|
sessionManager.on('sessionStateChanged', handler);
|
|
151
170
|
sessionManager.cancelAutoApproval(session.worktreePath, 'User pressed a key');
|
|
171
|
+
// Wait for async mutex update to complete (use vi.waitFor for proper async handling)
|
|
172
|
+
await vi.waitFor(() => {
|
|
173
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
174
|
+
expect(stateData.autoApprovalAbortController).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
const stateData = session.stateMutex.getSnapshot();
|
|
152
177
|
expect(abortController.signal.aborted).toBe(true);
|
|
153
|
-
expect(
|
|
154
|
-
expect(
|
|
155
|
-
expect(
|
|
156
|
-
expect(
|
|
178
|
+
expect(stateData.autoApprovalAbortController).toBeUndefined();
|
|
179
|
+
expect(stateData.autoApprovalFailed).toBe(true);
|
|
180
|
+
expect(stateData.state).toBe('waiting_input');
|
|
181
|
+
expect(stateData.pendingState).toBeUndefined();
|
|
157
182
|
expect(handler).toHaveBeenCalledWith(session);
|
|
158
183
|
sessionManager.off('sessionStateChanged', handler);
|
|
159
184
|
});
|
|
@@ -163,19 +188,26 @@ describe('SessionManager - Auto Approval Recovery', () => {
|
|
|
163
188
|
expect(mockPty).toBeDefined();
|
|
164
189
|
const handler = vi.fn();
|
|
165
190
|
sessionManager.on('sessionStateChanged', handler);
|
|
166
|
-
// Advance to pending_auto_approval state
|
|
167
|
-
vi.
|
|
168
|
-
|
|
191
|
+
// Advance to pending_auto_approval state (use async to process mutex updates)
|
|
192
|
+
await vi.advanceTimersByTimeAsync(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
|
|
193
|
+
// State should be pending_auto_approval (waiting for verification)
|
|
194
|
+
expect(session.stateMutex.getSnapshot().state).toBe('pending_auto_approval');
|
|
195
|
+
expect(verifyNeedsPermissionMock).toHaveBeenCalled();
|
|
196
|
+
// Resolve the verification (needsPermission: false means auto-approve)
|
|
197
|
+
expect(verifyResolve).not.toBeNull();
|
|
198
|
+
verifyResolve({ needsPermission: false });
|
|
169
199
|
// Wait for handleAutoApproval promise chain to fully resolve
|
|
170
200
|
await vi.waitFor(() => {
|
|
171
|
-
expect(session.state).toBe('busy');
|
|
201
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
172
202
|
});
|
|
173
|
-
expect(session.pendingState).toBeUndefined();
|
|
174
|
-
expect(session.pendingStateStart).toBeUndefined();
|
|
203
|
+
expect(session.stateMutex.getSnapshot().pendingState).toBeUndefined();
|
|
204
|
+
expect(session.stateMutex.getSnapshot().pendingStateStart).toBeUndefined();
|
|
175
205
|
// Verify Enter key was sent to approve
|
|
176
206
|
expect(mockPty.write).toHaveBeenCalledWith('\r');
|
|
177
|
-
// Verify sessionStateChanged was emitted
|
|
178
|
-
|
|
207
|
+
// Verify sessionStateChanged was emitted with session containing state=busy
|
|
208
|
+
const lastCall = handler.mock.calls[handler.mock.calls.length - 1];
|
|
209
|
+
expect(lastCall).toBeDefined();
|
|
210
|
+
expect(lastCall[0].stateMutex.getSnapshot().state).toBe('busy');
|
|
179
211
|
sessionManager.off('sessionStateChanged', handler);
|
|
180
212
|
});
|
|
181
213
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { Effect, Either } from 'effect';
|
|
3
|
-
import { spawn } from '
|
|
3
|
+
import { spawn } from './bunTerminal.js';
|
|
4
4
|
import { EventEmitter } from 'events';
|
|
5
|
-
// Mock
|
|
6
|
-
vi.mock('
|
|
7
|
-
spawn: vi.fn()
|
|
5
|
+
// Mock bunTerminal
|
|
6
|
+
vi.mock('./bunTerminal.js', () => ({
|
|
7
|
+
spawn: vi.fn(function () {
|
|
8
|
+
return null;
|
|
9
|
+
}),
|
|
8
10
|
}));
|
|
9
11
|
// Mock child_process
|
|
10
12
|
vi.mock('child_process', () => ({
|
|
@@ -24,15 +26,17 @@ vi.mock('./configurationManager.js', () => ({
|
|
|
24
26
|
// Mock Terminal
|
|
25
27
|
vi.mock('@xterm/headless', () => ({
|
|
26
28
|
default: {
|
|
27
|
-
Terminal: vi.fn().mockImplementation(()
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
Terminal: vi.fn().mockImplementation(function () {
|
|
30
|
+
return {
|
|
31
|
+
buffer: {
|
|
32
|
+
active: {
|
|
33
|
+
length: 0,
|
|
34
|
+
getLine: vi.fn(),
|
|
35
|
+
},
|
|
32
36
|
},
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
})
|
|
37
|
+
write: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
}),
|
|
36
40
|
},
|
|
37
41
|
}));
|
|
38
42
|
// Create a mock IPty class
|
|
@@ -110,7 +114,7 @@ describe('SessionManager Effect-based Operations', () => {
|
|
|
110
114
|
const session = await Effect.runPromise(effect);
|
|
111
115
|
expect(session).toBeDefined();
|
|
112
116
|
expect(session.worktreePath).toBe('/test/worktree');
|
|
113
|
-
expect(session.state).toBe('busy');
|
|
117
|
+
expect(session.stateMutex.getSnapshot().state).toBe('busy');
|
|
114
118
|
});
|
|
115
119
|
it('should return Effect that fails with ConfigError when preset not found', async () => {
|
|
116
120
|
// Setup mocks - both return null/undefined
|