@adhdev/daemon-core 0.8.25 → 0.8.28

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 (47) hide show
  1. package/dist/agent-stream/provider-adapter.d.ts +1 -0
  2. package/dist/boot/daemon-lifecycle.d.ts +2 -0
  3. package/dist/cli-adapters/pty-transport.d.ts +3 -0
  4. package/dist/commands/handler.d.ts +1 -0
  5. package/dist/commands/router.d.ts +24 -0
  6. package/dist/commands/stream-commands.d.ts +1 -1
  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 +824 -369
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +822 -367
  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/extension-provider-instance.d.ts +7 -0
  16. package/dist/providers/provider-loader.d.ts +26 -0
  17. package/dist/shared-types.d.ts +2 -0
  18. package/dist/status/normalize.js +14 -2
  19. package/dist/status/normalize.js.map +1 -1
  20. package/dist/status/normalize.mjs +14 -2
  21. package/dist/status/normalize.mjs.map +1 -1
  22. package/dist/status/snapshot.d.ts +8 -2
  23. package/node_modules/@adhdev/session-host-core/dist/index.d.mts +72 -1
  24. package/node_modules/@adhdev/session-host-core/dist/index.d.ts +72 -1
  25. package/node_modules/@adhdev/session-host-core/dist/index.js.map +1 -1
  26. package/node_modules/@adhdev/session-host-core/package.json +1 -1
  27. package/package.json +1 -1
  28. package/src/agent-stream/provider-adapter.ts +45 -3
  29. package/src/boot/daemon-lifecycle.ts +31 -1
  30. package/src/cli-adapters/provider-cli-adapter.ts +14 -3
  31. package/src/cli-adapters/pty-transport.ts +3 -0
  32. package/src/cli-adapters/session-host-transport.ts +8 -0
  33. package/src/commands/chat-commands.ts +38 -9
  34. package/src/commands/cli-manager.ts +2 -2
  35. package/src/commands/handler.ts +26 -3
  36. package/src/commands/router.ts +144 -1
  37. package/src/commands/stream-commands.ts +6 -3
  38. package/src/detection/cli-detector.ts +72 -29
  39. package/src/detection/ide-detector.ts +24 -8
  40. package/src/launch.ts +1 -1
  41. package/src/providers/acp-provider-instance.ts +19 -10
  42. package/src/providers/cli-provider-instance.ts +29 -1
  43. package/src/providers/extension-provider-instance.ts +24 -1
  44. package/src/providers/provider-loader.ts +144 -11
  45. package/src/shared-types.ts +2 -0
  46. package/src/status/normalize.ts +19 -2
  47. package/src/status/snapshot.ts +25 -14
@@ -14,6 +14,7 @@ import { registerExtensionProviders } from '../cdp/setup.js';
14
14
  import { DaemonCommandHandler } from './handler.js';
15
15
  import { DaemonCliManager } from './cli-manager.js';
16
16
  import { supportsExplicitSessionResume } from './cli-manager.js';
17
+ import type { HostedCliRuntimeDescriptor } from './cli-manager.js';
17
18
  import type { ProviderLoader } from '../providers/provider-loader.js';
18
19
  import type { ProviderInstanceManager } from '../providers/provider-instance-manager.js';
19
20
  import { launchWithCdp, killIdeProcess, isIdeRunning } from '../launch.js';
@@ -35,6 +36,18 @@ import * as fs from 'fs';
35
36
 
36
37
  // ─── Types ───
37
38
 
