@adhdev/daemon-core 0.5.3 → 0.5.5

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 (43) hide show
  1. package/dist/index.d.ts +79 -2
  2. package/dist/index.js +1131 -433
  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 +167 -7
  30. package/src/commands/cli-manager.ts +128 -30
  31. package/src/commands/handler.ts +47 -3
  32. package/src/commands/router.ts +32 -2
  33. package/src/commands/workspace-commands.ts +108 -0
  34. package/src/config/config.ts +29 -1
  35. package/src/config/workspace-activity.ts +65 -0
  36. package/src/config/workspaces.ts +250 -0
  37. package/src/daemon/dev-server.ts +1 -1
  38. package/src/index.ts +5 -0
  39. package/src/launch.ts +1 -1
  40. package/src/providers/ide-provider-instance.ts +11 -0
  41. package/src/status/reporter.ts +23 -4
  42. package/src/system/host-memory.ts +65 -0
  43. 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,124 @@ 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*$/,
189
+ /[›➤]\s*$/,
190
+ /for shortcuts/i,
191
+ /\?\s*for help/i,
192
+ /Press enter/i,
193
+ /^[\s\u2500-\u257f]*>\s*$/m,
194
+ ];
195
+
196
+ const FALLBACK_GENERATING: RegExp[] = [
197
+ /thinking/i,
198
+ /writing/i,
199
+ /Claude is/i,
200
+ /Opus|Sonnet|Haiku/i,
201
+ /[\u2800-\u28ff]/, // Braille spinner blocks
202
+ ];
203
+
204
+ const FALLBACK_APPROVAL: RegExp[] = [
205
+ /approve/i,
206
+ /Allow( once)?/i,
207
+ /\(y\/n\)/i,
208
+ /\[Y\/n\]/i,
209
+ /continue\?/i,
210
+ /Run this command/i,
211
+ ];
212
+
213
+ function defaultCleanOutput(raw: string, _lastUserInput?: string): string {
214
+ return stripAnsi(raw).trim();
215
+ }
216
+
217
+ export function normalizeCliProviderForRuntime(raw: any): CliProviderModule {
218
+ const patterns = raw?.patterns || {};
219
+ return {
220
+ ...raw,
221
+ patterns: {
222
+ prompt: coercePatternArray(patterns.prompt, FALLBACK_PROMPT),
223
+ generating: coercePatternArray(patterns.generating, FALLBACK_GENERATING),
224
+ approval: coercePatternArray(patterns.approval, FALLBACK_APPROVAL),
225
+ ready: coercePatternArray(patterns.ready, []),
226
+ },
227
+ cleanOutput: typeof raw?.cleanOutput === 'function' ? raw.cleanOutput : defaultCleanOutput,
228
+ };
229
+ }
230
+
96
231
  export class ProviderCliAdapter implements CliAdapter {
97
232
  readonly cliType: string;
98
233
  readonly cliName: string;
@@ -129,7 +264,7 @@ export class ProviderCliAdapter implements CliAdapter {
129
264
  private readonly timeouts: Required<NonNullable<CliProviderModule['timeouts']>>;
130
265
 
131
266
  constructor(provider: CliProviderModule, workingDir: string, private extraArgs: string[] = []) {
132
- this.provider = provider;
267
+ this.provider = normalizeCliProviderForRuntime(provider);
133
268
  this.cliType = provider.type;
134
269
  this.cliName = provider.name;
135
270
  this.workingDir = workingDir.startsWith('~')
@@ -180,18 +315,28 @@ export class ProviderCliAdapter implements CliAdapter {
180
315
 
181
316
  let shellCmd: string;
182
317
  let shellArgs: string[];
183
-
184
- if (spawnConfig.shell) {
185
- // Execute via shell (for gemini etc npm shim compat)
318
+ const useShellUnix = !isWin && (
319
+ !!spawnConfig.shell
320
+ || !path.isAbsolute(binaryPath)
321
+ || isScriptBinary(binaryPath)
322
+ || !looksLikeMachOOrElf(binaryPath)
323
+ );
324
+ const useShell = isWin ? !!spawnConfig.shell : useShellUnix;
325
+
326
+ if (useShell) {
327
+ // Execute via shell (npm shims, shebang scripts, anything that is not a bare Mach-O/ELF)
328
+ if (!spawnConfig.shell && !isWin) {
329
+ LOG.info('CLI', `[${this.cliType}] Using login shell (script shim or non-native binary)`);
330
+ }
186
331
  shellCmd = isWin ? 'cmd.exe' : (process.env.SHELL || '/bin/zsh');
187
- const fullCmd = [binaryPath, ...allArgs].join(' ');
332
+ const fullCmd = [binaryPath, ...allArgs].map(shSingleQuote).join(' ');
188
333
  shellArgs = isWin ? ['/c', fullCmd] : ['-l', '-c', fullCmd];
189
334
  } else {
190
335
  shellCmd = binaryPath;
191
336
  shellArgs = allArgs;
192
337
  }
193
338
 
194
- this.ptyProcess = pty.spawn(shellCmd, shellArgs, {
339
+ const ptyOpts = {
195
340
  name: 'xterm-256color',
196
341
  cols: 120,
197
342
  rows: 40,
@@ -200,7 +345,22 @@ export class ProviderCliAdapter implements CliAdapter {
200
345
  ...process.env,
201
346
  ...spawnConfig.env,
202
347
  } as Record<string, string>,
203
- });
348
+ };
349
+
350
+ try {
351
+ this.ptyProcess = pty.spawn(shellCmd, shellArgs, ptyOpts);
352
+ } catch (err: any) {
353
+ const msg = err?.message || String(err);
354
+ if (!isWin && !useShell && /posix_spawn|spawn/i.test(msg)) {
355
+ LOG.warn('CLI', `[${this.cliType}] Direct spawn failed (${msg}), retrying via login shell`);
356
+ shellCmd = process.env.SHELL || '/bin/zsh';
357
+ const fullCmd = [binaryPath, ...allArgs].map(shSingleQuote).join(' ');
358
+ shellArgs = ['-l', '-c', fullCmd];
359
+ this.ptyProcess = pty.spawn(shellCmd, shellArgs, ptyOpts);
360
+ } else {
361
+ throw err;
362
+ }
363
+ }
204
364
 
205
365
  this.ptyProcess.onData((data: string) => {
206
366
  this.handleOutput(data);
@@ -12,6 +12,8 @@ import chalk from 'chalk';
12
12
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
13
13
  import { detectCLI } from '../detection/cli-detector.js';
14
14
  import { loadConfig, saveConfig, addCliHistory } from '../config/config.js';
15
+ import { getWorkspaceState, resolveLaunchDirectory } from '../config/workspaces.js';
16
+ import { appendWorkspaceActivity } from '../config/workspace-activity.js';
15
17
  import { CliProviderInstance } from '../providers/cli-provider-instance.js';
16
18
  import { AcpProviderInstance } from '../providers/acp-provider-instance.js';
17
19
  import type { ProviderInstanceManager } from '../providers/provider-instance-manager.js';
@@ -54,6 +56,25 @@ export class DaemonCliManager {
54
56
  return `${cliType}_${hash}`;
55
57
  }
56
58
 
59
+ private persistRecentDir(cliType: string, dir: string): void {
60
+ try {
61
+ const normalizedType = this.providerLoader.resolveAlias(cliType);
62
+ const provider = this.providerLoader.getByAlias(cliType);
63
+ const actKind = provider?.category === 'acp' ? 'acp' : 'cli';
64
+ let next = loadConfig();
65
+ console.log(chalk.cyan(` 📂 Saving recent workspace: ${dir}`));
66
+ const recent = next.recentCliWorkspaces || [];
67
+ if (!recent.includes(dir)) {
68
+ next = { ...next, recentCliWorkspaces: [dir, ...recent].slice(0, 10) };
69
+ }
70
+ next = appendWorkspaceActivity(next, dir, { kind: actKind, agentType: normalizedType });
71
+ saveConfig(next);
72
+ console.log(chalk.green(` ✓ Recent workspace saved: ${dir}`));
73
+ } catch (e) {
74
+ console.error(chalk.red(` ✗ Failed to save recent workspace: ${e}`));
75
+ }
76
+ }
77
+
57
78
  private createAdapter(cliType: string, workingDir: string, cliArgs?: string[]): CliAdapter {
58
79
  // cliType normalize (Resolve alias)
59
80
  const normalizedType = this.providerLoader.resolveAlias(cliType);
@@ -71,7 +92,8 @@ export class DaemonCliManager {
71
92
  // ─── Session start/management ──────────────────────────────
72
93
 
73
94
  async startSession(cliType: string, workingDir: string, cliArgs?: string[], initialModel?: string): Promise<void> {
74
- const trimmed = (workingDir || os.homedir()).trim();
95
+ const trimmed = (workingDir || '').trim();
96
+ if (!trimmed) throw new Error('working directory required');
75
97
  const resolvedDir = trimmed.startsWith('~')
76
98
  ? trimmed.replace(/^~/, os.homedir())
77
99
  : path.resolve(trimmed);
@@ -161,21 +183,54 @@ export class DaemonCliManager {
161
183
  const instanceManager = this.deps.getInstanceManager();
162
184
  if (provider && instanceManager) {
163
185
  const cliInstance = new CliProviderInstance(provider, resolvedDir, cliArgs, key);
164
- await instanceManager.addInstance(key, cliInstance, {
165
- serverConn: this.deps.getServerConn(),
166
- settings: {},
167
- onPtyData: (data: string) => {
168
- this.deps.getP2p()?.broadcastPtyOutput(key, data);
169
- },
170
- });
186
+ try {
187
+ await instanceManager.addInstance(key, cliInstance, {
188
+ serverConn: this.deps.getServerConn(),
189
+ settings: {},
190
+ onPtyData: (data: string) => {
191
+ this.deps.getP2p()?.broadcastPtyOutput(key, data);
192
+ },
193
+ });
194
+ } catch (spawnErr: any) {
195
+ // Spawn failed — cleanup and propagate error
196
+ LOG.error('CLI', `[${cliType}] Spawn failed: ${spawnErr?.message}`);
197
+ instanceManager.removeInstance(key);
198
+ throw new Error(`Failed to start ${cliInfo.displayName}: ${spawnErr?.message}`);
199
+ }
171
200
 
172
201
  // Keep adapter ref too (backward compat — write, resize etc)
173
202
  this.adapters.set(key, cliInstance.getAdapter() as any);
174
203
  console.log(chalk.green(` ✓ CLI started: ${cliInfo.displayName} v${cliInfo.version || 'unknown'} in ${resolvedDir}`));
204
+
205
+ // Monitor for stopped/error → auto-cleanup
206
+ const checkStopped = setInterval(() => {
207
+ try {
208
+ const adapter = this.adapters.get(key);
209
+ if (!adapter) { clearInterval(checkStopped); return; }
210
+ const status = adapter.getStatus?.();
211
+ if (status?.status === 'stopped' || status?.status === 'error') {
212
+ clearInterval(checkStopped);
213
+ setTimeout(() => {
214
+ if (this.adapters.has(key)) {
215
+ this.adapters.delete(key);
216
+ this.deps.removeAgentTracking(key);
217
+ instanceManager.removeInstance(key);
218
+ LOG.info('CLI', `🧹 Auto-cleaned ${status.status} CLI: ${cliType}`);
219
+ this.deps.onStatusChange();
220
+ }
221
+ }, 5000);
222
+ }
223
+ } catch { /* ignore */ }
224
+ }, 3000);
175
225
  } else {
176
226
  // Fallback: InstanceManager without directly adapter manage
177
227
  const adapter = this.createAdapter(cliType, resolvedDir, cliArgs);
178
- await adapter.spawn();
228
+ try {
229
+ await adapter.spawn();
230
+ } catch (spawnErr: any) {
231
+ LOG.error('CLI', `[${cliType}] Spawn failed: ${spawnErr?.message}`);
232
+ throw new Error(`Failed to start ${cliInfo.displayName}: ${spawnErr?.message}`);
233
+ }
179
234
 
180
235
  const serverConn = this.deps.getServerConn();
181
236
  if (serverConn && typeof adapter.setServerConn === 'function') {
@@ -184,12 +239,12 @@ export class DaemonCliManager {
184
239
  adapter.setOnStatusChange(() => {
185
240
  this.deps.onStatusChange();
186
241
  const status = adapter.getStatus?.();
187
- if (status?.status === 'stopped') {
242
+ if (status?.status === 'stopped' || status?.status === 'error') {
188
243
  setTimeout(() => {
189
244
  if (this.adapters.get(key) === adapter) {
190
245
  this.adapters.delete(key);
191
246
  this.deps.removeAgentTracking(key);
192
- console.log(chalk.yellow(` 🧹 Auto-cleaned stopped CLI: ${adapter.cliType}`));
247
+ LOG.info('CLI', `🧹 Auto-cleaned ${status.status} CLI: ${adapter.cliType}`);
193
248
  this.deps.onStatusChange();
194
249
  }
195
250
  }, 3000);
@@ -214,13 +269,26 @@ export class DaemonCliManager {
214
269
  async stopSession(key: string): Promise<void> {
215
270
  const adapter = this.adapters.get(key);
216
271
  if (adapter) {
217
- adapter.shutdown();
272
+ try {
273
+ adapter.shutdown();
274
+ } catch (e: any) {
275
+ LOG.warn('CLI', `Shutdown error for ${adapter.cliType}: ${e?.message} (force-cleaning)`);
276
+ }
277
+ // Always cleanup regardless of shutdown success
218
278
  this.adapters.delete(key);
219
279
  this.deps.removeAgentTracking(key);
220
- // Also remove from InstanceManager
221
280
  this.deps.getInstanceManager()?.removeInstance(key);
222
- console.log(chalk.yellow(` 🛑 CLI Agent stopped: ${adapter.cliType} in ${adapter.workingDir}`));
281
+ LOG.info('CLI', `🛑 Agent stopped: ${adapter.cliType} in ${adapter.workingDir}`);
223
282
  this.deps.onStatusChange();
283
+ } else {
284
+ // Adapter not found — try InstanceManager direct removal
285
+ const im = this.deps.getInstanceManager();
286
+ if (im) {
287
+ im.removeInstance(key);
288
+ this.deps.removeAgentTracking(key);
289
+ LOG.warn('CLI', `🧹 Force-removed orphan entry: ${key}`);
290
+ this.deps.onStatusChange();
291
+ }
224
292
  }
225
293
  }
226
294
 
@@ -270,8 +338,28 @@ export class DaemonCliManager {
270
338
  switch (cmd) {
271
339
  case 'launch_cli': {
272
340
  const cliType = args?.cliType;
273
- const defaultedToHome = !args?.dir;
274
- const dir = args?.dir || os.homedir();
341
+ const config = loadConfig();
342
+ const resolved = resolveLaunchDirectory(
343
+ {
344
+ dir: args?.dir,
345
+ workspaceId: args?.workspaceId,
346
+ useDefaultWorkspace: args?.useDefaultWorkspace === true,
347
+ useHome: args?.useHome === true,
348
+ },
349
+ config,
350
+ );
351
+ if (!resolved.ok) {
352
+ const ws = getWorkspaceState(config);
353
+ return {
354
+ success: false,
355
+ error: resolved.message,
356
+ code: resolved.code,
357
+ workspaces: ws.workspaces,
358
+ defaultWorkspacePath: ws.defaultWorkspacePath,
359
+ };
360
+ }
361
+ const dir = resolved.path;
362
+ const launchSource = resolved.source;
275
363
  if (!cliType) throw new Error('cliType required');
276
364
 
277
365
  await this.startSession(cliType, dir, args?.cliArgs, args?.initialModel);
@@ -284,20 +372,9 @@ export class DaemonCliManager {
284
372
  }
285
373
  }
286
374
 
287
- try {
288
- const config = loadConfig();
289
- console.log(chalk.cyan(` 📂 Saving recent workspace: ${dir}`));
290
- const recent = config.recentCliWorkspaces || [];
291
- if (!recent.includes(dir)) {
292
- const updated = [dir, ...recent].slice(0, 10);
293
- saveConfig({ ...config, recentCliWorkspaces: updated });
294
- console.log(chalk.green(` ✓ Recent workspace saved: ${dir}`));
295
- }
296
- } catch (e) {
297
- console.error(chalk.red(` ✗ Failed to save recent workspace: ${e}`));
298
- }
375
+ this.persistRecentDir(cliType, dir);
299
376
 
300
- return { success: true, cliType, dir, id: newKey, defaultedToHome };
377
+ return { success: true, cliType, dir, id: newKey, launchSource };
301
378
  }
302
379
  case 'stop_cli': {
303
380
  const cliType = args?.cliType;
@@ -314,11 +391,32 @@ export class DaemonCliManager {
314
391
  }
315
392
  case 'restart_session': {
316
393
  const cliType = args?.cliType || args?.agentType || args?.ideType;
317
- const dir = args?.dir || process.cwd();
394
+ const cfg = loadConfig();
395
+ const rdir = resolveLaunchDirectory(
396
+ {
397
+ dir: args?.dir,
398
+ workspaceId: args?.workspaceId,
399
+ useDefaultWorkspace: args?.useDefaultWorkspace === true,
400
+ useHome: args?.useHome === true,
401
+ },
402
+ cfg,
403
+ );
404
+ if (!rdir.ok) {
405
+ const ws = getWorkspaceState(cfg);
406
+ return {
407
+ success: false,
408
+ error: rdir.message,
409
+ code: rdir.code,
410
+ workspaces: ws.workspaces,
411
+ defaultWorkspacePath: ws.defaultWorkspacePath,
412
+ };
413
+ }
414
+ const dir = rdir.path;
318
415
  if (!cliType) throw new Error('cliType required');
319
416
  const found = this.findAdapter(cliType, { instanceKey: args?._targetInstance, dir });
320
417
  if (found) await this.stopSession(found.key);
321
418
  await this.startSession(cliType, dir);
419
+ this.persistRecentDir(cliType, dir);
322
420
  return { success: true, restarted: true };
323
421
  }
324
422
  case 'agent_command': {