ccmanager 2.11.6 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/components/Configuration.js +14 -0
  2. package/dist/components/ConfigureCustomCommand.d.ts +9 -0
  3. package/dist/components/ConfigureCustomCommand.js +44 -0
  4. package/dist/components/ConfigureOther.d.ts +6 -0
  5. package/dist/components/ConfigureOther.js +113 -0
  6. package/dist/components/ConfigureOther.test.d.ts +1 -0
  7. package/dist/components/ConfigureOther.test.js +80 -0
  8. package/dist/components/ConfigureStatusHooks.js +7 -1
  9. package/dist/components/ConfigureTimeout.d.ts +9 -0
  10. package/dist/components/ConfigureTimeout.js +42 -0
  11. package/dist/components/CustomCommandSummary.d.ts +6 -0
  12. package/dist/components/CustomCommandSummary.js +10 -0
  13. package/dist/components/Menu.recent-projects.test.js +2 -0
  14. package/dist/components/Menu.test.js +2 -0
  15. package/dist/components/Session.d.ts +2 -2
  16. package/dist/components/Session.js +67 -4
  17. package/dist/constants/statusIcons.d.ts +3 -1
  18. package/dist/constants/statusIcons.js +3 -0
  19. package/dist/services/autoApprovalVerifier.d.ts +25 -0
  20. package/dist/services/autoApprovalVerifier.js +272 -0
  21. package/dist/services/autoApprovalVerifier.test.d.ts +1 -0
  22. package/dist/services/autoApprovalVerifier.test.js +120 -0
  23. package/dist/services/configurationManager.d.ts +8 -0
  24. package/dist/services/configurationManager.js +56 -0
  25. package/dist/services/sessionManager.autoApproval.test.d.ts +1 -0
  26. package/dist/services/sessionManager.autoApproval.test.js +160 -0
  27. package/dist/services/sessionManager.d.ts +5 -0
  28. package/dist/services/sessionManager.js +149 -1
  29. package/dist/services/sessionManager.statePersistence.test.js +2 -0
  30. package/dist/services/sessionManager.test.js +6 -0
  31. package/dist/types/index.d.ts +15 -1
  32. package/dist/utils/hookExecutor.test.js +8 -0
  33. package/dist/utils/logger.d.ts +83 -14
  34. package/dist/utils/logger.js +218 -17
  35. package/dist/utils/worktreeUtils.test.js +1 -0
  36. package/package.json +1 -1