39
+ export interface SessionHostControlPlane {
40
+ getDiagnostics(payload?: { includeSessions?: boolean; limit?: number }): Promise<any>;
41
+ listSessions(): Promise<any[]>;
42
+ stopSession(sessionId: string): Promise<any>;
43
+ resumeSession(sessionId: string): Promise<any>;
44
+ restartSession(sessionId: string): Promise<any>;
45
+ sendSignal(sessionId: string, signal: string): Promise<any>;
46
+ forceDetachClient(sessionId: string, clientId: string): Promise<any>;
47
+ acquireWrite(payload: { sessionId: string; clientId: string; ownerType: 'agent' | 'user'; force?: boolean }): Promise<any>;
48
+ releaseWrite(payload: { sessionId: string; clientId: string }): Promise<any>;
49
+ }
50
+
38
51
  export interface CommandRouterDeps {
39
52
  commandHandler: DaemonCommandHandler;
40
53
  cliManager: DaemonCliManager;
@@ -56,6 +69,8 @@ export interface CommandRouterDeps {
56
69
  getCdpLogFn?: (ideType: string) => (msg: string) => void;
57
70
  /** Package name for upgrade detection ('adhdev' or '@adhdev/daemon-standalone') */
58
71
  packageName?: string;
72
+ /** Session host control plane */
73
+ sessionHostControl?: SessionHostControlPlane | null;
59
74
  }
60
75
 
61
76
  export interface CommandRouterResult {
@@ -70,6 +85,30 @@ const CHAT_COMMANDS = [
70
85
  ];
71
86
  const READ_DEBUG_ENABLED = process.argv.includes('--dev') || process.env.ADHDEV_READ_DEBUG === '1';
72
87
 
88
+ function toHostedCliRuntimeDescriptor(record: any): HostedCliRuntimeDescriptor | null {
89
+ if (!record || typeof record !== 'object') return null;
90
+ const runtimeId = typeof record.sessionId === 'string' ? record.sessionId : '';
91
+ const cliType = typeof record.providerType === 'string' ? record.providerType : '';
92
+ const workspace = typeof record.workspace === 'string' ? record.workspace : '';
93
+ if (!runtimeId || !cliType || !workspace) return null;
94
+ return {
95
+ runtimeId,
96
+ runtimeKey: typeof record.runtimeKey === 'string' ? record.runtimeKey : undefined,
97
+ displayName: typeof record.displayName === 'string' ? record.displayName : undefined,
98
+ workspaceLabel: typeof record.workspaceLabel === 'string' ? record.workspaceLabel : undefined,
99
+ lifecycle: typeof record.lifecycle === 'string' ? record.lifecycle as HostedCliRuntimeDescriptor['lifecycle'] : undefined,
100
+ recoveryState: typeof record.meta?.runtimeRecoveryState === 'string'
101
+ ? String(record.meta.runtimeRecoveryState)
102
+ : null,
103
+ cliType,
104
+ workspace,
105
+ cliArgs: Array.isArray(record.meta?.cliArgs) ? record.meta.cliArgs as string[] : [],
106
+ providerSessionId: typeof record.meta?.providerSessionId === 'string'
107
+ ? String(record.meta.providerSessionId)
108
+ : undefined,
109
+ };
110
+ }
111
+
73
112
  export class DaemonCommandRouter {
74
113
  private deps: CommandRouterDeps;
75
114
 
@@ -159,6 +198,99 @@ export class DaemonCommandRouter {
159
198
  }
160
199
  }
161
200
 
201
+ case 'session_host_get_diagnostics': {
202
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
203
+ const diagnostics = await this.deps.sessionHostControl.getDiagnostics({
204
+ includeSessions: args?.includeSessions !== false,
205
+ limit: Number(args?.limit) || undefined,
206
+ });
207
+ return { success: true, diagnostics };
208
+ }
209
+
210
+ case 'session_host_list_sessions': {
211
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
212
+ const sessions = await this.deps.sessionHostControl.listSessions();
213
+ return { success: true, sessions };
214
+ }
215
+
216
+ case 'session_host_stop_session': {
217
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
218
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
219
+ if (!sessionId) return { success: false, error: 'sessionId required' };
220
+ const record = await this.deps.sessionHostControl.stopSession(sessionId);
221
+ return { success: true, record };
222
+ }
223
+
224
+ case 'session_host_resume_session': {
225
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
226
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
227
+ if (!sessionId) return { success: false, error: 'sessionId required' };
228
+ const record = await this.deps.sessionHostControl.resumeSession(sessionId);
229
+ const hosted = toHostedCliRuntimeDescriptor(record);
230
+ if (hosted) {
231
+ await this.deps.cliManager.restoreHostedSessions([hosted]);
232
+ }
233
+ return { success: true, record };
234
+ }
235
+
236
+ case 'session_host_restart_session': {
237
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
238
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
239
+ if (!sessionId) return { success: false, error: 'sessionId required' };
240
+ const record = await this.deps.sessionHostControl.restartSession(sessionId);
241
+ const hosted = toHostedCliRuntimeDescriptor(record);
242
+ if (hosted) {
243
+ await this.deps.cliManager.restoreHostedSessions([hosted]);
244
+ }
245
+ return { success: true, record };
246
+ }
247
+
248
+ case 'session_host_send_signal': {
249
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
250
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
251
+ const signal = typeof args?.signal === 'string' ? args.signal : '';
252
+ if (!sessionId) return { success: false, error: 'sessionId required' };
253
+ if (!signal) return { success: false, error: 'signal required' };
254
+ const record = await this.deps.sessionHostControl.sendSignal(sessionId, signal);
255
+ return { success: true, record };
256
+ }
257
+
258
+ case 'session_host_force_detach_client': {
259
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
260
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
261
+ const clientId = typeof args?.clientId === 'string' ? args.clientId : '';
262
+ if (!sessionId) return { success: false, error: 'sessionId required' };
263
+ if (!clientId) return { success: false, error: 'clientId required' };
264
+ const record = await this.deps.sessionHostControl.forceDetachClient(sessionId, clientId);
265
+ return { success: true, record };
266
+ }
267
+
268
+ case 'session_host_acquire_write': {
269
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
270
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
271
+ const clientId = typeof args?.clientId === 'string' ? args.clientId : '';
272
+ const ownerType = args?.ownerType === 'agent' ? 'agent' : 'user';
273
+ if (!sessionId) return { success: false, error: 'sessionId required' };
274
+ if (!clientId) return { success: false, error: 'clientId required' };
275
+ const record = await this.deps.sessionHostControl.acquireWrite({
276
+ sessionId,
277
+ clientId,
278
+ ownerType,
279
+ force: args?.force !== false,
280
+ });
281
+ return { success: true, record };
282
+ }
283
+
284
+ case 'session_host_release_write': {
285
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
286
+ const sessionId = typeof args?.sessionId === 'string' ? args.sessionId : '';
287
+ const clientId = typeof args?.clientId === 'string' ? args.clientId : '';
288
+ if (!sessionId) return { success: false, error: 'sessionId required' };
289
+ if (!clientId) return { success: false, error: 'clientId required' };
290
+ const record = await this.deps.sessionHostControl.releaseWrite({ sessionId, clientId });
291
+ return { success: true, record };
292
+ }
293
+
162
294
  case 'list_saved_sessions': {
163
295
  const providerType = typeof args?.providerType === 'string'
164
296
  ? args.providerType.trim()
@@ -233,6 +365,11 @@ export class DaemonCommandRouter {
233
365
  if (!ideType) throw new Error('ideType required');
234
366
  const killProcess = args?.killProcess !== false; // default true
235
367
  await this.stopIde(ideType, killProcess);
368
+ try {
369
+ const results = await detectIDEs(this.deps.providerLoader);
370
+ this.deps.detectedIdes.value = results;
371
+ this.deps.providerLoader.setIdeDetectionResults(results, true);
372
+ } catch { /* ignore detection refresh errors */ }
236
373
  return { success: true, ideType, stopped: true, processKilled: killProcess };
237
374
  }
238
375
 
@@ -283,6 +420,11 @@ export class DaemonCommandRouter {
283
420
  }
284
421
  }
285
422
  this.deps.onIdeConnected?.();
423
+ try {
424
+ const results = await detectIDEs(this.deps.providerLoader);
425
+ this.deps.detectedIdes.value = results;
426
+ this.deps.providerLoader.setIdeDetectionResults(results, true);
427
+ } catch { /* ignore detection refresh errors */ }
286
428
  if (result.success && resolvedWorkspace) {
287
429
  try {
288
430
  const next = appendRecentActivity(loadState(), {
@@ -309,8 +451,9 @@ export class DaemonCommandRouter {
309
451
 
310
452
  // ─── Detect IDEs ───
311
453
  case 'detect_ides': {
312
- const results = await detectIDEs();
454
+ const results = await detectIDEs(this.deps.providerLoader);
313
455
  this.deps.detectedIdes.value = results;
456
+ this.deps.providerLoader.setIdeDetectionResults(results, true);
314
457
  return { success: true, detectedInfo: results };
315
458
  }
316
459
 
@@ -76,7 +76,7 @@ export function handleGetProviderSettings(h: CommandHelpers, args: any): Command
76
76
  return { success: true, settings: allSettings, values: allValues };
77
77
  }
78
78
 
79
- export function handleSetProviderSetting(h: CommandHelpers, args: any): CommandResult {
79
+ export async function handleSetProviderSetting(h: CommandHelpers, args: any): Promise<CommandResult> {
80
80
  const loader = h.ctx.providerLoader as ProviderLoader | undefined;
81
81
  const { providerType, key, value } = args || {};
82
82
  if (!providerType || !key || value === undefined) {
@@ -89,6 +89,7 @@ export function handleSetProviderSetting(h: CommandHelpers, args: any): CommandR
89
89
  const updated = h.ctx.instanceManager.updateInstanceSettings(providerType, allSettings);
90
90
  LOG.info('Command', `[set_provider_setting] ${providerType}.${key}=${JSON.stringify(value)} → ${updated} instance(s) updated`);
91
91
  }
92
+ await h.ctx.onProviderSettingChanged?.(providerType, key, value);
92
93
  return { success: true, providerType, key, value };
93
94
  }
94
95
  return { success: false, error: `Failed to set ${providerType}.${key} — invalid key, value, or not a public setting` };
@@ -133,7 +134,7 @@ function getCliScriptCommand(payload: any): { type: string; text?: string } | nu
133
134
 
134
135
  const command = payload.command;
135
136
  if (!command || typeof command !== 'object') return null;
136
- if (command.type !== 'send_message') return null;
137
+ if (command.type !== 'send_message' && command.type !== 'pty_write') return null;
137
138
 
138
139
  const text = typeof command.text === 'string'
139
140
  ? command.text.trim()
@@ -141,7 +142,7 @@ function getCliScriptCommand(payload: any): { type: string; text?: string } | nu
141
142
  ? command.message.trim()
142
143
  : '';
143
144
  if (!text) return null;
144
- return { type: 'send_message', text };
145
+ return { type: command.type, text };
145
146
  }
146
147
 
147
148
  function applyProviderPatch(h: CommandHelpers, args: any, payload: any): void {
@@ -191,6 +192,8 @@ async function executeProviderScript(h: CommandHelpers, args: any, scriptName: s
191
192
  const cliCommand = getCliScriptCommand(parsed.payload);
192
193
  if (cliCommand?.type === 'send_message' && cliCommand.text) {
193
194
  await adapter.sendMessage(cliCommand.text);
195
+ } else if (cliCommand?.type === 'pty_write' && cliCommand.text && adapter.writeRaw) {
196
+ adapter.writeRaw(cliCommand.text + '\r');
194
197
  }
195
198
  applyProviderPatch(h, args, parsed.payload);
196
199
  return { success: true, ...(parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : { result: parsed.payload }) };
@@ -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);
@@ -14,7 +14,7 @@ import type { ProviderModule } from './contracts.js';
14
14
  import type { ProviderInstance, ProviderState, ProviderEvent, InstanceContext } from './provider-instance.js';
15
15
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
16
16
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
17
- import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
17
+ import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
18
18
  import { StatusMonitor } from './status-monitor.js';
19
19
  import { ChatHistoryWriter, readChatHistory } from '../config/chat-history.js';
20
20
  import { LOG } from '../logging/logger.js';
@@ -133,6 +133,7 @@ export class CliProviderInstance implements ProviderInstance {
133
133
 
134
134
  // PTY spawn
135
135
  await this.adapter.spawn();
136
+ this.maybeAppendRuntimeRecoveryMessage(this.adapter.getRuntimeMetadata());
136
137
  if (this.providerSessionId) {
137
138
  const restoredHistory = readChatHistory(this.type, 0, 200, this.providerSessionId);
138
139
  if (restoredHistory.messages.length > 0) {
@@ -246,6 +247,7 @@ export class CliProviderInstance implements ProviderInstance {
246
247
  this.promoteProviderSessionId(parsedProviderSessionId);
247
248
  }
248
249
  const runtime = this.adapter.getRuntimeMetadata();
250
+ this.maybeAppendRuntimeRecoveryMessage(runtime);
249
251
  const parsedMessages = Array.isArray(parsedStatus?.messages) ? parsedStatus.messages : [];
250
252
  const controlValues = extractProviderControlValues(this.provider.controls, parsedStatus);
251
253
  if (controlValues) {
@@ -603,6 +605,32 @@ export class CliProviderInstance implements ProviderInstance {
603
605
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
604
606
  }
605
607
 
608
+ private maybeAppendRuntimeRecoveryMessage(runtime: PtyRuntimeMetadata | null): void {
609
+ if (!runtime?.restoredFromStorage || !runtime.runtimeId) return;
610
+
611
+ const recoveryState = String(runtime.recoveryState || '').trim();
612
+ if (!recoveryState) return;
613
+
614
+ let content = '';
615
+ if (recoveryState === 'auto_resumed') {
616
+ content = 'Session host restored this CLI after restart and reattached it from a saved snapshot.';
617
+ } else if (recoveryState === 'resume_failed') {
618
+ const errorSuffix = runtime.recoveryError ? ` Resume failed: ${runtime.recoveryError}` : '';
619
+ content = `Session host found this CLI after restart, but automatic resume failed.${errorSuffix}`;
620
+ } else if (recoveryState === 'host_restart_interrupted') {
621
+ content = 'Session host found this CLI in interrupted state after restart and is attempting to resume it.';
622
+ } else if (recoveryState === 'orphan_snapshot') {
623
+ content = 'Session host restored the last snapshot for this CLI, but the original runtime was not resumed automatically.';
624
+ } else {
625
+ content = `Session host restored this CLI after restart (${recoveryState}).`;
626
+ }
627
+
628
+ this.appendRuntimeSystemMessage(
629
+ content,
630
+ `runtime_recovery:${runtime.runtimeId}:${recoveryState}`,
631
+ );
632
+ }
633
+
606
634
  private appendRuntimeSystemMessage(content: string, dedupKey: string, receivedAt = Date.now()): void {
607
635
  const normalizedContent = String(content || '').trim();
608
636
  if (!normalizedContent) return;