@adhdev/daemon-core 0.5.3 → 0.5.6

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 (45) hide show
  1. package/dist/index.d.ts +88 -2
  2. package/dist/index.js +1230 -439
  3. package/dist/index.js.map +1 -1
  4. package/package.json +1 -1
  5. package/providers/_builtin/extension/cline/scripts/read_chat.js +14 -1
  6. package/providers/_builtin/ide/antigravity/scripts/1.106/read_chat.js +24 -1
  7. package/providers/_builtin/ide/antigravity/scripts/1.107/read_chat.js +24 -1
  8. package/providers/_builtin/ide/cursor/scripts/0.49/focus_editor.js +3 -3
  9. package/providers/_builtin/ide/cursor/scripts/0.49/list_models.js +1 -1
  10. package/providers/_builtin/ide/cursor/scripts/0.49/list_modes.js +1 -1
  11. package/providers/_builtin/ide/cursor/scripts/0.49/open_panel.js +4 -4
  12. package/providers/_builtin/ide/cursor/scripts/0.49/read_chat.js +5 -1
  13. package/providers/_builtin/ide/cursor/scripts/0.49.bak/dismiss_notification.js +30 -0
  14. package/providers/_builtin/ide/cursor/scripts/0.49.bak/focus_editor.js +13 -0
  15. package/providers/_builtin/ide/cursor/scripts/0.49.bak/list_models.js +78 -0
  16. package/providers/_builtin/ide/cursor/scripts/0.49.bak/list_modes.js +40 -0
  17. package/providers/_builtin/ide/cursor/scripts/0.49.bak/list_notifications.js +23 -0
  18. package/providers/_builtin/ide/cursor/scripts/0.49.bak/list_sessions.js +42 -0
  19. package/providers/_builtin/ide/cursor/scripts/0.49.bak/new_session.js +20 -0
  20. package/providers/_builtin/ide/cursor/scripts/0.49.bak/open_panel.js +23 -0
  21. package/providers/_builtin/ide/cursor/scripts/0.49.bak/read_chat.js +79 -0
  22. package/providers/_builtin/ide/cursor/scripts/0.49.bak/resolve_action.js +19 -0
  23. package/providers/_builtin/ide/cursor/scripts/0.49.bak/scripts.js +78 -0
  24. package/providers/_builtin/ide/cursor/scripts/0.49.bak/send_message.js +23 -0
  25. package/providers/_builtin/ide/cursor/scripts/0.49.bak/set_mode.js +38 -0
  26. package/providers/_builtin/ide/cursor/scripts/0.49.bak/set_model.js +81 -0
  27. package/providers/_builtin/ide/cursor/scripts/0.49.bak/switch_session.js +28 -0
  28. package/providers/_builtin/ide/windsurf/scripts/read_chat.js +18 -1
  29. package/src/cli-adapters/provider-cli-adapter.ts +231 -12
  30. package/src/commands/chat-commands.ts +36 -0
  31. package/src/commands/cli-manager.ts +128 -30
  32. package/src/commands/handler.ts +47 -3
  33. package/src/commands/router.ts +32 -2
  34. package/src/commands/workspace-commands.ts +108 -0
  35. package/src/config/config.ts +29 -1
  36. package/src/config/workspace-activity.ts +65 -0
  37. package/src/config/workspaces.ts +250 -0
  38. package/src/daemon/dev-server.ts +1 -1
  39. package/src/index.ts +5 -0
  40. package/src/launch.ts +1 -1
  41. package/src/providers/cli-provider-instance.ts +7 -2
  42. package/src/providers/ide-provider-instance.ts +11 -0
  43. package/src/status/reporter.ts +23 -4
  44. package/src/system/host-memory.ts +65 -0
  45. package/src/types.ts +8 -1
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Cursor — send_message
3
+ *
4
+ * Cursor는 cdp-type-and-send 방식:
5
+ * 1. 입력 필드 존재 확인
6
+ * 2. needsTypeAndSend: true 반환 → daemon이 CDP로 타이핑 + Enter 처리
7
+ *
8
+ * 입력 셀렉터: .aislash-editor-input[contenteditable="true"]
9
+ * 파라미터: ${ MESSAGE } (사용되지 않음 — daemon이 직접 타이핑)
10
+ */
11
+ (() => {
12
+ try {
13
+ const input = document.querySelector('.aislash-editor-input[contenteditable="true"]');
14
+ if (!input) return JSON.stringify({ sent: false, error: 'Input box not found' });
15
+ return JSON.stringify({
16
+ sent: false,
17
+ needsTypeAndSend: true,
18
+ selector: '.aislash-editor-input[contenteditable="true"]',
19
+ });
20
+ } catch(e) {
21
+ return JSON.stringify({ sent: false, error: e.message });
22
+ }
23
+ })()
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Cursor — set_mode
3
+ *
4
+ * 모드 드롭다운에서 대상 모드 선택:
5
+ * 1. 드롭다운 열기
6
+ * 2. 매칭 아이템 클릭
7
+ *
8
+ * params.mode: string — 모드 이름
9
+ * → { success: true/false, mode? }
10
+ */
11
+ async (params) => {
12
+ try {
13
+ const target = params.mode;
14
+
15
+ const modeBtn = document.querySelector('.composer-unified-dropdown:not(.composer-unified-dropdown-model)');
16
+ if (!modeBtn) return JSON.stringify({ success: false, error: 'Mode button not found' });
17
+
18
+ modeBtn.click();
19
+ await new Promise(r => setTimeout(r, 500));
20
+
21
+ const menu = document.querySelector('[data-testid="model-picker-menu"]') || document.querySelector('.typeahead-popover');
22
+ if (!menu) return JSON.stringify({ success: false, error: 'Mode menu not found' });
23
+
24
+ const items = menu.querySelectorAll('.composer-unified-context-menu-item');
25
+ for (const item of items) {
26
+ const nameEl = item.querySelector('.monaco-highlighted-label');
27
+ const name = nameEl?.textContent?.trim() || '';
28
+ if (name && (name === target || name.toLowerCase() === target.toLowerCase())) {
29
+ item.click();
30
+ await new Promise(r => setTimeout(r, 200));
31
+ return JSON.stringify({ success: true, mode: name });
32
+ }
33
+ }
34
+
35
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
36
+ return JSON.stringify({ success: false, error: 'mode not found: ' + target });
37
+ } catch(e) { return JSON.stringify({ success: false, error: e.message }); }
38
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Cursor — set_model
3
+ *
4
+ * 모델 드롭다운에서 대상 모델 선택:
5
+ * 1. 드롭다운 열기
6
+ * 2. Auto 토글 끄기 (필요 시)
7
+ * 3. 검색 입력으로 필터
8
+ * 4. 매칭 아이템 클릭
9
+ * 5. 원래 Auto 상태 복원
10
+ *
11
+ * params.model: string — 모델 이름 (🧠 접미사 가능)
12
+ * → { success: true/false, model? }
13
+ */
14
+ async (params) => {
15
+ try {
16
+ const target = params.model;
17
+
18
+ const modelBtn = document.querySelector('.composer-unified-dropdown-model');
19
+ if (!modelBtn) return JSON.stringify({ success: false, error: 'Model button not found' });
20
+
21
+ modelBtn.click();
22
+ await new Promise(r => setTimeout(r, 500));
23
+
24
+ const menu = document.querySelector('[data-testid="model-picker-menu"]');
25
+ if (!menu) return JSON.stringify({ success: false, error: 'Model picker menu not found' });
26
+
27
+ // 🧠 접미사 처리
28
+ const wantBrain = target.includes('🧠');
29
+ const searchName = target.replace(/\s*🧠\s*$/, '').trim();
30
+
31
+ // Auto 토글 끄기
32
+ const autoItem = menu.querySelector('.composer-unified-context-menu-item[data-is-selected="true"]');
33
+ const autoToggle = autoItem ? [...autoItem.querySelectorAll('[class*="rounded-full"]')].find(el => el.offsetWidth === 24 && el.offsetHeight === 14) : null;
34
+ let wasAutoOn = false;
35
+ if (autoToggle) {
36
+ const bgStyle = autoToggle.getAttribute('style') || '';
37
+ wasAutoOn = bgStyle.includes('green');
38
+ if (wasAutoOn) {
39
+ autoToggle.click();
40
+ await new Promise(r => setTimeout(r, 500));
41
+ }
42
+ }
43
+
44
+ // 검색 입력으로 필터링
45
+ const refreshedMenu = document.querySelector('[data-testid="model-picker-menu"]');
46
+ const searchInput = refreshedMenu?.querySelector('input[placeholder="Search models"]');
47
+ if (searchInput) {
48
+ searchInput.focus();
49
+ searchInput.value = searchName;
50
+ searchInput.dispatchEvent(new Event('input', { bubbles: true }));
51
+ await new Promise(r => setTimeout(r, 300));
52
+ }
53
+
54
+ // 아이템에서 찾기
55
+ const items = (refreshedMenu || menu).querySelectorAll('.composer-unified-context-menu-item');
56
+ for (const item of items) {
57
+ const nameEl = item.querySelector('.monaco-highlighted-label');
58
+ const name = nameEl?.textContent?.trim() || '';
59
+ if (!name || name === 'Add Models') continue;
60
+ const hasBrain = !!item.querySelector('[class*="codicon-br"]');
61
+
62
+ if (name.toLowerCase().includes(searchName.toLowerCase()) && hasBrain === wantBrain) {
63
+ item.click();
64
+ await new Promise(r => setTimeout(r, 200));
65
+ const displayName = hasBrain ? name + ' 🧠' : name;
66
+ return JSON.stringify({ success: true, model: displayName });
67
+ }
68
+ }
69
+
70
+ // Auto 복원 + 닫기
71
+ if (wasAutoOn) {
72
+ const nm = document.querySelector('[data-testid="model-picker-menu"]');
73
+ const nai = nm?.querySelector('.composer-unified-context-menu-item');
74
+ const nt = nai ? [...nai.querySelectorAll('[class*="rounded-full"]')].find(el => el.offsetWidth === 24) : null;
75
+ if (nt) nt.click();
76
+ await new Promise(r => setTimeout(r, 200));
77
+ }
78
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
79
+ return JSON.stringify({ success: false, error: 'model not found: ' + target });
80
+ } catch(e) { return JSON.stringify({ success: false, error: e.message }); }
81
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Cursor — switch_session
3
+ *
4
+ * 사이드바 셀 클릭으로 세션 전환:
5
+ * 제목 매칭 (params.title) 또는 인덱스 (params.index)
6
+ *
7
+ * params.index: number, params.title: string|null
8
+ * → { switched: true/false, title?, error? }
9
+ */
10
+ (params) => {
11
+ try {
12
+ const cells = [...document.querySelectorAll('.agent-sidebar-cell')];
13
+ let target;
14
+ if (params.title) {
15
+ target = cells.find(c => {
16
+ const t = c.querySelector('.agent-sidebar-cell-text')?.textContent?.trim() || '';
17
+ return t.toLowerCase().includes(params.title.toLowerCase());
18
+ });
19
+ } else {
20
+ target = cells[params.index || 0];
21
+ }
22
+ if (!target) return JSON.stringify({ switched: false, error: 'Session not found', available: cells.length });
23
+ target.click();
24
+ return JSON.stringify({ switched: true, title: target.querySelector('.agent-sidebar-cell-text')?.textContent?.trim() });
25
+ } catch(e) {
26
+ return JSON.stringify({ switched: false, error: e.message });
27
+ }
28
+ }
@@ -86,6 +86,23 @@
86
86
  const title = document.title.split(' \u2014 ')[0].trim() || 'Cascade';
87
87
 
88
88
  // ─── 4. HTML → Markdown 변환기 ───
89
+ const BLOCK_TAGS = new Set(['DIV', 'P', 'BR', 'LI', 'TR', 'SECTION', 'ARTICLE', 'HEADER', 'FOOTER']);
90
+ function extractCodeText(node) {
91
+ if (node.nodeType === 3) return node.textContent || '';
92
+ if (node.nodeType !== 1) return '';
93
+ if (node.tagName === 'BR') return '\n';
94
+ const parts = [];
95
+ for (const child of node.childNodes) {
96
+ const isBlock = child.nodeType === 1 && BLOCK_TAGS.has(child.tagName);
97
+ const text = extractCodeText(child);
98
+ if (text) {
99
+ if (isBlock && parts.length > 0) parts.push('\n');
100
+ parts.push(text);
101
+ if (isBlock) parts.push('\n');
102
+ }
103
+ }
104
+ return parts.join('').replace(/\n{2,}/g, '\n');
105
+ }
89
106
  function htmlToMd(node) {
90
107
  if (node.nodeType === 3) return node.textContent || '';
91
108
  if (node.nodeType !== 1) return '';
@@ -121,7 +138,7 @@
121
138
  if (tag === 'PRE') {
122
139
  const codeEl = node.querySelector('code');
123
140
  const lang = codeEl ? (codeEl.className.match(/language-(\w+)/)?.[1] || '') : '';
124
- const code = (codeEl || node).textContent || '';
141
+ const code = extractCodeText(codeEl || node);
125
142
  return '\n```' + lang + '\n' + code.trim() + '\n```\n';
126
143
  }
127
144
  if (tag === 'CODE') {
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import * as os from 'os';
19
+ import * as path from 'path';
19
20
  import { execSync } from 'child_process';
20
21
  import type { CliAdapter } from '../cli-adapter-types.js';
21
22
  import { LOG } from '../logging/logger.js';
@@ -23,6 +24,22 @@ import { LOG } from '../logging/logger.js';
23
24
  let pty: any;
24
25
  try {
25
26
  pty = require('node-pty');
27
+ // node-pty@1.1.0 ships spawn-helper without +x on macOS — fix it
28
+ if (os.platform() !== 'win32') {
29
+ try {
30
+ const fs = require('fs');
31
+ const ptyDir = path.dirname(require.resolve('node-pty'));
32
+ const arch = os.arch() === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
33
+ const helper = path.join(ptyDir, 'prebuilds', arch, 'spawn-helper');
34
+ if (fs.existsSync(helper)) {
35
+ const stat = fs.statSync(helper);
36
+ if (!(stat.mode & 0o111)) {
37
+ fs.chmodSync(helper, stat.mode | 0o755);
38
+ LOG.info('CLI', '[node-pty] Fixed spawn-helper permissions');
39
+ }
40
+ }
41
+ } catch { /* best-effort */ }
42
+ }
26
43
  } catch {
27
44
  LOG.error('CLI', '[ProviderCliAdapter] node-pty not found. Install: npm install node-pty@1.0.0');
28
45
  }
@@ -93,6 +110,125 @@ function findBinary(name: string): string {
93
110
  }
94
111
  }
95
112
 
113
+ /** True if file starts with a UTF-8 BOM then #!, or plain #!. */
114
+ function isScriptBinary(binaryPath: string): boolean {
115
+ if (!path.isAbsolute(binaryPath)) return false;
116
+ try {
117
+ const fs = require('fs');
118
+ const resolved = fs.realpathSync(binaryPath);
119
+ const head = Buffer.alloc(8);
120
+ const fd = fs.openSync(resolved, 'r');
121
+ fs.readSync(fd, head, 0, 8, 0);
122
+ fs.closeSync(fd);
123
+ let i = 0;
124
+ if (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) i = 3;
125
+ return head[i] === 0x23 && head[i + 1] === 0x21; // '#!'
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ /** True only for Mach-O / ELF — npm shims and shell scripts return false. */
132
+ function looksLikeMachOOrElf(filePath: string): boolean {
133
+ if (!path.isAbsolute(filePath)) return false;
134
+ try {
135
+ const fs = require('fs');
136
+ const resolved = fs.realpathSync(filePath);
137
+ const buf = Buffer.alloc(8);
138
+ const fd = fs.openSync(resolved, 'r');
139
+ fs.readSync(fd, buf, 0, 8, 0);
140
+ fs.closeSync(fd);
141
+ let i = 0;
142
+ if (buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) i = 3;
143
+ const b = buf.subarray(i);
144
+ if (b.length < 4) return false;
145
+ // ELF
146
+ if (b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46) return true;
147
+ const le = b.readUInt32LE(0);
148
+ const be = b.readUInt32BE(0);
149
+ const magics = [0xfeedface, 0xfeedfacf, 0xcafebabe, 0xbebafeca];
150
+ return magics.some(m => m === le || m === be);
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ function shSingleQuote(arg: string): string {
157
+ if (/^[a-zA-Z0-9@%_+=:,./-]+$/.test(arg)) return arg;
158
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
159
+ }
160
+
161
+ /**
162
+ * provider.json stores regex placeholders as `{}` — JSON.parse leaves plain objects,
163
+ * so `.test` is missing and the PTY handler throws on first output. Coerce to RegExp
164
+ * (including `{ "source": "...", "flags": "i" }`) or fall back to generic TUIs (Claude Code, etc.).
165
+ */
166
+ function parsePatternEntry(x: unknown): RegExp | null {
167
+ if (x instanceof RegExp) return x;
168
+ if (x && typeof x === 'object' && typeof (x as { source?: string }).source === 'string') {
169
+ try {
170
+ const s = x as { source: string; flags?: string };
171
+ return new RegExp(s.source, s.flags || '');
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
179
+ function coercePatternArray(raw: unknown, fallbacks: RegExp[]): RegExp[] {
180
+ if (!Array.isArray(raw)) return [...fallbacks];
181
+ const parsed = raw.map(parsePatternEntry).filter((r): r is RegExp => r != null);
182
+ return parsed.length > 0 ? parsed : [...fallbacks];
183
+ }
184
+
185
+ /** Defaults tuned for Claude Code / similar agent CLIs when provider.json patterns are empty. */
186
+ const FALLBACK_PROMPT: RegExp[] = [
187
+ /Type your message/i,
188
+ /^>\s*$/m, // '>' alone on its own line
189
+ /[›➤]\s*$/,
190
+ /for shortcuts/i,
191
+ /\?\s*for help/i,
192
+ /Press enter/i,
193
+ // NOTE: removed /^[\s\u2500-\u257f]*>\s*$/m — the box-drawing char range is too wide and
194
+ // can match dialog-clearing ANSI output, causing false prompt detection in approval state.
195
+ ];
196
+
197
+ const FALLBACK_GENERATING: RegExp[] = [
198
+ /[\u2800-\u28ff]/, // Braille spinner blocks (universal TUI)
199
+ /esc to (cancel|interrupt|stop)/i, // Common TUI generation status line
200
+ /generating\.\.\./i,
201
+ /Claude is (?:thinking|processing|working)/i, // Specific Claude Code status
202
+ ];
203
+
204
+ const FALLBACK_APPROVAL: RegExp[] = [
205
+ /Allow\s+once/i,
206
+ /Always\s+allow/i,
207
+ /\(y\/n\)/i,
208
+ /\[Y\/n\]/i,
209
+ /Run\s+this\s+command/i,
210
+ // NOTE: removed /Do you want to (?:run|execute|allow)/i — too broad, matches AI explanation
211
+ // text like "Do you want to allow this feature?" causing false approval notifications.
212
+ ];
213
+
214
+ function defaultCleanOutput(raw: string, _lastUserInput?: string): string {
215
+ return stripAnsi(raw).trim();
216
+ }
217
+
218
+ export function normalizeCliProviderForRuntime(raw: any): CliProviderModule {
219
+ const patterns = raw?.patterns || {};
220
+ return {
221
+ ...raw,
222
+ patterns: {
223
+ prompt: coercePatternArray(patterns.prompt, FALLBACK_PROMPT),
224
+ generating: coercePatternArray(patterns.generating, FALLBACK_GENERATING),
225
+ approval: coercePatternArray(patterns.approval, FALLBACK_APPROVAL),
226
+ ready: coercePatternArray(patterns.ready, []),
227
+ },
228
+ cleanOutput: typeof raw?.cleanOutput === 'function' ? raw.cleanOutput : defaultCleanOutput,
229
+ };
230
+ }
231
+
96
232
  export class ProviderCliAdapter implements CliAdapter {
97
233
  readonly cliType: string;
98
234
  readonly cliName: string;
@@ -125,11 +261,18 @@ export class ProviderCliAdapter implements CliAdapter {
125
261
  // Approval cooldown
126
262
  private lastApprovalResolvedAt: number = 0;
127
263
 
264
+ // Approval state machine
265
+ private approvalTransitionBuffer: string = '';
266
+ private approvalExitTimeout: NodeJS.Timeout | null = null;
267
+
268
+ // Resize redraw suppression
269
+ private resizeSuppressUntil: number = 0;
270
+
128
271
  // Resolved timeouts (provider defaults + overrides)
129
272
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
130
273
 
131
274
  constructor(provider: CliProviderModule, workingDir: string, private extraArgs: string[] = []) {
132
- this.provider = provider;
275
+ this.provider = normalizeCliProviderForRuntime(provider);
133
276
  this.cliType = provider.type;
134
277
  this.cliName = provider.name;
135
278
  this.workingDir = workingDir.startsWith('~')
@@ -180,18 +323,28 @@ export class ProviderCliAdapter implements CliAdapter {
180
323
 
181
324
  let shellCmd: string;
182
325
  let shellArgs: string[];
183
-
184
- if (spawnConfig.shell) {
185
- // Execute via shell (for gemini etc npm shim compat)
326
+ const useShellUnix = !isWin && (
327
+ !!spawnConfig.shell
328
+ || !path.isAbsolute(binaryPath)
329
+ || isScriptBinary(binaryPath)
330
+ || !looksLikeMachOOrElf(binaryPath)
331
+ );
332
+ const useShell = isWin ? !!spawnConfig.shell : useShellUnix;
333
+
334
+ if (useShell) {
335
+ // Execute via shell (npm shims, shebang scripts, anything that is not a bare Mach-O/ELF)
336
+ if (!spawnConfig.shell && !isWin) {
337
+ LOG.info('CLI', `[${this.cliType}] Using login shell (script shim or non-native binary)`);
338
+ }
186
339
  shellCmd = isWin ? 'cmd.exe' : (process.env.SHELL || '/bin/zsh');
187
- const fullCmd = [binaryPath, ...allArgs].join(' ');
340
+ const fullCmd = [binaryPath, ...allArgs].map(shSingleQuote).join(' ');
188
341
  shellArgs = isWin ? ['/c', fullCmd] : ['-l', '-c', fullCmd];
189
342
  } else {
190
343
  shellCmd = binaryPath;
191
344
  shellArgs = allArgs;
192
345
  }
193
346
 
194
- this.ptyProcess = pty.spawn(shellCmd, shellArgs, {
347
+ const ptyOpts = {
195
348
  name: 'xterm-256color',
196
349
  cols: 120,
197
350
  rows: 40,
@@ -200,7 +353,22 @@ export class ProviderCliAdapter implements CliAdapter {
200
353
  ...process.env,
201
354
  ...spawnConfig.env,
202
355
  } as Record<string, string>,
203
- });
356
+ };
357
+
358
+ try {
359
+ this.ptyProcess = pty.spawn(shellCmd, shellArgs, ptyOpts);
360
+ } catch (err: any) {
361
+ const msg = err?.message || String(err);
362
+ if (!isWin && !useShell && /posix_spawn|spawn/i.test(msg)) {
363
+ LOG.warn('CLI', `[${this.cliType}] Direct spawn failed (${msg}), retrying via login shell`);
364
+ shellCmd = process.env.SHELL || '/bin/zsh';
365
+ const fullCmd = [binaryPath, ...allArgs].map(shSingleQuote).join(' ');
366
+ shellArgs = ['-l', '-c', fullCmd];
367
+ this.ptyProcess = pty.spawn(shellCmd, shellArgs, ptyOpts);
368
+ } else {
369
+ throw err;
370
+ }
371
+ }
204
372
 
205
373
  this.ptyProcess.onData((data: string) => {
206
374
  this.handleOutput(data);
@@ -234,6 +402,9 @@ export class ProviderCliAdapter implements CliAdapter {
234
402
  // ─── Output state machine ────────────────────────────
235
403
 
236
404
  private handleOutput(rawData: string): void {
405
+ // Suppress output processing briefly after resize to avoid false triggers from screen redraws
406
+ if (Date.now() < this.resizeSuppressUntil) return;
407
+
237
408
  const cleanData = stripAnsi(rawData);
238
409
  const { patterns } = this.provider;
239
410
 
@@ -279,32 +450,57 @@ export class ProviderCliAdapter implements CliAdapter {
279
450
  if (hasApproval && this.currentStatus !== 'waiting_approval') {
280
451
  if (this.lastApprovalResolvedAt && (Date.now() - this.lastApprovalResolvedAt) < this.timeouts.approvalCooldown) return;
281
452
 
453
+ // Capture context before clearing (recentOutputBuffer still has content here)
454
+ const ctxLines = this.recentOutputBuffer.split('\n')
455
+ .map(l => l.trim())
456
+ .filter(l => l && !/^[─═╭╮╰╯│]+$/.test(l));
282
457
  this.isWaitingForResponse = true;
283
458
  this.currentStatus = 'waiting_approval';
284
459
  this.recentOutputBuffer = '';
285
- const ctxLines = cleanData.split('\n').map(l => l.trim()).filter(l => l && !/^[─═╭╮╰╯│]+$/.test(l));
460
+ this.approvalTransitionBuffer = '';
286
461
  this.activeModal = {
287
462
  message: ctxLines.slice(-5).join(' ').slice(0, 200) || 'Approval required',
288
463
  buttons: ['Allow once', 'Always allow', 'Deny'],
289
464
  };
290
465
  if (this.idleTimeout) clearTimeout(this.idleTimeout);
466
+ // Safety timeout — if stuck in waiting_approval, auto-exit after 60s
467
+ if (this.approvalExitTimeout) clearTimeout(this.approvalExitTimeout);
468
+ this.approvalExitTimeout = setTimeout(() => {
469
+ if (this.currentStatus === 'waiting_approval') {
470
+ LOG.warn('CLI', `[${this.cliType}] Approval timeout — auto-exiting waiting_approval`);
471
+ this.activeModal = null;
472
+ this.lastApprovalResolvedAt = Date.now();
473
+ this.recentOutputBuffer = '';
474
+ this.approvalTransitionBuffer = '';
475
+ this.approvalExitTimeout = null;
476
+ this.currentStatus = this.isWaitingForResponse ? 'generating' : 'idle';
477
+ this.onStatusChange?.();
478
+ }
479
+ }, 60000);
291
480
  this.onStatusChange?.();
292
481
  return;
293
482
  }
294
483
 
295
484
  // ─── Phase 3: Approval release
485
+ // Accumulate chunks into approvalTransitionBuffer — the approval dialog clears via ANSI
486
+ // sequences that strip to nothing, so we can't rely on a single cleanData chunk matching.
296
487
  if (this.currentStatus === 'waiting_approval') {
297
- const genResume = patterns.generating.some(p => p.test(cleanData));
298
- const promptResume = patterns.prompt.some(p => p.test(cleanData));
488
+ this.approvalTransitionBuffer = (this.approvalTransitionBuffer + cleanData).slice(-500);
489
+ const genResume = patterns.generating.some(p => p.test(this.approvalTransitionBuffer));
490
+ const promptResume = patterns.prompt.some(p => p.test(this.approvalTransitionBuffer));
299
491
  if (genResume) {
492
+ if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
300
493
  this.currentStatus = 'generating';
301
494
  this.activeModal = null;
302
495
  this.recentOutputBuffer = '';
496
+ this.approvalTransitionBuffer = '';
303
497
  this.lastApprovalResolvedAt = Date.now();
304
498
  this.onStatusChange?.();
305
499
  } else if (promptResume) {
500
+ if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
306
501
  this.activeModal = null;
307
502
  this.recentOutputBuffer = '';
503
+ this.approvalTransitionBuffer = '';
308
504
  this.lastApprovalResolvedAt = Date.now();
309
505
  this.finishResponse();
310
506
  }
@@ -337,7 +533,11 @@ export class ProviderCliAdapter implements CliAdapter {
337
533
  }
338
534
 
339
535
  // Prompt → response complete
340
- if (patterns.prompt.some(p => p.test(this.responseBuffer))) {
536
+ // Only check the LAST 2 lines of cleanData — the prompt appears at the very end of the
537
+ // output stream. Checking the full chunk causes false positives when response content
538
+ // has '>' in code blocks, shell examples, or mid-response lines.
539
+ const trailingLines = cleanData.split('\n').slice(-2).join('\n');
540
+ if (patterns.prompt.some(p => p.test(trailingLines))) {
341
541
  this.finishResponse();
342
542
  } else {
343
543
  this.idleTimeout = setTimeout(() => {
@@ -353,6 +553,7 @@ export class ProviderCliAdapter implements CliAdapter {
353
553
  private finishResponse(): void {
354
554
  if (this.responseTimeout) { clearTimeout(this.responseTimeout); this.responseTimeout = null; }
355
555
  if (this.idleTimeout) { clearTimeout(this.idleTimeout); this.idleTimeout = null; }
556
+ if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
356
557
 
357
558
  const lastUserText = this.messages.filter(m => m.role === 'user').pop()?.content;
358
559
  let response = this.provider.cleanOutput(this.responseBuffer, lastUserText);
@@ -416,6 +617,7 @@ export class ProviderCliAdapter implements CliAdapter {
416
617
  cancel(): void { this.shutdown(); }
417
618
 
418
619
  shutdown(): void {
620
+ if (this.approvalExitTimeout) { clearTimeout(this.approvalExitTimeout); this.approvalExitTimeout = null; }
419
621
  if (this.ptyProcess) {
420
622
  this.ptyProcess.write('\x03');
421
623
  setTimeout(() => {
@@ -440,9 +642,26 @@ export class ProviderCliAdapter implements CliAdapter {
440
642
  this.ptyProcess?.write(data);
441
643
  }
442
644
 
645
+ /**
646
+ * Resolve an approval modal by navigating to the button at `buttonIndex` and pressing Enter.
647
+ * Index 0 = first option (already selected by default — just Enter).
648
+ * Index N = press Arrow Down N times, then Enter.
649
+ */
650
+ resolveModal(buttonIndex: number): void {
651
+ if (!this.ptyProcess || this.currentStatus !== 'waiting_approval') return;
652
+ const DOWN = '\x1B[B'; // Arrow Down
653
+ const keys = DOWN.repeat(Math.max(0, buttonIndex)) + '\r';
654
+ this.ptyProcess.write(keys);
655
+ }
656
+
443
657
  resize(cols: number, rows: number): void {
444
658
  if (this.ptyProcess) {
445
- try { this.ptyProcess.resize(cols, rows); } catch { }
659
+ try {
660
+ this.ptyProcess.resize(cols, rows);
661
+ // Suppress output for 300ms after resize — PTY redraws the screen and
662
+ // the redrawn content (spinners, status text) would falsely trigger generating detection
663
+ this.resizeSuppressUntil = Date.now() + 300;
664
+ } catch { }
446
665
  }
447
666
  }
448
667
  }
@@ -601,6 +601,42 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
601
601
 
602
602
  LOG.info('Command', `[resolveAction] action=${action} button="${button}" provider=${provider?.type}`);
603
603
 
604
+ // 0. CLI / ACP category: navigate approval dialog via PTY arrow keys + Enter
605
+ if (provider?.category === 'cli') {
606
+ const adapter = h.getCliAdapter(provider.type);
607
+ if (!adapter) return { success: false, error: 'CLI adapter not running' };
608
+ const status = (adapter as any).getStatus?.();
609
+ if (status?.status !== 'waiting_approval') {
610
+ return { success: false, error: 'Not in approval state' };
611
+ }
612
+ const buttons: string[] = status.activeModal?.buttons || ['Allow once', 'Always allow', 'Deny'];
613
+ // Resolve button index: explicit buttonIndex arg → button text match → action fallback
614
+ let buttonIndex = typeof args?.buttonIndex === 'number' ? args.buttonIndex : -1;
615
+ if (buttonIndex < 0) {
616
+ const btnLower = button.toLowerCase();
617
+ buttonIndex = buttons.findIndex(b => b.toLowerCase().includes(btnLower));
618
+ }
619
+ if (buttonIndex < 0) {
620
+ if (action === 'reject' || action === 'deny') {
621
+ buttonIndex = buttons.findIndex(b => /deny|reject|no/i.test(b));
622
+ if (buttonIndex < 0) buttonIndex = buttons.length - 1;
623
+ } else if (action === 'always' || /always/i.test(button)) {
624
+ buttonIndex = buttons.findIndex(b => /always/i.test(b));
625
+ if (buttonIndex < 0) buttonIndex = 1;
626
+ } else {
627
+ buttonIndex = 0; // approve → first option (default selected)
628
+ }
629
+ }
630
+ if (typeof (adapter as any).resolveModal === 'function') {
631
+ (adapter as any).resolveModal(buttonIndex);
632
+ } else {
633
+ const keys = '\x1B[B'.repeat(Math.max(0, buttonIndex)) + '\r';
634
+ (adapter as any).writeRaw?.(keys);
635
+ }
636
+ LOG.info('Command', `[resolveAction] CLI PTY → buttonIndex=${buttonIndex} "${buttons[buttonIndex] ?? '?'}"`);
637
+ return { success: true, buttonIndex, button: buttons[buttonIndex] ?? button };
638
+ }
639
+
604
640
  // 1. Extension: via AgentStreamManager
605
641
  if (provider?.category === 'extension' && h.agentStream && h.getCdp()) {
606
642
  const ok = await h.agentStream.resolveAgentAction(