@adhdev/daemon-core 0.8.27 → 0.8.29

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/agent-stream/manager.d.ts +1 -1
  2. package/dist/agent-stream/provider-adapter.d.ts +5 -0
  3. package/dist/commands/handler.d.ts +1 -0
  4. package/dist/commands/router.d.ts +5 -0
  5. package/dist/commands/stream-commands.d.ts +1 -1
  6. package/dist/config/chat-history.d.ts +12 -0
  7. package/dist/detection/cli-detector.d.ts +6 -2
  8. package/dist/detection/ide-detector.d.ts +2 -1
  9. package/dist/index.js +987 -377
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +985 -375
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/providers/acp-provider-instance.d.ts +1 -1
  14. package/dist/providers/cli-provider-instance.d.ts +1 -0
  15. package/dist/providers/provider-loader.d.ts +26 -0
  16. package/dist/shared-types.d.ts +2 -0
  17. package/dist/status/snapshot.d.ts +8 -0
  18. package/node_modules/@adhdev/session-host-core/dist/index.d.mts +24 -1
  19. package/node_modules/@adhdev/session-host-core/dist/index.d.ts +24 -1
  20. package/node_modules/@adhdev/session-host-core/dist/index.js +6 -1
  21. package/node_modules/@adhdev/session-host-core/dist/index.js.map +1 -1
  22. package/node_modules/@adhdev/session-host-core/dist/index.mjs +6 -1
  23. package/node_modules/@adhdev/session-host-core/dist/index.mjs.map +1 -1
  24. package/node_modules/@adhdev/session-host-core/package.json +1 -1
  25. package/package.json +1 -1
  26. package/src/agent-stream/manager.ts +2 -2
  27. package/src/agent-stream/provider-adapter.ts +111 -4
  28. package/src/boot/daemon-lifecycle.ts +28 -1
  29. package/src/cli-adapters/provider-cli-adapter.ts +17 -3
  30. package/src/commands/chat-commands.ts +89 -10
  31. package/src/commands/cli-manager.ts +16 -2
  32. package/src/commands/handler.ts +1 -0
  33. package/src/commands/router.ts +23 -1
  34. package/src/commands/stream-commands.ts +6 -3
  35. package/src/config/chat-history.ts +269 -18
  36. package/src/detection/cli-detector.ts +72 -29
  37. package/src/detection/ide-detector.ts +24 -8
  38. package/src/launch.ts +1 -1
  39. package/src/providers/acp-provider-instance.ts +19 -10
  40. package/src/providers/cli-provider-instance.ts +17 -2
  41. package/src/providers/provider-loader.ts +144 -11
  42. package/src/shared-types.ts +2 -0
  43. package/src/status/snapshot.ts +19 -1
@@ -9,6 +9,8 @@
9
9
 
10
10
  import { exec } from 'child_process';
11
11
  import * as os from 'os';
12
+ import * as path from 'path';
13
+ import { existsSync } from 'fs';
12
14
  import type { ProviderLoader } from '../providers/provider-loader.js';
13
15
 
14
16
  export interface CLIInfo {
@@ -28,6 +30,33 @@ function parseVersion(raw: string): string {
28
30
  return match ? match[1] : raw.split('\n')[0].slice(0, 100);
29
31
  }
30
32
 
33
+ function shellQuote(value: string): string {
34
+ if (/^[a-zA-Z0-9_./:@%+=,-]+$/.test(value)) return value;
35
+ return `"${value.replace(/(["\\$`])/g, '\\$1')}"`;
36
+ }
37
+
38
+ function expandHome(value: string): string {
39
+ const trimmed = value.trim();
40
+ if (!trimmed.startsWith('~')) return trimmed;
41
+ return path.join(os.homedir(), trimmed.slice(1));
42
+ }
43
+
44
+ function isExplicitCommandPath(command: string): boolean {
45
+ const trimmed = command.trim();
46
+ return path.isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\') || trimmed.startsWith('~');
47
+ }
48
+
49
+ function resolveCommandPath(command: string): string | null {
50
+ const trimmed = command.trim();
51
+ if (!trimmed) return null;
52
+ if (isExplicitCommandPath(trimmed)) {
53
+ const expanded = expandHome(trimmed);
54
+ const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(expanded);
55
+ return existsSync(candidate) ? candidate : null;
56
+ }
57
+ return null;
58
+ }
59
+
31
60
  /** Run a shell command with timeout, returning stdout or null on failure */