@@ -0,0 +1,272 @@
1
+ import { Effect } from 'effect';
2
+ import { ProcessError } from '../types/errors.js';
3
+ import { configurationManager } from './configurationManager.js';
4
+ import { logger } from '../utils/logger.js';
5
+ import { execFile, spawn, } from 'child_process';
6
+ const DEFAULT_TIMEOUT_SECONDS = 30;
7
+ const getTimeoutMs = () => {
8
+ const config = configurationManager.getAutoApprovalConfig();
9
+ const timeoutSeconds = config.timeout ?? DEFAULT_TIMEOUT_SECONDS;
10
+ return timeoutSeconds * 1000;
11
+ };
12
+ const createAbortError = () => {
13
+ const error = new Error('Auto-approval verification aborted');
14
+ error.name = 'AbortError';
15
+ return error;
16
+ };
17
+ const PLACEHOLDER = {
18
+ terminal: '{{TERMINAL_OUTPUT}}',
19
+ };
20
+ const PROMPT_TEMPLATE = `You are a safety gate preventing risky auto-approvals of CLI actions. Examine the terminal output below and decide if the agent must pause for user permission.
21
+
22
+ Terminal Output:
23
+ ${PLACEHOLDER.terminal}
24
+
25
+ Return true (permission needed) if ANY of these apply:
26
+ - Output includes or references commands that write/modify/delete files (e.g., rm, mv, chmod, chown, cp, tee, sed -i), manage packages (npm/pip/apt/brew install), change git history, or alter configs.
27
+ - Privilege escalation or sensitive areas are involved (sudo, root, /etc, /var, /boot, system services), or anything touching SSH keys/credentials, browser data, environment secrets, or home dotfiles.
28
+ - Network or data exfiltration is possible (curl/wget, ssh/scp/rsync, docker/podman, port binding, npm publish, git push/fetch from unknown hosts).
29
+ - Process/system impact is likely (kill, pkill, systemctl, reboot, heavy loops, resource-intensive builds/tests, spawning many processes).
30
+ - Signs of command injection, untrusted input being executed, or unclear placeholders like \`<path>\`, \`$(...)\`, backticks, or pipes that could be unsafe.
31
+ - Errors, warnings, ambiguous states, manual review requests, or anything not clearly safe/read-only.
32
+
33
+ Return false (auto-approve) when:
34
+ - The output clearly shows explicit user intent/confirmation to run the exact action (e.g., user typed the command AND confirmed, or explicitly said “I want to delete <path>; please do it now”). Explicit intent should normally override the risk list unless there are signs of coercion/compromise, the target path is unclear, or the action differs from what was confirmed.
35
+ - The output shows strictly read-only, low-risk operations (e.g., lint/test passing, help text, formatting dry runs, simple logs) with no pending commands that could change the system or touch sensitive data.
36
+
37
+ When unsure, return true.
38
+
39
+ Respond with ONLY valid JSON matching: {"needsPermission": true|false, "reason"?: string}. When needsPermission is true, include a brief reason (<=140 chars) explaining why permission is needed. Do not add any other fields or text.`;
40
+ const buildPrompt = (terminalOutput) => PROMPT_TEMPLATE.replace(PLACEHOLDER.terminal, terminalOutput);
41
+ /**
42
+ * Service to verify if auto-approval should be granted for pending states
43
+ * Uses Claude Haiku model to analyze terminal output and determine if
44
+ * user permission is required before proceeding
45
+ */
46
+ export class AutoApprovalVerifier {
47
+ constructor() {
48
+ Object.defineProperty(this, "model", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: 'haiku'
53
+ });
54
+ }
55
+ createExecOptions(signal) {
56
+ return {
57
+ encoding: 'utf8',
58
+ maxBuffer: 10 * 1024 * 1024,
59
+ signal,
60
+ };
61
+ }
62
+ runClaudePrompt(prompt, jsonSchema, signal) {
63
+ return new Promise((resolve, reject) => {
64
+ let settled = false;
65
+ let child;
66
+ const execOptions = this.createExecOptions(signal);
67
+ const settle = (action) => {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ removeAbortListener();
72
+ clearTimeout(timeoutId);
73
+ action();
74
+ };
75
+ const abortListener = () => {
76
+ settle(() => {
77
+ if (child?.pid) {
78
+ child.kill('SIGKILL');
79
+ }
80
+ reject(createAbortError());
81
+ });
82
+ };
83
+ const removeAbortListener = () => {
84
+ if (!signal)
85
+ return;
86
+ signal.removeEventListener('abort', abortListener);
87
+ };
88
+ const timeoutMs = getTimeoutMs();
89
+ const timeoutId = setTimeout(() => {
90
+ settle(() => {
91
+ logger.warn('Auto-approval verification timed out, terminating helper Claude process');
92
+ if (child?.pid) {
93
+ child.kill('SIGKILL');
94
+ }
95
+ reject(new Error(`Auto-approval verification timed out after ${timeoutMs / 1000}s`));
96
+ });
97
+ }, timeoutMs);
98
+ if (signal) {
99
+ if (signal.aborted) {
100
+ abortListener();
101
+ return;
102
+ }
103
+ signal.addEventListener('abort', abortListener, { once: true });
104
+ }
105
+ child = execFile('claude', [
106
+ '--model',
107
+ this.model,
108
+ '-p',
109
+ '--output-format',
110
+ 'json',
111
+ '--json-schema',
112
+ jsonSchema,
113
+ ], execOptions, (error, stdout) => {
114
+ settle(() => {
115
+ if (error) {
116
+ reject(error);
117
+ return;
118
+ }
119
+ resolve(stdout);
120
+ });
121
+ });
122
+ child.stderr?.on('data', chunk => {
123
+ logger.debug('Auto-approval stderr chunk', chunk.toString());
124
+ });
125
+ child.on('error', err => {
126
+ settle(() => reject(err));
127
+ });
128
+ if (child.stdin) {
129
+ child.stdin.write(prompt);
130
+ child.stdin.end();
131
+ }
132
+ else {
133
+ settle(() => reject(new Error('claude stdin unavailable')));
134
+ }
135
+ child.on('close', code => {
136
+ if (code && code !== 0) {
137
+ settle(() => reject(new Error(`claude exited with code ${code}`)));
138
+ }
139
+ });
140
+ });
141
+ }
142
+ async runCustomCommand(command, prompt, terminalOutput, signal) {
143
+ return new Promise((resolve, reject) => {
144
+ let settled = false;
145
+ let timeoutId;
146
+ const settle = (action) => {
147
+ if (settled)
148
+ return;
149
+ settled = true;
150
+ clearTimeout(timeoutId);
151
+ if (signal) {
152
+ signal.removeEventListener('abort', abortListener);
153
+ }
154
+ action();
155
+ };
156
+ const abortListener = () => {
157
+ settle(() => {
158
+ child.kill('SIGKILL');
159
+ reject(createAbortError());
160
+ });
161
+ };
162
+ const spawnOptions = {
163
+ shell: true,
164
+ env: {
165
+ ...process.env,
166
+ DEFAULT_PROMPT: prompt,
167
+ TERMINAL_OUTPUT: terminalOutput,
168
+ },
169
+ stdio: ['ignore', 'pipe', 'pipe'],
170
+ signal,
171
+ };
172
+ const child = spawn(command, [], spawnOptions);
173
+ let stdout = '';
174
+ let stderr = '';
175
+ const timeoutMs = getTimeoutMs();
176
+ timeoutId = setTimeout(() => {
177
+ logger.warn('Auto-approval custom command timed out, terminating process');
178
+ settle(() => {
179
+ child.kill('SIGKILL');
180
+ reject(new Error(`Auto-approval verification custom command timed out after ${timeoutMs / 1000}s`));
181
+ });
182
+ }, timeoutMs);
183
+ if (signal) {
184
+ if (signal.aborted) {
185
+ abortListener();
186
+ return;
187
+ }
188
+ signal.addEventListener('abort', abortListener, { once: true });
189
+ }
190
+ child.stdout?.on('data', chunk => {
191
+ stdout += chunk.toString();
192
+ });
193
+ child.stderr?.on('data', chunk => {
194
+ const data = chunk.toString();
195
+ stderr += data;
196
+ logger.debug('Auto-approval custom command stderr', data);
197
+ });
198
+ child.on('error', error => {
199
+ settle(() => reject(error));
200
+ });
201
+ child.on('exit', (code, signalExit) => {
202
+ settle(() => {
203
+ if (code === 0) {
204
+ resolve(stdout);
205
+ return;
206
+ }
207
+ const message = signalExit !== null
208
+ ? `Custom command terminated by signal ${signalExit}`
209
+ : `Custom command exited with code ${code}`;
210
+ reject(new Error(stderr ? `${message}\nStderr: ${stderr}` : message));
211
+ });
212
+ });
213
+ });
214
+ }
215
+ /**
216
+ * Verify if the current terminal output requires user permission
217
+ * before proceeding with auto-approval
218
+ *
219
+ * @param terminalOutput - Current terminal output to analyze
220
+ * @returns Effect that resolves to true if permission needed, false if can auto-approve
221
+ */
222
+ verifyNeedsPermission(terminalOutput, options) {
223
+ const attemptVerification = Effect.tryPromise({
224
+ try: async () => {
225
+ const autoApprovalConfig = configurationManager.getAutoApprovalConfig();
226
+ const customCommand = autoApprovalConfig.customCommand?.trim();
227
+ const prompt = buildPrompt(terminalOutput);
228
+ const jsonSchema = JSON.stringify({
229
+ type: 'object',
230
+ properties: {
231
+ needsPermission: {
232
+ type: 'boolean',
233
+ description: 'Whether user permission is needed before auto-approval',
234
+ },
235
+ reason: {
236
+ type: 'string',
237
+ description: 'Optional reason describing why user permission is needed',
238
+ },
239
+ },
240
+ required: ['needsPermission'],
241
+ });
242
+ const signal = options?.signal;
243
+ if (signal?.aborted) {
244
+ throw createAbortError();
245
+ }
246
+ const responseText = customCommand
247
+ ? await this.runCustomCommand(customCommand, prompt, terminalOutput, signal)
248
+ : await this.runClaudePrompt(prompt, jsonSchema, signal);
249
+ return JSON.parse(responseText);
250
+ },
251
+ catch: (error) => error,
252
+ });
253
+ return Effect.catchAll(attemptVerification, (error) => {
254
+ if (error.name === 'AbortError') {
255
+ return Effect.fail(new ProcessError({
256
+ command: 'autoApprovalVerifier.verifyNeedsPermission',
257
+ message: 'Auto-approval verification aborted',
258
+ }));
259
+ }
260
+ const isParseError = error instanceof SyntaxError;
261
+ const reason = isParseError
262
+ ? 'Failed to parse auto-approval helper response'
263
+ : 'Auto-approval helper command failed';
264
+ logger.error(reason, error);
265
+ return Effect.succeed({
266
+ needsPermission: true,
267
+ reason: `${reason}: ${error.message ?? 'unknown error'}`,
268
+ });
269
+ });
270
+ }
271
+ }
272
+ export const autoApprovalVerifier = new AutoApprovalVerifier();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { Effect } from 'effect';
3
+ import { EventEmitter } from 'events';
4
+ const execFileMock = vi.fn();
5
+ vi.mock('child_process', () => ({
6
+ execFile: (...args) => execFileMock(...args),
7
+ }));
8
+ vi.mock('./configurationManager.js', () => ({
9
+ configurationManager: {
10
+ getAutoApprovalConfig: vi.fn().mockReturnValue({ enabled: false }),
11
+ },
12
+ }));
13
+ describe('AutoApprovalVerifier', () => {
14
+ beforeEach(() => {
15
+ vi.useFakeTimers();
16
+ execFileMock.mockImplementation((_cmd, _args, _options, callback) => {
17
+ const child = new EventEmitter();
18
+ const write = vi.fn();
19
+ const end = vi.fn();
20
+ child.stdin = { write, end };
21
+ setTimeout(() => {
22
+ callback(null, '{"needsPermission":false}', '');
23
+ child.emit('close', 0);
24
+ }, 5);
25
+ return child;
26
+ });
27
+ });
28
+ afterEach(() => {
29
+ vi.useRealTimers();
30
+ vi.clearAllMocks();
31
+ });
32
+ it('executes claude check asynchronously without blocking input', async () => {
33
+ const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
34
+ let ticked = false;
35
+ setTimeout(() => {
36
+ ticked = true;
37
+ }, 1);
38
+ const needsPermissionPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission('output'));
39
+ await vi.runAllTimersAsync();
40
+ const result = await needsPermissionPromise;
41
+ expect(result.needsPermission).toBe(false);
42
+ expect(ticked).toBe(true);
43
+ const child = execFileMock.mock.results[0]?.value;
44
+ expect(execFileMock).toHaveBeenCalledWith('claude', expect.arrayContaining(['--model', 'haiku']), expect.objectContaining({ encoding: 'utf8' }), expect.any(Function));
45
+ expect(child.stdin.write).toHaveBeenCalledTimes(1);
46
+ expect(child.stdin.end).toHaveBeenCalledTimes(1);
47
+ });
48
+ it('returns true when Claude response indicates permission is needed', async () => {
49
+ execFileMock.mockImplementationOnce((_cmd, _args, _options, callback) => {
50
+ const child = new EventEmitter();
51
+ child.stdin = { write: vi.fn(), end: vi.fn() };
52
+ setTimeout(() => {
53
+ callback(null, '{"needsPermission":true}', '');
54
+ child.emit('close', 0);
55
+ }, 0);
56
+ return child;
57
+ });
58
+ const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
59
+ const resultPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission('Error: critical'));
60
+ await vi.runAllTimersAsync();
61
+ const result = await resultPromise;
62
+ expect(result.needsPermission).toBe(true);
63
+ });
64
+ it('defaults to requiring permission on malformed JSON', async () => {
65
+ execFileMock.mockImplementationOnce((_cmd, _args, _options, callback) => {
66
+ const child = new EventEmitter();
67
+ child.stdin = { write: vi.fn(), end: vi.fn() };
68
+ setTimeout(() => {
69
+ callback(null, 'not-json', '');
70
+ child.emit('close', 0);
71
+ }, 0);
72
+ return child;
73
+ });
74
+ const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
75
+ const resultPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission('logs'));
76
+ await vi.runAllTimersAsync();
77
+ const result = await resultPromise;
78
+ expect(result.needsPermission).toBe(true);
79
+ expect(result.reason).toBeDefined();
80
+ });
81
+ it('defaults to requiring permission when execution errors', async () => {
82
+ execFileMock.mockImplementationOnce((_cmd, _args, _options, callback) => {
83
+ const child = new EventEmitter();
84
+ child.stdin = { write: vi.fn(), end: vi.fn() };
85
+ setTimeout(() => {
86
+ callback(new Error('Command failed'), '', '');
87
+ child.emit('close', 1);
88
+ }, 0);
89
+ return child;
90
+ });
91
+ const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
92
+ const resultPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission('logs'));
93
+ await vi.runAllTimersAsync();
94
+ const result = await resultPromise;
95
+ expect(result.needsPermission).toBe(true);
96
+ expect(result.reason).toBeDefined();
97
+ });
98
+ it('passes JSON schema flag and prompt content to claude helper', async () => {
99
+ const write = vi.fn();
100
+ const terminalOutput = 'test output';
101
+ execFileMock.mockImplementationOnce((_cmd, args, _options, callback) => {
102
+ const child = new EventEmitter();
103
+ child.stdin = { write, end: vi.fn() };
104
+ setTimeout(() => {
105
+ callback(null, '{"needsPermission":false}', '');
106
+ child.emit('close', 0);
107
+ }, 0);
108
+ // Capture the args for assertions
109
+ child.capturedArgs = args;
110
+ return child;
111
+ });
112
+ const { autoApprovalVerifier } = await import('./autoApprovalVerifier.js');
113
+ const resultPromise = Effect.runPromise(autoApprovalVerifier.verifyNeedsPermission(terminalOutput));
114
+ await vi.runAllTimersAsync();
115
+ await resultPromise;
116
+ const args = execFileMock.mock.calls[0]?.[1] ?? [];
117
+ expect(args).toEqual(expect.arrayContaining(['--output-format', 'json', '--json-schema']));
118
+ expect(write).toHaveBeenCalledWith(expect.stringContaining(terminalOutput));
119
+ });
120
+ });
@@ -21,6 +21,10 @@ export declare class ConfigurationManager {
21
21
  setConfiguration(config: ConfigurationData): void;
22
22
  getWorktreeConfig(): WorktreeConfig;
23
23
  setWorktreeConfig(worktreeConfig: WorktreeConfig): void;
24
+ getAutoApprovalConfig(): NonNullable<ConfigurationData['autoApproval']>;
25
+ setAutoApprovalConfig(autoApproval: NonNullable<ConfigurationData['autoApproval']>): void;
26
+ setAutoApprovalEnabled(enabled: boolean): void;
27
+ setAutoApprovalTimeout(timeout: number): void;
24
28
  getCommandConfig(): CommandConfig;
25
29
  setCommandConfig(commandConfig: CommandConfig): void;
26
30
  private migrateLegacyCommandToPresets;
@@ -109,5 +113,9 @@ export declare class ConfigurationManager {
109
113
  * Synchronous legacy shortcuts migration helper
110
114
  */
111
115
  private migrateLegacyShortcutsSync;
116
+ /**
117
+ * Get whether auto-approval is enabled
118
+ */
119
+ isAutoApprovalEnabled(): boolean;
112
120
  }
113
121
  export declare const configurationManager: ConfigurationManager;
@@ -99,6 +99,20 @@ export class ConfigurationManager {
99
99
  command: 'claude',
100
100
  };
101
101
  }
