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.
@@ -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 'node-pty';
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
- const verifyNeedsPermissionMock = vi.fn(() => Effect.succeed({ needsPermission: false }));
8
- vi.mock('node-pty', () => ({
9
- spawn: vi.fn(),
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
- buffer: {
52
- active: {
53
- length: 0,
54
- getLine: vi.fn(),
56
+ Terminal: vi.fn().mockImplementation(function () {
57
+ return {
58
+ buffer: {
59
+ active: {
60
+ length: 0,
61
+ getLine: vi.fn(),
62
+ },
55
63
  },
56
- },
57
- write: vi.fn(),
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.autoApprovalFailed = true;
128
- // First waiting_input cycle (auto-approval suppressed)
129
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3);
130
- expect(session.state).toBe('waiting_input');
131
- expect(session.autoApprovalFailed).toBe(true);
132
- // Transition back to busy should reset the failure flag
133
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3);
134
- expect(session.state).toBe('busy');
135
- expect(session.autoApprovalFailed).toBe(false);
136
- // Next waiting_input should trigger pending_auto_approval
137
- vi.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
138
- expect(session.state).toBe('pending_auto_approval');
139
- await Promise.resolve(); // allow handleAutoApproval promise to resolve
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.state = 'pending_auto_approval';
146
- session.autoApprovalAbortController = abortController;
147
- session.pendingState = 'pending_auto_approval';
148
- session.pendingStateStart = Date.now();
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(session.autoApprovalAbortController).toBeUndefined();
154
- expect(session.autoApprovalFailed).toBe(true);
155
- expect(session.state).toBe('waiting_input');
156
- expect(session.pendingState).toBeUndefined();
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.advanceTimersByTime(STATE_CHECK_INTERVAL_MS * 3 + STATE_PERSISTENCE_DURATION_MS);
168
- expect(session.state).toBe('pending_auto_approval');
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
- expect(handler).toHaveBeenCalledWith(expect.objectContaining({ state: 'busy' }));
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 'node-pty';
3
+ import { spawn } from './bunTerminal.js';
4
4
  import { EventEmitter } from 'events';
5
- // Mock node-pty
6
- vi.mock('node-pty', () => ({
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
- buffer: {
29
- active: {
30
- length: 0,
31
- getLine: vi.fn(),
29
+ Terminal: vi.fn().mockImplementation(function () {
30
+ return {
31
+ buffer: {
32
+ active: {
33
+ length: 0,
34
+ getLine: vi.fn(),
35
+ },
32
36
  },
33
- },
34
- write: vi.fn(),
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