32
61
  function execAsync(cmd: string, timeoutMs = 5000): Promise<string | null> {
33
62
  return new Promise((resolve) => {
@@ -47,9 +76,13 @@ function execAsync(cmd: string, timeoutMs = 5000): Promise<string | null> {
47
76
  * Detect all CLI/ACP agents (parallel)
48
77
  * @param providerLoader ProviderLoader instance (dynamic list creation)
49
78
  */
50
- export async function detectCLIs(providerLoader?: ProviderLoader): Promise<CLIInfo[]> {
79
+ export async function detectCLIs(
80
+ providerLoader?: ProviderLoader,
81
+ options?: { includeVersion?: boolean },
82
+ ): Promise<CLIInfo[]> {
51
83
  const platform = os.platform();
52
84
  const whichCmd = platform === 'win32' ? 'where' : 'which';
85
+ const includeVersion = options?.includeVersion !== false;
53
86
 
54
87
  // Provider-based dynamic list creation, fallback is empty array
55
88
  const cliList = providerLoader
@@ -60,28 +93,31 @@ export async function detectCLIs(providerLoader?: ProviderLoader): Promise<CLIIn
60
93
  const results = await Promise.all(
61
94
  cliList.map(async (cli): Promise<CLIInfo> => {
62
95
  try {
63
- const pathResult = await execAsync(`${whichCmd} ${cli.command}`);
96
+ const explicitPath = resolveCommandPath(cli.command);
97
+ const pathResult = explicitPath || await execAsync(`${whichCmd} ${shellQuote(cli.command)}`);
64
98
  if (!pathResult) return { ...cli, installed: false };
65
99
 
66
- const firstPath = pathResult.split('\n')[0];
100
+ const firstPath = explicitPath || pathResult.split('\n')[0];
67
101
 
68
102
  // Get version (parallel with other checks)
69
103
  let version: string | undefined;
70
- try {
104
+ if (includeVersion) {
71
105
  const versionCommands = [
106
+ `"${firstPath}" --version`,
107
+ `"${firstPath}" -V`,
108
+ `"${firstPath}" -v`,
72
109
  cli.versionCommand,
73
- `${cli.command} --version`,
74
- `${cli.command} -V`,
75
- `${cli.command} -v`,
76
110
  ].filter((v): v is string => !!v);
77
- for (const versionCommand of versionCommands) {
78
- const versionResult = await execAsync(versionCommand, 3000);
79
- if (versionResult) {
80
- version = parseVersion(versionResult);
81
- break;
111
+ try {
112
+ for (const versionCommand of versionCommands) {
113
+ const versionResult = await execAsync(versionCommand, 3000);
114
+ if (versionResult) {
115
+ version = parseVersion(versionResult);
116
+ break;
117
+ }
82
118
  }
83
- }
84
- } catch { }
119
+ } catch { }
120
+ }
85
121
 
86
122
  return { ...cli, installed: true, version, path: firstPath };
87
123
  } catch {
@@ -94,7 +130,11 @@ export async function detectCLIs(providerLoader?: ProviderLoader): Promise<CLIIn
94
130
  }
95
131
 
96
132
  /** Detect specific CLI — only probes the one requested provider */
97
- export async function detectCLI(cliId: string, providerLoader?: ProviderLoader): Promise<CLIInfo | null> {
133
+ export async function detectCLI(
134
+ cliId: string,
135
+ providerLoader?: ProviderLoader,
136
+ options?: { includeVersion?: boolean },
137
+ ): Promise<CLIInfo | null> {
98
138
  const resolvedId = providerLoader ? providerLoader.resolveAlias(cliId) : cliId;
99
139
 
100
140
  if (providerLoader) {
@@ -104,25 +144,28 @@ export async function detectCLI(cliId: string, providerLoader?: ProviderLoader):
104
144
  const platform = os.platform();
105
145
  const whichCmd = platform === 'win32' ? 'where' : 'which';
106
146
  try {
107
- const pathResult = await execAsync(`${whichCmd} ${target.command}`);
147
+ const explicitPath = resolveCommandPath(target.command);
148
+ const pathResult = explicitPath || await execAsync(`${whichCmd} ${shellQuote(target.command)}`);
108
149
  if (!pathResult) return null;
109
- const firstPath = pathResult.split('\n')[0];
150
+ const firstPath = explicitPath || pathResult.split('\n')[0];
110
151
  let version: string | undefined;
111
- try {
152
+ if (options?.includeVersion !== false) {
112
153
  const versionCommands = [
154
+ `"${firstPath}" --version`,
155
+ `"${firstPath}" -V`,
156
+ `"${firstPath}" -v`,
113
157
  target.versionCommand,
114
- `${target.command} --version`,
115
- `${target.command} -V`,
116
- `${target.command} -v`,
117
158
  ].filter((v): v is string => !!v);
118
- for (const versionCommand of versionCommands) {
119
- const versionResult = await execAsync(versionCommand, 3000);
120
- if (versionResult) {
121
- version = parseVersion(versionResult);
122
- break;
159
+ try {
160
+ for (const versionCommand of versionCommands) {
161
+ const versionResult = await execAsync(versionCommand, 3000);
162
+ if (versionResult) {
163
+ version = parseVersion(versionResult);
164
+ break;
165
+ }
123
166
  }
124
- }
125
- } catch { }
167
+ } catch { }
168
+ }
126
169
  return { ...target, installed: true, version, path: firstPath };
127
170
  } catch {
128
171
  return null;
@@ -131,6 +174,6 @@ export async function detectCLI(cliId: string, providerLoader?: ProviderLoader):
131
174
  }
132
175
 
133
176
  // Fallback: full scan for unknown provider IDs
134
- const all = await detectCLIs(providerLoader);
177
+ const all = await detectCLIs(providerLoader, options);
135
178
  return all.find((c) => c.id === resolvedId && c.installed) || null;
136
179
  }
@@ -10,6 +10,8 @@
10
10
  import { execSync } from 'child_process';
11
11
  import { existsSync } from 'fs';
12
12
  import { platform, homedir } from 'os';
13
+ import * as path from 'path';
14
+ import type { ProviderLoader } from '../providers/provider-loader.js';
13
15
 
14
16
  // ─── Types ──────────────────────────────────────
15
17
 
@@ -62,9 +64,18 @@ function getMergedDefinitions(): IDEDefinition[] {
62
64
  }
63
65
 
64
66
  function findCliCommand(command: string): string | null {
67
+ const trimmed = String(command || '').trim();
68
+ if (!trimmed) return null;
69
+ if (path.isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\') || trimmed.startsWith('~')) {
70
+ const candidate = trimmed.startsWith('~')
71
+ ? path.join(homedir(), trimmed.slice(1))
72
+ : trimmed;
73
+ const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(candidate);
74
+ return existsSync(resolved) ? resolved : null;
75
+ }
65
76
  try {
66
77
  const result = execSync(
67
- platform() === 'win32' ? `where ${command}` : `which ${command}`,
78
+ platform() === 'win32' ? `where ${trimmed}` : `which ${trimmed}`,
68
79
  { encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
69
80
  ).trim();
70
81
  return result.split('\n')[0] || null;
@@ -89,27 +100,29 @@ function getIdeVersion(cliCommand: string): string | null {
89
100
  function checkPathExists(paths: string[]): string | null {
90
101
  const home = homedir();
91
102
  for (const p of paths) {
92
- if (p.includes('*')) {
103
+ const normalized = p.startsWith('~')
104
+ ? path.join(home, p.slice(1))
105
+ : p;
106
+ if (normalized.includes('*')) {
93
107
  // Wildcard expansion: replace `*` with the current user's home folder name
94
108
  // e.g. "C:\Users\*\AppData\..." → "C:\Users\vilmi\AppData\..."
95
109
  const username = home.split(/[\\/]/).pop() || '';
96
- const resolved = p.replace('*', username);
110
+ const resolved = normalized.replace('*', username);
97
111
  if (existsSync(resolved)) return resolved;
98
112
  } else {
99
- if (existsSync(p)) return p;
113
+ if (existsSync(normalized)) return normalized;
100
114
  }
101
115
  }
102
116
  return null;
103
117
  }
104
118
 
105
- export async function detectIDEs(): Promise<IDEInfo[]> {
119
+ export async function detectIDEs(providerLoader?: ProviderLoader): Promise<IDEInfo[]> {
106
120
  const os = platform() as 'darwin' | 'win32' | 'linux';
107
121
  const results: IDEInfo[] = [];
108
122
 
109
123
  for (const def of getMergedDefinitions()) {
110
- const cliPath = findCliCommand(def.cli);
111
- const appPath = checkPathExists(def.paths[os] || []);
112
- const installed = !!(cliPath || appPath);
124
+ const cliPath = findCliCommand(providerLoader?.getIdeCliCommand(def.id, def.cli) || def.cli);
125
+ const appPath = checkPathExists(providerLoader?.getIdePathCandidates(def.id, def.paths[os] || []) || []);
113
126
 
114
127
  let resolvedCli = cliPath;
115
128
 
@@ -136,6 +149,9 @@ export async function detectIDEs(): Promise<IDEInfo[]> {
136
149
  }
137
150
  }
138
151
 
152
+ const installed = os === 'darwin'
153
+ ? !!(resolvedCli || appPath)
154
+ : !!resolvedCli;
139
155
  const version = resolvedCli ? getIdeVersion(resolvedCli) : null;
140
156
 
141
157
  results.push({
package/src/launch.ts CHANGED
@@ -310,7 +310,7 @@ export async function launchWithCdp(options: LaunchOptions = {}): Promise<Launch
310
310
 
311
311
  // 1. IDE determine
312
312
  let targetIde: IDEInfo | undefined;
313
- const ides = await detectIDEs();
313
+ const ides = await detectIDEs(getProviderLoader());
314
314
 
315
315
  if (options.ideId) {
316
316
  targetIde = ides.find(i => i.id === options.ideId && i.installed);
@@ -327,8 +327,9 @@ export class AcpProviderInstance implements ProviderInstance {
327
327
  // Find configId for this category
328
328
  const opt = this.configOptions.find(c => c.category === category);
329
329
  if (!opt) {
330
- this.log.warn(`[${this.type}] No config option for category: ${category}`);
331
- return;
330
+ const message = `[${this.type}] No config option for category: ${category}`;
331
+ this.log.warn(message);
332
+ throw new Error(message);
332
333
  }
333
334
 
334
335
  // Static config mode: update selection and restart process
@@ -343,8 +344,9 @@ export class AcpProviderInstance implements ProviderInstance {
343
344
  }
344
345
 
345
346
  if (!this.connection || !this.sessionId) {
346
- this.log.warn(`[${this.type}] Cannot set config: no active connection/session`);
347
- return;
347
+ const message = `[${this.type}] Cannot set config: no active connection/session`;
348
+ this.log.warn(message);
349
+ throw new Error(message);
348
350
  }
349
351
 
350
352
  try {
@@ -361,7 +363,9 @@ export class AcpProviderInstance implements ProviderInstance {
361
363
  if (result?.configOptions) this.parseConfigOptions(result.configOptions);
362
364
  this.log.info(`[${this.type}] Config ${category} set to: ${value} | response: ${JSON.stringify(result)?.slice(0, 300)}`);
363
365
  } catch (e: any) {
364
- this.log.warn(`[${this.type}] set_config_option failed: ${e?.message}`);
366
+ const message = e?.message || 'Unknown ACP config error';
367
+ this.log.warn(`[${this.type}] set_config_option failed: ${message}`);
368
+ throw new Error(message);
365
369
  }
366
370
  }
367
371
 
@@ -380,8 +384,9 @@ export class AcpProviderInstance implements ProviderInstance {
380
384
  }
381
385
 
382
386
  if (!this.connection || !this.sessionId) {
383
- this.log.warn(`[${this.type}] Cannot set mode: no active connection/session`);
384
- return;
387
+ const message = `[${this.type}] Cannot set mode: no active connection/session`;
388
+ this.log.warn(message);
389
+ throw new Error(message);
385
390
  }
386
391
 
387
392
  try {
@@ -392,7 +397,9 @@ export class AcpProviderInstance implements ProviderInstance {
392
397
  this.currentMode = modeId;
393
398
  this.log.info(`[${this.type}] Mode set to: ${modeId}`);
394
399
  } catch (e: any) {
395
- this.log.warn(`[${this.type}] set_mode failed: ${e?.message}`);
400
+ const message = e?.message || 'Unknown ACP mode error';
401
+ this.log.warn(`[${this.type}] set_mode failed: ${message}`);
402
+ throw new Error(message);
396
403
  }
397
404
  }
398
405
 
@@ -447,7 +454,9 @@ export class AcpProviderInstance implements ProviderInstance {
447
454
  throw new Error(`[ACP:${this.type}] No spawn config defined`);
448
455
  }
449
456
 
450
- const command = spawnConfig.command;
457
+ const command = typeof this.settings.executablePath === 'string' && this.settings.executablePath.trim()
458
+ ? this.settings.executablePath.trim()
459
+ : spawnConfig.command;
451
460
  // Static config: create args via spawnArgBuilder (when provider defines it)
452
461
  let baseArgs = spawnConfig.args || [];
453
462
  if (this.provider.spawnArgBuilder && Object.keys(this.selectedConfig).length > 0) {
@@ -822,7 +831,7 @@ export class AcpProviderInstance implements ProviderInstance {
822
831
 
823
832
  private permissionResolvers: ((approved: boolean) => void)[] = [];
824
833
 
825
- private async resolvePermission(approved: boolean): Promise<void> {
834
+ async resolvePermission(approved: boolean): Promise<void> {
826
835
  const resolver = this.permissionResolvers.shift();
827
836
  if (resolver) {
828
837
  resolver(approved);
@@ -60,6 +60,7 @@ export class CliProviderInstance implements ProviderInstance {
60
60
  private historyWriter: ChatHistoryWriter;
61
61
  private runtimeMessages: Array<{ key: string; message: ChatMessage }> = [];
62
62
  readonly instanceId: string;
63
+ private suppressIdleHistoryReplay = false;
63
64
 
64
65
  private presentationMode: 'terminal' | 'chat';
65
66
  private providerSessionId?: string;
@@ -135,7 +136,15 @@ export class CliProviderInstance implements ProviderInstance {
135
136
  await this.adapter.spawn();
136
137
  this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
137
138
  if (this.providerSessionId) {
139
+ this.historyWriter.compactHistorySession(this.type, this.providerSessionId);
138
140
  const restoredHistory = readChatHistory(this.type, 0, 200, this.providerSessionId);
141
+ this.historyWriter.seedSessionHistory(
142
+ this.type,
143
+ restoredHistory.messages,
144
+ this.providerSessionId,
145
+ this.instanceId,
146
+ );
147
+ this.suppressIdleHistoryReplay = restoredHistory.messages.length > 0;
139
148
  if (restoredHistory.messages.length > 0) {
140
149
  this.adapter.seedCommittedMessages(
141
150
  restoredHistory.messages.map((message) => ({
@@ -184,7 +193,7 @@ export class CliProviderInstance implements ProviderInstance {
184
193
  } else if (this.type === 'codex-cli') {
185
194
  probedSessionId = this.probeSessionIdFromConfig({
186
195
  dbPath: '~/.codex/state_5.sqlite',
187
- query: 'select id from threads where cwd in ({dirs}) and created_at >= ? and archived = 0 order by created_at desc limit 1',
196
+ query: 'select id from threads where cwd in ({dirs}) and updated_at >= ? and archived = 0 order by updated_at desc limit 1',
188
197
  timestampFormat: 'unix_s',
189
198
  });
190
199
  } else if (this.type === 'goose-cli') {
@@ -260,6 +269,10 @@ export class CliProviderInstance implements ProviderInstance {
260
269
  const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
261
270
 
262
271
  if (parsedMessages.length > 0) {
272
+ const shouldSkipReplayPersist =
273
+ this.suppressIdleHistoryReplay
274
+ && adapterStatus.status === 'idle'
275
+ && parsedStatus?.status === 'idle';
263
276
  let messagesToSave = parsedMessages;
264
277
  if ((parsedStatus?.status === 'generating' || parsedStatus?.status === 'long_generating')) {
265
278
  const lastIdx = messagesToSave.length - 1;
@@ -267,7 +280,7 @@ export class CliProviderInstance implements ProviderInstance {
267
280
  messagesToSave = messagesToSave.slice(0, lastIdx);
268
281
  }
269
282
  }
270
- if (messagesToSave.length > 0) {
283
+ if (!shouldSkipReplayPersist && messagesToSave.length > 0) {
271
284
  this.historyWriter.appendNewMessages(
272
285
  this.type,
273
286
  messagesToSave,
@@ -374,6 +387,7 @@ export class CliProviderInstance implements ProviderInstance {
374
387
  if (newStatus !== this.lastStatus) {
375
388
  LOG.info('CLI', `[${this.type}] status: ${this.lastStatus} → ${newStatus}`);
376
389
  if (this.lastStatus === 'idle' && newStatus === 'generating') {
390
+ this.suppressIdleHistoryReplay = false;
377
391
  // Cancel any pending completed event (multi-step: idle→generating resume)
378
392
  if (this.completedDebouncePending) {
379
393
  LOG.info('CLI', `[${this.type}] cancelled pending completed (resumed generating)`);
@@ -394,6 +408,7 @@ export class CliProviderInstance implements ProviderInstance {
394
408
  this.generatingDebounceTimer = null;
395
409
  }, 1000);
396
410
  } else if (newStatus === 'waiting_approval') {
411
+ this.suppressIdleHistoryReplay = false;
397
412
  // Flush pending generating_started if debounce still pending
398
413
  if (this.generatingDebouncePending) {
399
414
  if (this.generatingDebounceTimer) { clearTimeout(this.generatingDebounceTimer); this.generatingDebounceTimer = null; }
@@ -24,12 +24,19 @@ import type {
24
24
  ProviderModule,
25
25
  ProviderCategory,
26
26
  ProviderScripts,
27
+ ProviderSettingDef,
27
28
  ProviderSettingSchema,
28
29
  ResolvedProvider,
29
30
  } from './contracts.js';
30
31
 
32
+ interface ProviderAvailabilityState {
33
+ installed: boolean;
34
+ detectedPath: string | null;
35
+ }
36
+
31
37
  export class ProviderLoader {
32
38
  private providers = new Map<string, ProviderModule>();
39
+ private providerAvailability = new Map<string, ProviderAvailabilityState>();
33
40
  private userDir: string;
34
41
  private upstreamDir: string;
35
42
  private disableUpstream: boolean;
@@ -152,6 +159,7 @@ export class ProviderLoader {
152
159
  */
153
160
  loadAll(): void {
154
161
  this.providers.clear();
162
+ this.providerAvailability.clear();
155
163
 
156
164
  // 1. Load upstream (GitHub auto-download — primary source)
157
165
  let upstreamCount = 0;
@@ -236,11 +244,12 @@ export class ProviderLoader {
236
244
  const versionCommand = typeof verCmdConfig === 'object' && verCmdConfig !== null
237
245
  ? verCmdConfig[process.platform]
238
246
  : verCmdConfig;
247
+ const command = this.getSpawnCommand(p.type, p.spawn.command);
239
248
  result.push({
240
249
  id: p.type,
241
250
  displayName: p.displayName || p.name,
242
251
  icon: p.icon || '🔧',
243
- command: p.spawn.command,
252
+ command,
244
253
  category: p.category,
245
254
  ...(typeof versionCommand === 'string' && versionCommand.trim()
246
255
  ? { versionCommand: versionCommand.trim() }
@@ -386,6 +395,80 @@ export class ProviderLoader {
386
395
  .map(p => p.type);
387
396
  }
388
397
 
398
+ getSpawnCommand(type: string, fallback?: string): string {
399
+ const override = this.getOptionalStringSetting(type, 'executablePath');
400
+ if (override) return override;
401
+ return fallback || this.providers.get(type)?.spawn?.command || type;
402
+ }
403
+
404
+ getIdeCliCommand(type: string, fallback?: string | null): string | null {
405
+ const override = this.getOptionalStringSetting(type, 'cliPathOverride');
406
+ if (override) return override;
407
+ return fallback || this.providers.get(type)?.cli || null;
408
+ }
409
+
410
+ getIdePathCandidates(type: string, fallback?: string[]): string[] {
411
+ const override = this.getOptionalStringSetting(type, 'appPathOverride');
412
+ if (override) return [override];
413
+ if (fallback && fallback.length > 0) return fallback;
414
+ const osPaths = this.providers.get(type)?.paths?.[process.platform];
415
+ return Array.isArray(osPaths) ? [...osPaths] : [];
416
+ }
417
+
418
+ setProviderAvailability(type: string, state: { installed: boolean; detectedPath?: string | null }): void {
419
+ this.providerAvailability.set(type, {
420
+ installed: !!state.installed,
421
+ detectedPath: state.detectedPath ?? null,
422
+ });
423
+ }
424
+
425
+ setCliDetectionResults(results: Array<{ id: string; installed: boolean; path?: string }>, replace: boolean = true): void {
426
+ if (replace) {
427
+ for (const provider of this.providers.values()) {
428
+ if (provider.category === 'cli' || provider.category === 'acp') {
429
+ this.providerAvailability.set(provider.type, { installed: false, detectedPath: null });
430
+ }
431
+ }
432
+ }
433
+ for (const result of results) {
434
+ this.setProviderAvailability(result.id, {
435
+ installed: !!result.installed,
436
+ detectedPath: result.path || null,
437
+ });
438
+ }
439
+ }
440
+
441
+ setIdeDetectionResults(results: Array<{ id: string; installed: boolean; path?: string | null; cliCommand?: string | null }>, replace: boolean = true): void {
442
+ if (replace) {
443
+ for (const provider of this.providers.values()) {
444
+ if (provider.category === 'ide') {
445
+ this.providerAvailability.set(provider.type, { installed: false, detectedPath: null });
446
+ }
447
+ }
448
+ }
449
+ for (const result of results) {
450
+ this.setProviderAvailability(result.id, {
451
+ installed: !!result.installed,
452
+ detectedPath: result.cliCommand || result.path || null,
453
+ });
454
+ }
455
+ }
456
+
457
+ getAvailableProviderInfos(): Array<ProviderModule & { installed?: boolean; detectedPath?: string | null }> {
458
+ return this.getAll().map((provider) => {
459
+ const availability = this.providerAvailability.get(provider.type);
460
+ return {
461
+ ...provider,
462
+ ...(availability
463
+ ? {
464
+ installed: availability.installed,
465
+ detectedPath: availability.detectedPath,
466
+ }
467
+ : {}),
468
+ };
469
+ });
470
+ }
471
+
389
472
  /**
390
473
  * Register IDE providers to core/detector registry
391
474
  * → Enables detectIDEs() to detect provider.js-based IDEs
@@ -888,9 +971,8 @@ export class ProviderLoader {
888
971
  * Get public settings schema for a provider (for dashboard UI rendering)
889
972
  */
890
973
  getPublicSettings(type: string): ProviderSettingSchema[] {
891
- const provider = this.providers.get(type);
892
- if (!provider?.settings) return [];
893
- return Object.entries(provider.settings)
974
+ const settings = this.getSettingsSchema(type);
975
+ return Object.entries(settings)
894
976
  .filter(([, def]) => (def as any).public === true)
895
977
  .map(([key, def]) => ({ key, ...(def as any) }));
896
978
  }
@@ -911,8 +993,7 @@ export class ProviderLoader {
911
993
  * Resolved setting value for a provider (default + user override)
912
994
  */
913
995
  getSettingValue(type: string, key: string): any {
914
- const provider = this.providers.get(type);
915
- const schemaDef = provider?.settings?.[key];
996
+ const schemaDef = this.getSettingsSchema(type)[key];
916
997
  const defaultVal = schemaDef ? (schemaDef as any).default : undefined;
917
998
 
918
999
  // Load user-saved value
@@ -930,10 +1011,9 @@ export class ProviderLoader {
930
1011
  * All resolved settings for a provider (default + user override)
931
1012
  */
932
1013
  getSettings(type: string): Record<string, any> {
933
- const provider = this.providers.get(type);
934
- if (!provider?.settings) return {};
1014
+ const settings = this.getSettingsSchema(type);
935
1015
  const result: Record<string, any> = {};
936
- for (const [key, def] of Object.entries(provider.settings)) {
1016
+ for (const [key] of Object.entries(settings)) {
937
1017
  result[key] = this.getSettingValue(type, key);
938
1018
  }
939
1019
  return result;
@@ -943,8 +1023,7 @@ export class ProviderLoader {
943
1023
  * Save provider setting value (writes to config.json)
944
1024
  */
945
1025
  setSetting(type: string, key: string, value: any): boolean {
946
- const provider = this.providers.get(type);
947
- const schemaDef = provider?.settings?.[key] as any;
1026
+ const schemaDef = this.getSettingsSchema(type)[key] as any;
948
1027
  if (!schemaDef) return false;
949
1028
 
950
1029
  // Non-public settings cannot be modified externally
@@ -952,6 +1031,7 @@ export class ProviderLoader {
952
1031
 
953
1032
  // Type validation
954
1033
  if (schemaDef.type === 'boolean' && typeof value !== 'boolean') return false;
1034
+ if (schemaDef.type === 'string' && typeof value !== 'string') return false;
955
1035
  if (schemaDef.type === 'number') {
956
1036
  if (typeof value !== 'number') return false;
957
1037
  if (schemaDef.min !== undefined && value < schemaDef.min) return false;
@@ -974,6 +1054,59 @@ export class ProviderLoader {
974
1054
  }
975
1055
  }
976
1056
 
1057
+ private getOptionalStringSetting(type: string, key: string): string | null {
1058
+ const value = this.getSettingValue(type, key);
1059
+ if (typeof value !== 'string') return null;
1060
+ const trimmed = value.trim();
1061
+ return trimmed ? trimmed : null;
1062
+ }
1063
+
1064
+ private getSettingsSchema(type: string): Record<string, ProviderSettingDef> {
1065
+ const provider = this.providers.get(type);
1066
+ if (!provider) return {};
1067
+ return {
1068
+ ...this.getSyntheticSettings(type, provider),
1069
+ ...(provider.settings || {}),
1070
+ };
1071
+ }
1072
+
1073
+ private getSyntheticSettings(type: string, provider: ProviderModule): Record<string, ProviderSettingDef> {
1074
+ const result: Record<string, ProviderSettingDef> = {};
1075
+
1076
+ if ((provider.category === 'cli' || provider.category === 'acp') && provider.spawn?.command && !provider.settings?.executablePath) {
1077
+ result.executablePath = {
1078
+ type: 'string',
1079
+ default: '',
1080
+ public: true,
1081
+ label: 'Executable path',
1082
+ description: 'Optional absolute path for this provider binary. Leave blank to use the default PATH lookup.',
1083
+ };
1084
+ }
1085
+
1086
+ if (provider.category === 'ide') {
1087
+ if (provider.cli && !provider.settings?.cliPathOverride) {
1088
+ result.cliPathOverride = {
1089
+ type: 'string',
1090
+ default: '',
1091
+ public: true,
1092
+ label: 'CLI path override',
1093
+ description: 'Optional absolute path for the IDE CLI launcher. Leave blank to use the detected default.',
1094
+ };
1095
+ }
1096
+ if (provider.paths && !provider.settings?.appPathOverride) {
1097
+ result.appPathOverride = {
1098
+ type: 'string',
1099
+ default: '',
1100
+ public: true,
1101
+ label: 'App path override',
1102
+ description: 'Optional absolute path for the IDE app bundle or executable. Leave blank to use the default install locations.',
1103
+ };
1104
+ }
1105
+ }
1106
+
1107
+ return result;
1108
+ }
1109
+
977
1110
  // ─── Private ───────────────────────────────────
978
1111
 
979
1112
  /**
@@ -136,6 +136,8 @@ export interface AvailableProviderInfo {
136
136
  category: 'ide' | 'extension' | 'cli' | 'acp';
137
137
  displayName: string;
138
138
  icon: string;
139
+ installed?: boolean;
140
+ detectedPath?: string | null;
139
141
  }
140
142
 
141
143
  /** ACP config option (model/mode/thought_level selection) */