102
+ if (!this.config.autoApproval) {
103
+ this.config.autoApproval = {
104
+ enabled: false,
105
+ timeout: 30,
106
+ };
107
+ }
108
+ else {
109
+ if (!Object.prototype.hasOwnProperty.call(this.config.autoApproval, 'enabled')) {
110
+ this.config.autoApproval.enabled = false;
111
+ }
112
+ if (!Object.prototype.hasOwnProperty.call(this.config.autoApproval, 'timeout')) {
113
+ this.config.autoApproval.timeout = 30;
114
+ }
115
+ }
102
116
  // Migrate legacy command config to presets if needed
103
117
  this.migrateLegacyCommandToPresets();
104
118
  }
@@ -165,6 +179,28 @@ export class ConfigurationManager {
165
179
  this.config.worktree = worktreeConfig;
166
180
  this.saveConfig();
167
181
  }
182
+ getAutoApprovalConfig() {
183
+ const config = this.config.autoApproval || {
184
+ enabled: false,
185
+ };
186
+ // Default timeout to 30 seconds if not set
187
+ return {
188
+ ...config,
189
+ timeout: config.timeout ?? 30,
190
+ };
191
+ }
192
+ setAutoApprovalConfig(autoApproval) {
193
+ this.config.autoApproval = autoApproval;
194
+ this.saveConfig();
195
+ }
196
+ setAutoApprovalEnabled(enabled) {
197
+ const currentConfig = this.getAutoApprovalConfig();
198
+ this.setAutoApprovalConfig({ ...currentConfig, enabled });
199
+ }
200
+ setAutoApprovalTimeout(timeout) {
201
+ const currentConfig = this.getAutoApprovalConfig();
202
+ this.setAutoApprovalConfig({ ...currentConfig, timeout });
203
+ }
168
204
  getCommandConfig() {
169
205
  // For backward compatibility, return the default preset as CommandConfig
170
206
  const defaultPreset = this.getDefaultPreset();
@@ -511,6 +547,20 @@ export class ConfigurationManager {
511
547
  command: 'claude',
512
548
  };
513
549
  }
550
+ if (!config.autoApproval) {
551
+ config.autoApproval = {
552
+ enabled: false,
553
+ timeout: 30,
554
+ };
555
+ }
556
+ else {
557
+ if (!Object.prototype.hasOwnProperty.call(config.autoApproval, 'enabled')) {
558
+ config.autoApproval.enabled = false;
559
+ }
560
+ if (!Object.prototype.hasOwnProperty.call(config.autoApproval, 'timeout')) {
561
+ config.autoApproval.timeout = 30;
562
+ }
563
+ }
514
564
  return config;
515
565
  }
516
566
  /**
@@ -537,5 +587,11 @@ export class ConfigurationManager {
537
587
  }
538
588
  return null;
539
589
  }
590
+ /**
591
+ * Get whether auto-approval is enabled
592
+ */
593
+ isAutoApprovalEnabled() {
594
+ return this.config.autoApproval?.enabled ?? false;
595
+ }
540
596
  }
541
597
  export const configurationManager = new ConfigurationManager();
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { EventEmitter } from 'events';
3
+ import { spawn } from 'node-pty';
4
+ import { STATE_CHECK_INTERVAL_MS, STATE_PERSISTENCE_DURATION_MS, } from '../constants/statePersistence.js';
5
+ import { Effect } from 'effect';
6
+ const detectStateMock = vi.fn();
7
+ const verifyNeedsPermissionMock = vi.fn(() => Effect.succeed({ needsPermission: false }));
8
+ vi.mock('node-pty', () => ({
9
+ spawn: vi.fn(),
10
+ }));
11
+ vi.mock('./stateDetector.js', () => ({
12
+ createStateDetector: () => ({ detectState: detectStateMock }),
13
+ }));
14
+ vi.mock('./configurationManager.js', () => ({
15
+ configurationManager: {
16
+ getConfig: vi.fn().mockReturnValue({
17
+ commands: [
18
+ {
19
+ id: 'test',
20
+ name: 'Test',
21
+ command: 'test',
22
+ args: [],
23
+ },
24
+ ],
25
+ defaultCommandId: 'test',
26
+ }),
27
+ getPresetById: vi.fn().mockReturnValue({
28
+ id: 'test',
29
+ name: 'Test',
30
+ command: 'test',
31
+ args: [],
32
+ }),
33
+ getDefaultPreset: vi.fn().mockReturnValue({
34
+ id: 'test',
35
+ name: 'Test',
36
+ command: 'test',
37
+ args: [],
38
+ }),
39
+ getHooks: vi.fn().mockReturnValue({}),
40
+ getStatusHooks: vi.fn().mockReturnValue({}),
41
+ setWorktreeLastOpened: vi.fn(),
42
+ getWorktreeLastOpenedTime: vi.fn(),
43
+ getWorktreeLastOpened: vi.fn(() => ({})),
44
+ isAutoApprovalEnabled: vi.fn(() => true),
45
+ setAutoApprovalEnabled: vi.fn(),
46
+ },
47
+ }));
48
+ vi.mock('@xterm/headless', () => ({
49
+ default: {
50
+ Terminal: vi.fn().mockImplementation(() => ({
51
+ buffer: {
52
+ active: {
53
+ length: 0,
54
+ getLine: vi.fn(),
55
+ },
56
+ },
57
+ write: vi.fn(),
58
+ })),
59
+ },
60
+ }));
61
+ vi.mock('./autoApprovalVerifier.js', () => ({
62
+ autoApprovalVerifier: { verifyNeedsPermission: verifyNeedsPermissionMock },
63
+ }));
64
+ describe('SessionManager - Auto Approval Recovery', () => {
65
+ let SessionManager;
66
+ let sessionManager;
67
+ let mockPtyInstances;
68
+ let eventEmitters;
69
+ beforeEach(async () => {
70
+ vi.useFakeTimers();
71
+ detectStateMock.mockReset();
72
+ verifyNeedsPermissionMock.mockClear();
73
+ mockPtyInstances = new Map();
74
+ eventEmitters = new Map();
75
+ spawn.mockImplementation((_command, _args, options) => {
76
+ const path = options.cwd;
77
+ const eventEmitter = new EventEmitter();
78
+ eventEmitters.set(path, eventEmitter);
79
+ const mockPty = {
80
+ onData: vi.fn((callback) => {
81
+ eventEmitter.on('data', callback);
82
+ return { dispose: vi.fn() };
83
+ }),
84
+ onExit: vi.fn((callback) => {
85
+ eventEmitter.on('exit', callback);
86
+ return { dispose: vi.fn() };
87
+ }),
88
+ write: vi.fn(),
89
+ resize: vi.fn(),
90
+ kill: vi.fn(),
91
+ process: 'test',
92
+ pid: 12345 + mockPtyInstances.size,
93
+ };
94
+ mockPtyInstances.set(path, mockPty);
95
+ return mockPty;
96
+ });
97
+ // Detection sequence: first prompt (no auto-approval), back to busy, second prompt (should auto-approve)
98
+ const detectionStates = [
99
+ 'waiting_input',
100
+ 'waiting_input',
101
+ 'waiting_input',
102
+ 'busy',
103
+ 'busy',
104
+ 'busy',
105
+ 'waiting_input',
106
+ 'waiting_input',
107
+ 'waiting_input',
108
+ ];
109
+ let callIndex = 0;
110
+ detectStateMock.mockImplementation(() => {
111
+ const state = detectionStates[Math.min(callIndex, detectionStates.length - 1)];
112
+ callIndex++;
113
+ return state;
114
+ });
115
+ const sessionManagerModule = await import('./sessionManager.js');
116
+ SessionManager = sessionManagerModule.SessionManager;
117
+ sessionManager = new SessionManager();
118
+ });
119
+ afterEach(() => {
120
+ sessionManager.destroy();
121
+ vi.useRealTimers();
122
+ vi.clearAllMocks();
123
+ });
124
+ it('re-enables auto approval after leaving waiting_input', async () => {
125
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
126
+ // 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
140
+ expect(verifyNeedsPermissionMock).toHaveBeenCalled();
141
+ });
142
+ it('cancels auto approval when user input is detected', async () => {
143
+ const session = await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/path'));
144
+ 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();
149
+ const handler = vi.fn();
150
+ sessionManager.on('sessionStateChanged', handler);
151
+ sessionManager.cancelAutoApproval(session.worktreePath, 'User pressed a key');
152
+ 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();
157
+ expect(handler).toHaveBeenCalledWith(session);
158
+ sessionManager.off('sessionStateChanged', handler);
159
+ });
160
+ });
@@ -6,6 +6,7 @@ export interface SessionCounts {
6
6
  idle: number;
7
7
  busy: number;
8
8
  waiting_input: number;
9
+ pending_auto_approval: number;
9
10
  total: number;
10
11
  }
11
12
  export declare class SessionManager extends EventEmitter implements ISessionManager {
@@ -14,6 +15,9 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
14
15
  private busyTimers;
15
16
  private spawn;
16
17
  detectTerminalState(session: Session): SessionState;
18
+ private getTerminalContent;
19
+ private handleAutoApproval;
20
+ private cancelAutoApprovalVerification;
17
21
  constructor();
18
22
  private createSessionId;
19
23
  private createTerminal;
@@ -50,6 +54,7 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
50
54
  private cleanupSession;
51
55
  getSession(worktreePath: string): Session | undefined;
52
56
  setSessionActive(worktreePath: string, active: boolean): void;
57
+ cancelAutoApproval(worktreePath: string, reason?: string): void;
53
58
  destroySession(worktreePath: string): void;
54
59
  /**
55
60
  * Terminate session and cleanup resources using Effect-based error handling